Skip to content

App Router Patterns That Actually Scale in 2026

App Router is a different mental model than Pages. Most teams misuse it the same way. Here’s the structure that holds up at 100+ routes.

John Cravey with AIFounder5 min readUpdated Jul 6, 2026

The App Router has been stable for two years and most production codebases we audit still use it like the Pages Router with extra steps. Server components are treated as a curiosity rather than the default, data fetching gets duplicated across routes, and the `'use client'` directive is sprinkled everywhere as a panic button. The result is bundles bigger than the old Pages Router shipped, hydration costs that don’t need to exist, and a codebase that becomes a chore to extend.

We’ve now shipped App Router builds on seven FH client sites ranging from 20 to 180 routes. Here’s the structure that holds up.

Free estimate · 2 minutes

Fast site, real proof, visible call button. Priced.

The estimate sketches the conversion-built site we would ship for your business in the first thirty days. Sixty seconds, no opt-in.

Default to server, escalate to client only at the leaves

Every component should start as a server component. The only reason to add `'use client'` is one of these four things: you’re using a hook (`useState`, `useEffect`, `useRef`), you’re using a browser API (`window`, `document`, `localStorage`), you’re attaching an event handler (`onClick`, `onChange`), or you’re using a third-party library that requires the client. Anything else stays on the server.

The discipline matters because client components cascade — every component that imports a client component becomes effectively client-rendered. When you mark a top-level layout as `'use client'`, you’ve just made the entire route tree under it hydrate on the browser. We’ve seen sites where 100% of routes ended up client-rendered because someone added `'use client'` to a wrapper component three levels up. Bundle size 3x, LCP 1.4s slower, SEO impact compounding negatively for six months before anyone noticed.

The client-island pattern

When you need interactivity, isolate it. A page is usually 80% static content with 5–10 interactive elements (a form, a menu, a video player, a search box). Build each interactive element as its own client component, import it into a server page, and the rest of the page stays on the server. The client JS payload drops from “the entire page” to “just the interactive bits.”

// app/contact/page.tsx — SERVER component, no "use client"
import { LeadForm } from "./LeadForm";

export default function ContactPage() {
  return (
    <main>
      <h1>Get in touch</h1>
      <p>Static marketing copy here, server-rendered.</p>
      <LeadForm />  {/* island */}
    </main>
  );
}

// app/contact/LeadForm.tsx — CLIENT component
"use client";
import { useState } from "react";
export function LeadForm() {
  const [name, setName] = useState("");
  return <form>{/* … */}</form>;
}

Co-locate data with routes, not in a global lib

The temptation in App Router is to put every fetch in `lib/api.ts` and import it everywhere. That’s the Pages mental model. App Router rewards co-location: put the data fetch inside the server component that uses it. The React Server Components rendering model deduplicates concurrent fetches for you, so two components on the same page fetching the same URL hit the network once.

When a fetch is genuinely shared across more than three routes (the user, the cart, the org config), promote it to a `lib/server/` module — but server-only. Mark the file with `'server-only'` at the top to make sure it never accidentally gets imported into a client bundle. We’ve had this fail once: a server module with the Supabase service-role key got imported transitively into a `'use client'` chain, and the secret nearly shipped in the bundle. The `'server-only'` marker would have caught it at build.

Loading states and Suspense boundaries

`loading.tsx` at any route level is automatic — Next wraps your page in a Suspense boundary and shows your loading file while the page streams in. Most teams stop there. The bigger lift is wrapping individual slow data fetches in their own `<Suspense>` so the fast parts of the page render immediately and only the slow part skeletons.

// app/dashboard/page.tsx
import { Suspense } from "react";
import { FastHeader } from "./FastHeader";
import { SlowReport } from "./SlowReport";
import { ReportSkeleton } from "./ReportSkeleton";

export default function Dashboard() {
  return (
    <main>
      <FastHeader />        {/* renders immediately */}
      <Suspense fallback={<ReportSkeleton />}>
        <SlowReport />      {/* streams in when ready */}
      </Suspense>
    </main>
  );
}

Route groups and the colocate-everything rule

Use `(group)` folders to share layouts and to organize routes without affecting the URL. We use them three ways across FH builds: `(marketing)` for public site routes that share a top-bar/footer, `(admin)` for internal authenticated routes that share a sidebar, `(api)` for API routes that share auth middleware. The convention is that anything specific to a route — components, helpers, types, tests — lives inside the route folder. Nothing leaks to a global components directory unless it’s used in three or more places.

Metadata and SEO at the route level

Every route exports a `metadata` object (static) or a `generateMetadata` function (dynamic). The dynamic variant gets called once per route during static generation and matters for Search Console. We always export `alternates: { canonical: "/the-route" }` to prevent duplicate-content issues from query-string variants, and we always pass `openGraph` for social previews.

export const metadata: Metadata = {
  title: "Solutions — Web Design, SEO, PPC | Frontend Horizon",
  description: "Five core services. Sprint-priced against a named outcome.",
  alternates: { canonical: "/solutions" },
  openGraph: {
    title: "Solutions — Frontend Horizon",
    description: "Sprint-priced services for SMBs.",
    type: "website",
  },
};

Server actions for forms — when they earn their keep

Server actions are great for forms that submit to your own backend. They eliminate the API route boilerplate and they’re progressively enhanced — the form works even if JS hasn’t loaded yet. We use them for the FH contact form and for every lead form on client sites. They’re not the right tool for everything; if you need a third-party service to be the receiver (Stripe, Twilio), an API route is still cleaner.

What App Router does badly (in 16.1)

Two things still annoy us. First, route segment config like `revalidate` doesn’t cascade — set it once at the layout level and child routes can still override it silently. Second, error boundaries (`error.tsx`) need to be client components by spec, which means an error in a deeply-nested server component bubbles to a client boundary and you lose useful server-side context. We work around the second by logging errors in the server component itself and only using `error.tsx` for the user-facing fallback.

How this maps to FH client work

Every site we ship — BHR, james-marina, CabCarpentry, the upcoming Tivey site — runs on App Router with this exact pattern. Server-by-default, client islands, co-located data, Suspense for slow parts, metadata per route. Build times are fast, bundles are small, SEO ranking compounds because the pages serve real HTML to the crawler instead of an empty shell that hydrates on the client.

If you’re running an older Pages Router project and the rebuild question is on the table, read this first. Sometimes the answer is a focused App Router migration of three high-traffic routes, not a full rebuild.

Answers

Frequently asked questions

What is the core App Router mental model?

Server by default, client only at the leaves. Every component renders on the server unless something forces it to the client, and the discipline is to push that boundary as far down the tree as possible. Teams that invert this ship a client application with a server-rendered wrapper.

What is the client-island pattern?

Keeping interactive components small and isolated so the rest of the page stays on the server. A page with a filter control should send a filter island to the browser, not the entire page. The pattern is what makes a large App Router project stay fast as routes accumulate.

Where should data fetching live?

Next to the route that needs it, not in a global lib directory that every route imports from. Co-location keeps a route's dependencies legible and stops the shared module becoming a junk drawer that drags unrelated code into every bundle.

How should loading states be structured?

With Suspense boundaries placed where the slow thing actually is, rather than one boundary around the whole page. A page-level boundary means the whole route waits on its slowest query, which throws away the streaming behaviour that makes the router worth using.

What are route groups for?

Organizing routes without affecting the URL, which lets you colocate everything a section needs, including its components, its data, and its tests, without inventing a parallel folder structure. It is the main tool for keeping a hundred-route project navigable.

How should metadata be handled?

At the route level, next to the page it describes, using the framework's own metadata export. Centralized metadata files drift from the pages they describe almost immediately, and the drift is invisible because nothing errors when a title describes the wrong page.

When do server actions earn their keep?

For forms and mutations where the alternative is an API route that exists only to be called by one component. They cut the code roughly in half and give progressive enhancement for free. They are not a general RPC layer, and using them as one produces the same sprawl the API routes had.

What does the App Router still do badly?

Anything where the framework's caching model and your expectations diverge, which is where most production surprises live. It rewards reading the caching rules once carefully rather than discovering them per route, because the failure mode is stale content rather than an error.

How many routes can this structure hold?

Hundreds, provided the discipline holds: server-by-default, small client islands, co-located data, and route groups doing the organizing. Projects that fall over at scale almost always did so because 'use client' spread upward rather than because of route count.

What is the most common App Router mistake?

Treating it as the Pages Router with new file names. The directory structure is superficially similar and the execution model is not, so patterns that were correct before, such as fetching in a client component or wrapping everything in a provider, become the source of the performance problem.

Should shared layout state live in a provider?

Only if it genuinely must be client state. A provider at the layout level marks that subtree as client and cascades, which is the single most common way a server-rendered app quietly becomes a client-rendered one. Push the provider down to the island that needs it.

How do I know if my App Router structure is healthy?

Look at the JavaScript shipped per route. If routes with no interactivity are shipping meaningful bundles, something above them is marked client. That number is the honest scorecard for whether the mental model is being followed, and it degrades quietly without it.

Question we did not answer? Ask us directly and we will answer it here.

John Cravey, Founder
Written by
John Cravey
Founder

Founder of Frontend Horizon. Writes most of the long-form work on the FH blog.

Newer post
Core Web Vitals 2026: The Metrics That Matter and the Targets That Hold
Older post
Google Analytics 4: The Conversion Events That Actually Matter
Keep reading

More from the blog

Next.js·10 min

Structured Data in Next.js: How JSON-LD Gets You Cited by Google and AI

Structured data is how you tell Google and AI what your page means, not just what it says. Here’s the Next.js way to ship it.

Next.js·11 min

Titles, Meta Descriptions, and Social Cards: The Next.js Metadata Playbook for Every Business Size

Your title tag is the ad you never wrote. Here’s how to make Next.js render one that gets clicked, whatever size you are.

Next.js·10 min

Redirects in Next.js: How Not to Torch Your Rankings in a Redesign

Every redesign is a chance to lose the rankings you spent years earning. Redirects are the seatbelt. Here’s how to wear it.