Skip to content
All articles
Architecture

The Next.js App Router architecture that actually scales

How we lay out App Router projects: route groups, server and client boundaries, data fetching without waterfalls, caching, and where state really belongs.

Daniel OkoroFrontend lead
7 min read

Most App Router projects don't fall over because of a bad framework decision. They fall over because the directory tree grew organically for six months, "use client" crept upward into layouts, and nobody could say where a given piece of state actually lived. We've inherited enough of those codebases to have opinions. Here's the structure we start from on every Next.js build.

Directory layout

Route groups do the heavy lifting. A typical project root looks like this:

text
src/
  app/
    (marketing)/           # public, static, own layout
      page.tsx
      pricing/page.tsx
    (auth)/
      layout.tsx
      login/page.tsx
    (app)/                 # authenticated surface
      layout.tsx           # sidebar, session guard
      dashboard/
        page.tsx
        loading.tsx
        _components/revenue-chart.tsx
      invoices/[id]/page.tsx
    api/webhooks/stripe/route.ts
  components/ui/           # primitives only
  features/invoices/       # queries, actions, schema
  lib/                     # db, auth, stripe, utils

Three boundaries, enforced in review:

  • src/components/ui holds primitives with no business knowledge. A Button never imports from features/.
  • src/features/<domain> holds queries, server actions, zod schemas, domain types. Most of the code lives here.
  • src/lib holds infrastructure clients and pure helpers. It never imports React.

Anything used by exactly one route goes in a private _components folder next to that route. The underscore keeps it out of routing, and deleting the route deletes its UI. Promotion to features/ happens on the second consumer, not the first.

We cap nesting at roughly four segments. Past that, imports turn into ../../../../ archaeology. Deep hierarchies usually mean the URL is modeling database joins instead of user intent.

The server/client boundary

The rule: client components are leaves. Interactivity lives at the edge of the tree, not the trunk.

Putting "use client" on a layout is the most expensive mistake in App Router. Everything imported below that boundary compiles into the client bundle, including your date library and your markdown renderer. On one rescue project the authenticated layout was a client component and the shared entry chunk was 612 KB gzipped. Moving the interactive shell one level down and passing children through brought it to 188 KB.

That children slot is the workhorse. A client component can render server content as long as it arrives as a prop rather than an import:

tsx
// src/components/app-shell.tsx
"use client";

import { useState, type ReactNode } from "react";

export function AppShell({
  sidebar,
  children,
}: {
  sidebar: ReactNode;
  children: ReactNode;
}) {
  const [open, setOpen] = useState(false);

  return (
    <div className="flex min-h-screen">
      <aside data-open={open} className="w-64 shrink-0 border-r">{sidebar}</aside>
      <main className="flex-1">
        <button onClick={() => setOpen((v) => !v)}>Toggle</button>
        {children}
      </main>
    </div>
  );
}

sidebar and children can both be server components that hit the database. The client shell only owns the open/closed boolean. Props crossing the boundary must be serializable: no functions, no class instances, no Date objects buried in ORM models you forgot to map.

Data fetching

Fetch where the data is used. A component rendering invoice totals should query invoice totals, not receive them threaded through four layers of props. React's cache() dedupes identical calls within a request, so two components asking for the same organization do one query.

Waterfalls are the real cost. Sequential awaits mean the second query waits on the first for no reason. We use Promise.all for independent reads and Suspense to stream the slow ones:

tsx
// src/app/(app)/dashboard/page.tsx
import { Suspense } from "react";
import { getOrg, getUsageSummary } from "@/features/billing/queries";
import { RevenueChart } from "./_components/revenue-chart";
import { ActivityFeed } from "./_components/activity-feed";

export default async function DashboardPage() {
  const [org, usage] = await Promise.all([getOrg(), getUsageSummary()]);

  return (
    <>
      <header>
        <h2>{org.name}</h2>
        <p>{usage.seatsUsed} of {usage.seatLimit} seats</p>
      </header>

      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart orgId={org.id} />
      </Suspense>
      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed orgId={org.id} />
      </Suspense>
    </>
  );
}

RevenueChart runs an aggregate that takes 900ms against a year of data. Wrapped in Suspense, it stops blocking first paint. On that dashboard p95 TTFB went from 820ms to 190ms. loading.tsx gives the whole route the same treatment during navigation.

Route handlers are for things outside your React tree: webhooks, uploads with a custom content type, endpoints a mobile client consumes. Calling your own /api/invoices from a server component adds a network hop and loses type safety.

Caching

Next is dynamic by default now, which is the honest posture: you opt into caching deliberately. For catalog-shaped pages (public listings, docs, marketing) ISR plus revalidateTag on write is still the best ratio of effort to result. In Next 15/16 the newer direction is use cache with cacheLife and cacheTag at the function level, a better fit because the decision attaches to the data rather than the route.

The point worth internalizing: cache correctness is a data-ownership question. Before setting a TTL, answer who writes this record and what should happen the moment they do. A 40-row country lookup gets a 12-hour TTL and nobody cares. Invoice status gets tagged and invalidated on write, because a client refreshing after payment and seeing "unpaid" files a ticket.

Server actions

Server actions suit mutations that originate in your own UI: form submits, toggles, deletes. They keep the round trip typed and skip the API layer.

They are not free. A server action is a POST endpoint with a public ID, and anyone can call it with any payload. Every action validates with zod and re-checks authorization on the server, no exceptions:

ts
"use server";

import { z } from "zod";
import { revalidateTag } from "next/cache";
import { requireOrgMember } from "@/lib/auth";

const UpdateInvoice = z.object({
  invoiceId: z.string().uuid(),
  status: z.enum(["draft", "sent", "paid", "void"]),
});

export async function updateInvoiceStatus(input: unknown) {
  const { invoiceId, status } = UpdateInvoice.parse(input);
  const { orgId } = await requireOrgMember();

  const { count } = await db.invoice.updateMany({
    where: { id: invoiceId, orgId },
    data: { status },
  });
  if (count === 0) return { ok: false as const };

  revalidateTag(`invoice:${invoiceId}`);
  return { ok: true as const };
}

Note the orgId in the where clause. Scoping the query is what stops one tenant editing another's records. When a mobile app or third party needs the same operation, we expose a versioned route handler and let both call the shared function.

Where state belongs

URL search params are the default store. Filters, pagination, sort order, active tab, open drawer with an ID: all of it goes in the URL. It's shareable, survives refresh, and works with the back button for free.

Server state stays on the server. If it lives in Postgres, don't mirror it into a client cache and then fight to keep the two in sync.

Client state is for genuinely ephemeral things: an unsubmitted form, a hover preview, an optimistic row. useState handles nearly all of it. Context is fine for low-frequency values like theme or locale. Zustand earns its place when several distant components share fast-changing state, say a canvas editor or a wizard with cross-step validation. On a standard CRUD dashboard, that's roughly never.

What we don't do

  • No monorepo on day one. One Next app plus one FastAPI service is not a monorepo problem. Turborepo goes in when there's a second deployable.
  • No global store scaffolded in week one. Adding it later is an afternoon. Removing it is a refactor.
  • No barrel index.ts in every folder. Re-export files defeat tree-shaking in ways that are hard to trace, and they create import cycles that only surface at build time.
  • No any at the fetch boundary. Parse external data with zod and let types flow from the schema.

How we work

On client projects we stand this skeleton up in the first few days and write it into the repo's conventions file, so the structure holds after we hand off. Handover includes the route map, the boundary rules, a caching table showing what's tagged versus time-based, and a note on where each kind of state lives. Most of the above costs nothing to adopt early and a lot to retrofit at month six. That asymmetry is the whole argument.

Daniel Okoro · Frontend lead

Part of the NorthStackHub delivery team. Writes here when a client build turns up a decision worth documenting — usually after the second time we have had to explain it on a call.

Meet the team

Facing the same problem?

We scope this kind of work every week. Describe what you are building and we will send back an approach, a timeline and a number — no charge for the thinking.

Replies within 4 business hours · No obligation · You keep the scope document