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 EleviFounder5 min read

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.

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.

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

Professional Services·2 min

Make Your Site Read Like the Firm You Are: Positioning for Professional Services

Your buyers compare three firms in a tab each. Generic copy makes you the one they close. Specific positioning makes you the one they call.

Next.js·6 min

Next.js 16.1 in Production: The Migration Playbook We Run on Every FH Site

Next 16.1 is the lean target. Here’s the exact migration we run, what breaks, and what to delete after.

Next.js·4 min

Server Components vs Client Components: The Mental Model That Stops You Reaching for ‘use client’

Most teams add ‘use client’ because they’re scared. The bundle pays for it.