Skip to main content

Command Palette

Search for a command to run...

Modern Next.js

Routing, Layouts, Server Components, API Routes & Server Actions

Updated
18 min readView as Markdown
Modern Next.js
S
Software Developer | Full Stack Developer |

Can a React application have both frontend and backend inside the same project?

For most of React's history, the honest answer was "not really, not cleanly." A React app was a frontend. If you needed a database call, an authentication check, or a form submission handled securely, you reached for a separate Express server, a serverless function tucked away in another repo, or a BFF (backend-for-frontend) layer that someone on the team had to own and deploy independently.

Modern Next.js changes that answer to a confident yes. A single Next.js project can define your routes, your UI, your data-fetching logic, your API endpoints, and your server-side mutations, all in one codebase, deployed as one unit. This post walks through how Next.js got here, and how the App Router, Server Components, API Routes, and Server Actions fit together to make that possible.

We'll use a SaaS dashboard application as a running example throughout, since it's a good stress test for routing, layouts, auth, and data fetching all at once.

Evolution of Next.js

Next.js started as a solution to a narrow but painful problem: React alone gives you no routing, no server rendering, and no file-based structure. Early Next.js (the Pages Router, pages/) solved this by mapping files to routes and offering getServerSideProps / getStaticProps for data fetching.

That model worked well for content sites and small apps, but it started to strain under the weight of large applications:

  • Layout duplication. Every page had to re-implement its own shell (nav, sidebar, providers) unless you built custom _app.js gymnastics.

  • All client-side by default. Every component shipped to the browser as JavaScript, even components that never needed interactivity, like a static product description or a table header.

  • Data-fetching methods tied to the page, not the component. You couldn't easily fetch data deep inside a component tree without prop-drilling or client-side requests.

  • No good primitive for backend mutations. You either wrote an API route and called it with fetch, or reached for a separate backend entirely.

The App Router (app/), introduced as the new default, was Next.js's answer to these pain points. It's built on React Server Components, nested layouts, and a routing convention designed specifically for large, full-stack applications, not just marketing pages.

The result is what people now mean when they say "modern full-stack React development": one framework, one deployment, one mental model for UI, data, and backend logic.

Old world                          Modern world (App Router)
─────────────                      ──────────────────────────
React (frontend)                   Next.js App Router
   +                                  ├─ Routing
Express/Node (backend)                ├─ Layouts
   +                                  ├─ Server Components (data + rendering)
Separate deployments                  ├─ API Routes (external-facing backend)
   +                                  └─ Server Actions (mutations, forms)
Manual glue code                   One project, one deploy

Understanding the App Router

The App Router uses folder-based routing: the file system is the route map. This is the foundation everything else in this post builds on, so it's worth being precise about it.

Route creation:

Every route is a folder inside app/, and the UI for that route lives in a page.tsx file inside it.

app/
├─ page.tsx              → /
├─ pricing/
│  └─ page.tsx           → /pricing
└─ dashboard/
   └─ page.tsx           → /dashboard

Only page.tsx (and a few other reserved filenames like layout.tsx, loading.tsx, error.tsx) are routable. Any other file, a component, a helper, a hook, can live alongside them without becoming an accidental route.

Dynamic routes:

Square brackets define dynamic segments, useful for anything identified by an ID or slug, a customer, an invoice, a blog post.

app/
└─ dashboard/
   └─ invoices/
      └─ [invoiceId]/
         └─ page.tsx     → /dashboard/invoices/123

Nested routes:

Because folders nest, routes nest naturally. /dashboard/settings/billing is just three folders deep, each with its own page.tsx if it needs one.

Route groups:

Sometimes you want to organize folders for clarity or apply a shared layout to a set of routes without that grouping showing up in the URL. Parentheses create a route group:

app/
├─ (marketing)/
│  ├─ page.tsx           → /
│  └─ pricing/page.tsx   → /pricing
└─ (app)/
   └─ dashboard/page.tsx → /dashboard

The (marketing) and (app) segments are invisible in the URL, they exist purely to organize files and scope layouts.

Organizing large applications:

For a dashboard SaaS product, a realistic structure separates marketing pages, authenticated app pages, and shared logic:

app/
├─ (marketing)/          → public site
├─ (auth)/                → login, signup
├─ (dashboard)/           → authenticated app
│  ├─ layout.tsx
│  ├─ analytics/
│  ├─ billing/
│  └─ settings/
└─ api/                   → API Routes

This is where folder-based routing stops being a convenience and starts being an architectural tool.

Layouts in Next.js

Why layouts exist?

Before layouts, "shared UI" meant either duplicating markup across pages or wrapping everything in a top-level component that re-rendered on every navigation, sidebar, nav bar, and all. Neither is great: one duplicates code, the other wastes rendering work and loses state (like a sidebar's scroll position) between page transitions.

Shared UI across routes:

A layout.tsx file wraps every page.tsx beneath it in the folder tree. A dashboard layout can define the sidebar and top nav once, and every dashboard page automatically renders inside it.

app/(dashboard)/layout.tsx
   └─ wraps
      ├─ /dashboard
      ├─ /dashboard/analytics
      └─ /dashboard/billing

Nested layouts:

Layouts compose. A root layout can hold global providers and fonts; a dashboard layout inside it adds the sidebar; a settings layout inside that adds a settings-specific tab bar. Each layer only concerns itself with its own slice of UI.

Persistent layouts:

This is the detail that makes layouts more than a code-organization trick: when a user navigates from /dashboard/analytics to /dashboard/billing, the shared (dashboard)/layout.tsx does not remount. The sidebar keeps its scroll position, any client-side state inside it survives, and only the inner page content re-renders. This is a meaningful UX and performance win for anything with persistent chrome, dashboards, admin panels, email clients.

Building scalable applications using layouts:

For large apps, layouts become a way to scope concerns: authentication checks at the (dashboard)/layout.tsx level, billing-specific data fetching at the billing/layout.tsx level, and so on, each layer responsible only for what's beneath it.

Route Organization Strategies

There's no single "correct" folder structure, but a few patterns show up repeatedly in production apps.

Feature-based organization groups everything related to a feature, its routes, components, and server logic, together, rather than splitting by file type across the codebase. This scales better for teams than a strict components/, pages/, hooks/ split, because a developer working on "billing" touches one area instead of five.

Dashboard applications typically separate the authenticated shell from everything else, so the sidebar/nav layout only applies where it's needed:

app/
├─ (public)/     → landing page, pricing, blog
├─ (auth)/       → login, signup, password reset
└─ (dashboard)/  → everything behind login

Public vs protected routes is usually handled with a combination of route groups (for organization) and middleware (for enforcement, more on this in the authentication section).

Scaling route structures generally means: keep route groups shallow, push shared logic into layouts instead of duplicating it into every page, and colocate feature-specific components inside their route folder rather than a distant global components/ directory once a feature outgrows a single file.

Server Components

This is the single biggest architectural shift in the App Router, and arguably in React over the last few years.

Why Server Components were introduced?

In the Pages Router world, and in client-rendered React generally, every component ships as JavaScript to the browser, gets parsed, gets executed, and gets hydrated, regardless of whether it does anything interactive. A component that just renders a paragraph of text pulled from a database still costs bundle size and hydration time.

React Server Components (RSC) let a component render entirely on the server, send only the resulting HTML (and a compact serialized description) to the client, and ship zero JavaScript for that component. In the App Router, every component is a Server Component by default, you opt into client behavior explicitly, not the other way around.

How they differ from Client Components?

Server Component Client Component
Where it runs Server only Server (for initial HTML) + browser
Can use useState/useEffect No Yes
Can access browser APIs No Yes
Can query a database / read secrets directly Yes No
Ships JavaScript to the browser No Yes
Opt-in marker Default "use client" at top of file

Benefits of server-side execution:

A Server Component can talk to a database, read environment secrets, or call an internal service directly in its render function, no API layer required in between. That's a genuine simplification: the "fetch data, then render" dance that used to require useEffect + loading states can, for a huge number of cases, just be an await inside the component.

Reduced JavaScript bundles and performance improvements:

Because non-interactive UI, page shells, static content, data-heavy tables, marketing sections, never ships as JavaScript, the client bundle shrinks dramatically in a typical dashboard app. Less JavaScript means less to download, parse, and hydrate, which shows up directly in metrics like Time to Interactive.

Client Components

Server Components can't do everything, and that's by design, some things genuinely require the browser.

Interactive UI requirements — a dropdown that opens on click, a modal, a drag-and-drop board, need event handlers, which only exist in the browser.

State management — anything using useState, useReducer, or a client-side state library needs to be a Client Component, since state lives in the browser's memory across re-renders.

Browser APIslocalStorage, window, geolocation, IntersectionObserver, are only available client-side.

Event handlingonClick, onChange, onSubmit handlers require the component to be interactive in the browser.

You mark a file as a Client Component with "use client" at the top. Importantly, this marks a boundary, not a blanket rule, a Client Component can still be a small, focused island inside an otherwise server-rendered page.

When Client Components are necessary:

A good rule of thumb for a dashboard app: the page shell, the data table's static structure, and the row data itself can be Server Components. The sort/filter controls, a "select all" checkbox, or a live-updating chart need to be Client Components, because they hold interactive state.

Mixing Server and Client Components

Modern composition patterns:

The most common, and most effective, pattern is Server Components on the outside, Client Components on the inside: a Server Component fetches data and renders the page, and passes that data as props into small Client Components that handle interactivity.

ServerPage (fetches invoices from DB)
 └─ renders <InvoiceTable data={invoices} />
      └─ InvoiceTable is a Client Component
           handling sorting, row selection, etc.

Client Components cannot import Server Components directly (a Client Component can't "reach back" into the server), but they can receive Server Components as children, a useful pattern when you want a client-side wrapper (say, a modal) around server-rendered content.

Data fetching strategies:

Because Server Components can fetch data directly, the old pattern of "render loading spinner → fetch in useEffect → render data" is often unnecessary for the initial page load. Data fetching happens before the HTML is even sent.

Performance considerations:

The tradeoff: every Client Component boundary adds to the JavaScript bundle and requires hydration. The goal isn't "avoid Client Components", it's keeping the client boundary as small and as deep in the tree as possible, so interactivity is cheap and precise rather than blanket.

Building maintainable applications:

A useful mental model for teams: default to Server Components, and treat "use client" as a deliberate, reviewed decision, not a habit carried over from pre-App-Router React where everything was a client component by necessity.

API Routes

What API Routes are, and why they exist?

API Routes (app/api/.../route.ts) let you define backend HTTP endpoints inside the same Next.js project — a GET, POST, PUT, or DELETE handler that returns JSON (or anything else) rather than HTML.

app/
└─ api/
   └─ invoices/
      └─ route.ts     → GET/POST /api/invoices

Backend functionality inside Next.js:

This is genuinely a backend, not a workaround: it can query a database, verify a JWT, call third-party services, and run whatever server logic you need, all deployed alongside the frontend.

Common use cases:

  • Authentication endpoints — login, logout, token refresh, OAuth callback handlers.

  • CRUD operations exposed to external consumers — a public or partner-facing API.

  • Webhooks — endpoints that third parties (Stripe, GitHub, a payment processor) call into.

  • Internal APIs consumed by a mobile app or a separate frontend that isn't part of this Next.js project.

The defining trait of an API Route is that it produces a stable, callable HTTP endpoint — something that can be hit by curl, a mobile client, a webhook sender, or any consumer that isn't necessarily this app's own UI.

Server Actions

Why Server Actions were introduced?

Before Server Actions, even a simple "submit this form and save it to the database" required: a client-side form, an API Route to handle the POST, a fetch call wiring the two together, and manual loading/error state. For a form-heavy application (which most SaaS dashboards are), that's a lot of boilerplate for something conceptually simple.

Server Actions are functions marked with "use server" that can be called directly from a form or a component — no API Route, no manual fetch, no separate endpoint to define and maintain.

// app/dashboard/invoices/actions.ts
"use server";

export async function createInvoice(formData: FormData) {
  const amount = formData.get("amount");
  await db.invoice.create({ data: { amount } });
}
<form action={createInvoice}>
  <input name="amount" />
  <button type="submit">Create Invoice</button>
</form>

Next.js handles the network request under the hood, including progressive enhancement, the form can work even before client JavaScript finishes loading.

Reducing API boilerplate:

For internal mutations, the ones only your own UI ever calls, Server Actions remove an entire layer: no route file, no manual request/response shaping, no client-side fetch wrapper.

Form handling, mutations, and data updates:

Server Actions are the natural home for "create," "update," and "delete" operations triggered from the UI: creating an invoice, updating a profile, deleting a row. They can also be called outside of a <form>, directly from a Client Component's event handler, for things like an optimistic "like" button.

Modern Next.js workflows:

Server Actions pair naturally with revalidation, after a mutation, the action can tell Next.js which cached data is now stale, and the UI updates without a manual client-side refetch.

API Routes vs Server Actions

These two overlap in capability but serve different architectural roles, and choosing wrong tends to show up later as either unnecessary boilerplate or a missing public contract.

API Routes Server Actions
Primary purpose Expose a callable HTTP endpoint Perform a server-side mutation triggered by your own UI
Best for External consumers: mobile apps, webhooks, third-party integrations, public APIs Internal forms and mutations within the same app
Boilerplate You define request parsing, response shaping Minimal — call the function like any other
Versioning / stable contract Easy — it's a URL with a defined shape Harder — it's not designed as a public contract
Progressive enhancement No, requires JS to call fetch Yes, forms work without client JS
Caching/revalidation integration Manual Built-in via revalidatePath / revalidateTag
Reusable outside this app Yes No — tightly coupled to this Next.js app

Tradeoffs, in practice: if you need a stable endpoint that a mobile team, a partner, or a webhook provider will call, that's an API Route — Server Actions aren't designed to be a versioned public contract. If it's a form or mutation that only your own dashboard triggers, a Server Action usually means less code, fewer moving parts, and built-in revalidation. Many real applications use both: API Routes for the public/external surface, Server Actions for internal UI mutations.

Authentication Architecture

A SaaS dashboard is a good example of the full authentication picture the App Router enables.

Login systems typically use a Server Action or an API Route to verify credentials and issue a session token or cookie.

Session management in the App Router commonly relies on HTTP-only cookies, checked either in Server Components (to decide what to render) or in middleware (to decide whether to allow the request at all).

Middleware runs before a request reaches a route, making it the natural place to enforce "you must be logged in to see this" at the edge, before any page-level code executes.

Protected routes are usually organized so the entire authenticated section of the app sits under one route group with one layout, letting a single middleware rule or layout-level check protect everything beneath it, rather than repeating auth checks per page.

Data Fetching Patterns

Server-side data fetching: a Server Component await-ing a database call or internal API — is the default in the App Router, and it means data is ready before HTML is sent.

Client-side data fetching: still has a place: data that's user-specific and changes after load, or data fetched in response to client interaction (search-as-you-type, infinite scroll), often uses a client-side library on top of a Client Component.

Streaming, via loading.tsx and React Suspense boundaries, lets slow parts of a page render progressively instead of blocking the whole page behind the slowest query, a dashboard can show its layout and fast widgets immediately while a heavier analytics chart streams in afterward.

Caching and revalidation in the App Router operate at multiple levels, the fetch cache, the full-route cache, and the client-side router cache, and Server Actions can explicitly invalidate specific paths or tags after a mutation, so the UI reflects fresh data without a manual refresh.

Building Large-Scale Applications

For teams working in the same codebase, a few practices consistently show up in production Next.js apps:

  • Feature-based folders inside route groups, so a "billing" feature's page, components, actions, and types live together rather than scattered across the repo.

  • Colocating Server Actions with the feature that owns them (billing/actions.ts) rather than a single global actions.ts file that becomes a merge-conflict magnet.

  • Shared UI in a top-level components/ directory only for things genuinely used across many features, everything else stays local to its route.

  • Clear separation of public, auth, and app route groups, so permission boundaries map directly onto the folder structure instead of being scattered through conditional logic.

  • Consistent data-access layer (a lib/db or similar) that both Server Components and Server Actions call into, rather than duplicating query logic in both places.

None of this is unique to Next.js, but the App Router's folder-based routing gives these conventions a natural home instead of requiring a bespoke structure invented from scratch.

Performance Optimization

Several App Router features work together to keep large applications fast:

  • Server rendering by default means the client receives ready-to-paint HTML, rather than an empty shell waiting for JavaScript.

  • Streaming lets the fastest parts of a page reach the user first, instead of the whole page waiting on the slowest data source.

  • Reduced client bundles, since only Client Components ship JavaScript, a page with mostly static or server-rendered content can be nearly JS-free on first load.

  • Selective hydration means the browser can hydrate interactive islands independently and prioritize the ones the user is actually interacting with, rather than hydrating the entire page in one blocking pass.

  • Built-in caching layers (fetch cache, route cache) reduce redundant server work across navigations.

The overall shift is from "ship everything, hydrate everything, fetch on the client" to "render what you can on the server, ship only what needs interactivity, and stream the rest."

The Future of Full-Stack React

Next.js's App Router represents a broader trend: the line between "frontend framework" and "full-stack framework" is disappearing. Routing, layouts, rendering, backend endpoints, and mutations are converging into one set of conventions instead of being split across separate tools stitched together by hand.

A few reasons this direction seems likely to continue:

  • One deployment, one mental model. Teams increasingly want fewer moving parts, not more, one repo and one framework covering frontend and backend reduces coordination overhead.

  • Server Components address a real, longstanding problem: bundle size and unnecessary client-side JavaScript, rather than being a trend for its own sake, which is why the underlying React model (not just Next.js's implementation of it) is being adopted more broadly.

  • Enterprise adoption tends to follow whichever approach reduces the number of systems a team has to maintain and secure; a framework that handles routing, rendering, and backend logic together is an easier sell than five separate services.

None of this makes API Routes, Client Components, or client-side fetching obsolete, as this post has tried to show throughout, each tool has a real use case and a real tradeoff. The shift isn't "Server Components replace everything." It's that Next.js now gives full-stack React developers a coherent set of primitives, routing, layouts, Server Components, API Routes, Server Actions, to reach for the right tool deliberately, instead of bolting a backend onto a frontend framework after the fact.