Skip to main content

Command Palette

Search for a command to run...

The App Router is a boundary system, not a routing system

Five boundaries hide in the folder tree — network, segment, render, mutation, cache

Updated
13 min readView as Markdown
The App Router is a boundary system, not a routing system

Next.js 16 renamed middleware.ts to proxy.ts. The release notes give a one-line reason: to make the app's network boundary explicit. In the same release, caching stopped happening unless you ask for it.

Two changes, one motive. And that motive is the most useful thing to understand about the App Router, because it isn't really a routing system.

Routing is the part you meet first. Folders become URLs, and most of the config file you used to write disappears. But the folder tree isn't answering what URL is this. It's answering where does this code run, and what crosses the wire. Layouts, 'use client', Route Handlers, Server Actions, proxy.ts, "use cache" — those look like ten unrelated features until you notice they're five answers to that one question.

This post is written from the Next.js 16 docs and release notes rather than from an incident, so where I'm inferring rather than citing, I say so.

The mental model that runs out

The model most people start with is: a folder is a URL, and the App Router is the Pages Router with nicer nesting.

That model is not wrong. It's incomplete, and it runs out at exactly the moment you hit your first confusing error — a hook that won't work in a file you didn't think was special, an auth check in a layout that doesn't fire, a query that returns stale data after a mutation.

It's worth knowing what the Pages Router actually couldn't do, because the App Router is the answer to it. In pages/, the unit of the server/client split was the page. getServerSideProps ran on the server, the default export ran on both, and there was no way to say "this component, three levels deep, is server-only." One boundary, drawn per route, all-or-nothing. The App Router (shipped in Next.js 13, October 2022; stable in the 13.4 release the following spring) moves that boundary from the route to the component.

Once the boundary is per-component, you need a way to say where each one sits. That's what the directives and the special filenames are for.

flowchart LR
    R[Request] --> P[proxy.ts<br/>network boundary]
    P --> S[Segment match<br/>layouts + page]
    S --> C{Cache boundary<br/>use cache?}
    C -->|hit| H[Cached shell]
    C -->|miss| SC[Server Components render]
    SC --> RB[Render boundary<br/>use client]
    RB --> B[HTML + RSC payload + JS]

Boundary 1: the network edge, and what it must not decide

proxy.ts runs before routing. In Next.js 16 it runs on the Node.js runtime, and per the version 16 upgrade guide that runtime is not configurable — middleware.ts survives only for Edge runtime cases and is deprecated.

// proxy.ts
import { NextResponse, type NextRequest } from 'next/server';

export default function proxy(request: NextRequest) {
  const session = request.cookies.get('session');

  // cheap optimistic redirect — keeps logged-out users off a shell they'd fail to fill
  if (!session) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = { matcher: ['/dashboard/:path*', '/projects/:path*'] };

Read that comment again, because it's the part people get wrong. This is a routing decision, not an authorization decision. The cookie exists; that's all we checked. Whether it's valid, whether it belongs to a user who can see this project — none of that happened here.

Warning — Treating the proxy layer as your authorization boundary is the exact shape of the Next.js middleware authorization bypass disclosed in March 2025 (CVE-2025-29927), where a crafted header let requests skip the middleware entirely. Read Next.js's own advisory for that CVE before you decide how much weight this layer carries.

The proxy runs on every matched request, prefetches included, which implies a per-request database round trip here is more expensive than it looks. I'd keep it to cookie presence, locale, and redirects, and put the real check next to the data.

Boundary 2: segments, and what survives a navigation

Here's a folder tree for a SaaS dashboard, which is the shape most of these features were designed around:

app/
├─ (marketing)/
│  ├─ layout.tsx              # public shell: nav, footer
│  ├─ page.tsx                # /
│  └─ pricing/page.tsx        # /pricing
├─ (app)/
│  ├─ layout.tsx              # signed-in shell: sidebar, org switcher
│  ├─ dashboard/page.tsx      # /dashboard
│  └─ projects/
│     ├─ layout.tsx           # project tabs — persists across the routes below
│     └─ [projectId]/
│        ├─ page.tsx          # /projects/abc
│        └─ settings/page.tsx # /projects/abc/settings
├─ api/
│  └─ webhooks/stripe/route.ts
└─ layout.tsx                 # html, body, fonts, providers

(marketing) and (app) are route groups. The parentheses mean the folder contributes no URL segment — it exists so two halves of the same app can have completely different shells without a fake /app prefix. That's the main reason to reach for them; grouping files for tidiness alone usually isn't worth the indirection.

Dynamic segments come in as a promise now:

// app/(app)/projects/[projectId]/page.tsx
export default async function ProjectPage({
  params,
}: {
  params: Promise<{ projectId: string }>;
}) {
  // async since Next.js 15; synchronous access was removed in 16
  const { projectId } = await params;
  return <ProjectHeader id={projectId} />;
}

The thing that makes a layout a layout is persistence. Navigate from /projects/abc to /projects/abc/settings and the project layout does not remount. Open panels stay open, scroll position holds, and state inside it survives. Next.js 16 leans on this further at the network level: the release notes describe layout deduplication in prefetching, so a page with fifty links to the same layout downloads that layout once instead of fifty times.

Persistence is also why a layout is the wrong place for an auth check. The Next.js authentication guide is direct about it: returning null in a layout for an unauthorized user is not recommended, because an app has multiple entry points and that pattern won't stop nested route segments or Server Actions from being reached.

Note — In Next.js 16, every parallel route slot needs an explicit default.js. Builds fail without one. If you're upgrading and hit that, it's a real behaviour change, not a config mistake.

When not to add a layout: if the only thing you're sharing is a header, import a component. A layout earns its keep when something must survive navigation or when a whole subtree needs the same shell.

Boundary 3: the render split, where one directive moves a lot of code

Server Components are the default. There's no directive for them, which is itself the point — the special case is the client.

'use client' doesn't mark a file. It marks an entry point. Everything that file imports, and everything those modules import, joins the client graph and ships to the browser. That's the single most misread line in the framework.

// ❌ app/(app)/dashboard/page.tsx
'use client'; // one <button> just pulled the whole page — and its imports — into the bundle

import { HeavyChart } from '@/components/heavy-chart';
import { db } from '@/lib/db'; // server-only module in a client graph: build error

export default function Dashboard() {
  const [range, setRange] = useState('30d');
  // ...
}

The fix is to push the directive down to the leaf that actually needs state, and let the page stay on the server:

// ✅ app/(app)/dashboard/page.tsx — stays a Server Component
import { getRevenue } from '@/lib/queries';
import { RangePicker } from './range-picker'; // 'use client' lives in there

export default async function Dashboard({
  searchParams,
}: {
  searchParams: Promise<{ range?: string }>;
}) {
  const { range = '30d' } = await searchParams;
  const rows = await getRevenue(range); // runs on the server; the query never ships

  return (
    <>
      <RangePicker value={range} />
      <RevenueTable rows={rows} />
    </>
  );
}

Two rules make mixing work. A Client Component can't import a Server Component — but it can render one handed to it as a prop, because by then it's already-rendered output rather than a module reference:

// ✅ app/(app)/layout.tsx — Sidebar is a Client Component, children stay server-rendered
import { Sidebar } from '@/components/sidebar';

export default function AppLayout({ children }: { children: React.ReactNode }) {
  return <Sidebar>{children}</Sidebar>; // slot, not import
}

And anything crossing the boundary as a prop has to be serialisable. Functions and class instances don't make it.

Where this trips people: 'use client' does not mean client-only. Those components still render on the server to produce the initial HTML. Touching window in the component body still breaks, and the directive is not the fix — useEffect or a dynamic import is.

When not to move something to the server: if a subtree is driven by browser state, a canvas, or a third-party UI library built on context, the server buys you nothing and costs you a round trip. The bundle argument is also weaker than the marketing suggests on a heavily interactive authenticated app, where most of what's left after the split is genuinely interactive. I'd measure before restructuring.

Boundary 4: mutations, where Server Actions and Route Handlers are the same door

The "Server Actions or API routes?" question resolves the moment you accept that both are HTTP POST endpoints. One of them just doesn't look like it.

// app/(app)/projects/actions.ts
'use server';

import { z } from 'zod';
import { updateTag } from 'next/cache';
import { requireUser } from '@/lib/auth';
import { renameProjectForUser } from '@/lib/queries';

const Rename = z.object({
  projectId: z.string().uuid(),
  name: z.string().min(1).max(80),
});

export async function renameProject(formData: FormData) {
  const user = await requireUser();                    // the action authenticates itself
  const input = Rename.parse(Object.fromEntries(formData)); // and validates its own input

  await renameProjectForUser(input.projectId, input.name, user.id); // ownership in the query
  updateTag(`project-${input.projectId}`);             // Next.js 16: read-your-writes
}

Those first two lines aren't ceremony. The Server Actions guide states that render-time gating is not a security boundary, because requests can be sent without going through the UI. Next.js does ship real protections here — actions accept POST only, action IDs are encrypted at build time, unused actions are stripped from the bundle so they never get an endpoint — and the same page says plainly that framework protections don't replace application-level checks.

A Route Handler is the same capability with the HTTP left visible:

// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const signature = request.headers.get('stripe-signature');
  if (!signature) return new Response('missing signature', { status: 400 });
  // verify, then process
  return Response.json({ received: true });
}
Server Action Route Handler
Caller your own React tree anything that speaks HTTP
Methods POST only GET, POST, PUT, PATCH, DELETE
URL generated action ID, not yours to choose you pick the path
Returns a serialisable value to the caller a full Response: status, headers, streams
Works without JS yes, via <form action={...}> no
Auth and validation you write it you write it

The verdict, scoped: for mutations triggered by your own UI, I'd reach for a Server Action, and the progressive-enhancement row is most of the reason. The moment a second consumer exists — a webhook, a mobile client, a cron job, anything that needs a stable URL or a verb other than POST — that's a Route Handler, and trying to force it through an action is how you end up re-implementing HTTP badly.

When not to use a Server Action: anything large or long-running. Actions carry a request body size limit configured in next.config, so a multi-megabyte upload wants a Route Handler or a direct-to-storage signed URL instead.

sequenceDiagram
    participant U as Browser
    participant N as Next.js server
    participant D as Database
    U->>N: POST (action id in Next-Action header)
    N->>N: requireUser + validate input
    N->>D: write
    N->>N: updateTag(project-abc)
    N-->>U: RSC payload with re-rendered tree

Boundary 5: caching, which is now something you ask for

This is the change that most invalidates older tutorials. Per the Next.js 16 release notes, caching under Cache Components is entirely opt-in: dynamic code in a page, layout, or API route executes at request time by default. It's enabled by a flag:

// next.config.ts
const nextConfig = { cacheComponents: true };
export default nextConfig;

Then you mark what should be cached, and for how long:

// lib/queries.ts
import { cacheLife, cacheTag } from 'next/cache';

export async function getPlans() {
  'use cache';
  cacheLife('days');
  cacheTag('plans');
  return db.plan.findMany(); // same for every visitor — safe to cache
}

Invalidation split into three APIs in 16, and picking between them is a boundary question too:

  • revalidateTag(tag, profile) — now requires a cacheLife profile as its second argument, and gives stale-while-revalidate. For content that can be eventually consistent.
  • updateTag(tag) — Server Actions only, read-your-writes. The user sees their own change immediately.
  • refresh() — Server Actions only, refreshes uncached data without touching the cache at all.

When not to cache: anything keyed on the session. Caching a per-tenant query is how one customer's numbers end up rendered for another, and the failure is silent until it isn't. getPlans() above is cacheable precisely because it's identical for everyone.

Streaming sits next to this. A loading.tsx or a <Suspense> boundary lets the shell go out immediately while the slow query resolves — which is the same trade in a different currency: what can I send now, and what has to wait.

What the model explains

Four things stop being surprising once you read the tree as boundaries rather than URLs:

  • Moving 'use client' up one file grew the bundle, because you moved an entry point, not a flag.
  • The auth check in the layout didn't stop anything, because layouts persist and aren't an entry point for actions.
  • A Stripe webhook can't be a Server Action, because Stripe can't call one — there's no URL to give it.
  • Big apps get organised by boundary first and feature second: route groups split the shells, lib/ holds the server-only data layer, and the review question becomes "which boundary does this cross?"

That last one is the part that scales across a team. It's a question a reviewer can ask without reading the whole diff.

Where this model leaks

It leaks in at least four places, and pretending otherwise would be the marketing version of this post.

'use client' components still server-render, so "client component" is a bundling term, not an execution guarantee. proxy.ts is Node-only in 16, so if you actually need Edge, you're on the deprecated file. Cache Components ship behind a flag and the ergonomics are still moving — check the cacheComponents config docs for the version you're on rather than trusting any blog post, including this one. And the boundaries are enforced by a compiler and a set of conventions, not by the runtime: an action that's used anywhere in your app is a live endpoint whether or not the UI ever calls it.

The honest limitation of the whole frame: it tells you where code runs, not whether that code is any good. Nothing here stops you writing an N+1 in a Server Component. The boundary just moves it off the network tab, where it's harder to notice.

One thing I haven't found a clean answer to — has anyone managed to express "this query is per-user, never cache it" so that it fails the build rather than a code review? Every version I've seen relies on convention, and convention is exactly what breaks at the sixth engineer.

3 views