The App Router changed how we architect React apps—bringing server components, hierarchical layouts, and first‑class data‑fetching closer to the framework core. This deep dive distills field‑tested patterns for scalable UI architectures in Next.js 14/15: how to model routes, stream UI safely, cache the right things, and keep your app accessible, fast, and maintainable.
What you’ll get: Production patterns, copy‑paste snippets, and checklists for routing, data, server actions, streaming, SEO, auth, testing, and deployment.
Table of Contents
- Core mental model (RSC + App Router)
- Reference project layout
- Routing patterns: groups, parallel & intercepting routes
- Data fetching & caching (static, dynamic, ISR)
- Server Actions for mutations (forms, revalidation)
- Streaming UI with Suspense & loading.tsx
- State management across server/client boundaries
- Design system integration (shadcn/ui + Radix UI)
- Auth and route protection
- Edge vs Node runtimes & middleware
- SEO & metadata (generateMetadata, Open Graph, sitemaps)
- Error boundaries & not-found
- Testing strategy (unit, e2e, visual)
- Performance & observability
- Security checklist
- CI/CD & deployment
- FAQ
Core mental model (RSC + App Router)
- Server Components (RSC) run on the server by default—no client JS unless you opt in with
"use client". This keeps bundles smaller and data fetching closer to the source. - Layouts are nested:
app/layout.tsxwrapsapp/(group)/layout.tsx, which wrapsapp/(group)/page.tsx. - Routes are folders: every segment can have
page.tsx,layout.tsx,loading.tsx,error.tsx,not-found.tsx,route.ts(for API handlers). - Client Components: only when you need stateful UI, effects, event handlers. Co-locate state near the interaction.
Rule of thumb: Start server-first. Add "use client" at the smallest sub-tree that truly needs it.
Reference project layout
app/
layout.tsx
page.tsx
(marketing)/
layout.tsx
page.tsx
(app)/
layout.tsx
dashboard/
page.tsx
loading.tsx
error.tsx
settings/
layout.tsx
page.tsx
@modal/(..)products/[id]/page.tsx // intercept into a modal
api/
analytics/route.ts
components/
lib/
db/
auth/
fetchers/
styles/
- Groups
(marketing)and(app)don’t affect the URL—use them to structure layouts. - Parallel routes like
@modalrender alongside your primary route. - Intercepted routes
(..)segmentlet you mount a route inside another (e.g., modal over list).
Routing patterns: groups, parallel & intercepting routes
Route groups
Keep code organized without touching the URL.
// app/(marketing)/layout.tsx
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div className="marketing-shell">{children}</div>;
}Parallel routes
Render two trees in parallel—great for split panes or a modal outlet.
// app/@modal/default.tsx (fallback when no modal)
export default function DefaultModalSlot() {
return null;
}// app/@modal/(..)products/[id]/page.tsx (intercept into modal)
"use client";
import { useRouter } from "next/navigation";
export default function ProductModal({ params }: { params: { id: string } }) {
const router = useRouter();
return (
<div className="fixed inset-0 bg-black/50 grid place-items-center">
<div className="w-[520px] rounded-lg bg-white p-6 shadow-xl">
<h2 className="text-xl font-semibold">Product {params.id}</h2>
<button
className="mt-4 rounded-md border px-4 py-2"
onClick={() => router.back()}
>
Close
</button>
</div>
</div>
);
}Data fetching & caching (static, dynamic, ISR)
Fetch on the server by default; choose a cache strategy per request:
// Static with ISR (revalidate every 10 minutes)
export const revalidate = 600;
async function getArticles() {
const res = await fetch("https://api.example.com/articles", {
// Next can cache and revalidate this response on the server
next: { revalidate: 600, tags: ["articles"] },
});
return res.json();
}
export default async function Page() {
const articles = await getArticles();
return (
<ul>
{articles.map((a: any) => (
<li key={a.id}>{a.title}</li>
))}
</ul>
);
}// Force dynamic (no cache)
export const dynamic = "force-dynamic";
async function getUser(token: string) {
const res = await fetch("https://api.example.com/me", {
cache: "no-store",
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}Selective revalidation after a mutation:
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function onArticleCreate() {
// ... create
revalidateTag("articles"); // or revalidatePath("/blog");
}Static params for large catalogs:
// app/products/[slug]/page.tsx
export async function generateStaticParams() {
const slugs = await fetch("https://api.example.com/slugs").then((r) =>
r.json(),
);
return slugs.map((slug: string) => ({ slug }));
}Server Actions for mutations (forms, revalidation)
Server Actions let forms call server code directly without an API route.
// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createTodo(formData: FormData) {
const title = String(formData.get("title") || "");
// await db.todo.create({ data: { title } });
revalidatePath("/todos");
}// app/todos/page.tsx
import { createTodo } from "../actions";
export default function TodosPage() {
return (
<form action={createTodo} className="flex gap-2">
<input
name="title"
placeholder="New todo"
className="rounded border px-3 py-2"
/>
<button className="rounded bg-black px-3 py-2 text-white">Add</button>
</form>
);
}Optimistic UI: keep it simple—show a pending state using useFormStatus in a client component.
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
disabled={pending}
className="rounded bg-black px-3 py-2 text-white"
>
{pending ? "Saving..." : "Add"}
</button>
);
}Streaming UI with Suspense & loading.tsx
Split slow parts behind <Suspense> and stream HTML as data resolves.
// app/dashboard/page.tsx
import { Suspense } from "react";
import SlowWidget from "./slow-widget";
export default function Dashboard() {
return (
<div className="grid gap-6">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<Suspense
fallback={<div className="h-28 animate-pulse rounded bg-neutral-100" />}
>
{/* Streams once data for SlowWidget is ready */}
<SlowWidget />
</Suspense>
</div>
);
}// app/dashboard/loading.tsx (route-level fallback during navigation)
export default function Loading() {
return <div className="p-6">Loading dashboard…</div>;
}State management across server/client boundaries
- Keep server data on the server; pass serialized props to small client islands.
- For client state, prefer local state or lightweight stores (e.g., Zustand) at the closest boundary.
- Avoid global providers at the root unless truly cross‑cutting (theme, auth, analytics).
// app/providers.tsx (wrap only what needs to be client-side)
"use client";
import { ThemeProvider } from "next-themes";
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider attribute="class">{children}</ThemeProvider>;
}Design system integration (shadcn/ui + Radix UI)
- Use Radix UI for accessible primitives; style with Tailwind.
- Adopt shadcn/ui to bootstrap ownable components (copy‑paste + tailor).
- Centralize tokens via CSS variables and point Tailwind to them for theming and dark mode.
pnpm dlx shadcn@latest init
pnpm dlx shadcn add button dialog input cardAuth and route protection
- Protect at the server: fetch the session in a Server Component and branch logic before render.
- Gate sensitive layout segments; avoid client‑only guards which flash content.
// app/(app)/layout.tsx
import { getSession } from "@/lib/auth"; // your server-side session
import { redirect } from "next/navigation";
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session) redirect("/login");
return <>{children}</>;
}- Add middleware for coarse route protection or domain/i18n logic when needed.
Edge vs Node runtimes & middleware
- Export per route:
export const runtime = "edge" | "nodejs"; - Edge is great for low‑latency reads, AB tests, geofencing; Node is better for heavy compute and libraries needing native APIs.
- Middleware (
middleware.ts) runs on the Edge—use it for redirects, locales, auth heuristics (not sensitive checks).
SEO & metadata (generateMetadata, Open Graph, sitemaps)
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await fetch(`https://api.example.com/blog/${params.slug}`).then(
(r) => r.json(),
);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: "article",
},
alternates: { canonical: `https://example.com/blog/${params.slug}` },
};
}- Generate sitemaps & robots via
app/sitemap.tsandapp/robots.ts. - For social cards, prebuild images or render dynamic OG images at
/opengraph-image.tsxif needed.
Error boundaries & not-found
error.tsxcatches errors within a route segment; you can reset with a provided function.not-found.tsxrenders for 404s; thrownotFound()from server code to trigger.
// app/(app)/dashboard/error.tsx
"use client";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="space-y-3">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<pre className="text-sm">{error.message}</pre>
<button onClick={reset} className="rounded border px-3 py-2">
Retry
</button>
</div>
);
}// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
// ...
if (!post) notFound();Testing strategy (unit, e2e, visual)
- Unit: Vitest/Jest + RTL for component behavior; mock server actions with indirection.
- E2E: Playwright for route transitions, protected pages, and modal intercepts.
- Visual: Storybook captures before merges for core components.
# .github/workflows/ci.yml (excerpt)
name: ci
on: [push, pull_request]
jobs:
build_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: "pnpm" }
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm build && pnpm testPerformance & observability
- Cache smartly: prefer static/ISR for public content; tag cached fetches to surgically revalidate.
- Images & fonts: use
next/imageandnext/fontfor best CLS/LCP. - Streaming: use Suspense for large widgets and route-level
loading.tsx. - Bundle discipline: keep client islands small; avoid global providers.
- Observability: add logging around server actions, cache hits/misses, and route timings; surface metrics to your APM.
Security checklist
- Escape by default: RSC auto-escapes, but never render unsanitized HTML.
- Headers: HSTS,
X-Content-Type-Options,Referrer-Policy, CSP (nonce/strict-dynamic). - Auth: enforce on the server; avoid exposing secrets in Client Components.
- Rate limit sensitive routes (login, actions).
- Validate inputs in server actions and API routes.
- Secrets in environment variables only (not in the repo).
CI/CD & deployment
- Node 20+; pnpm with a frozen lockfile.
- Build on CI, upload artifacts if using multi-env deploys.
- Use incremental static regeneration and tag revalidation in rollout playbooks.
- Verify middleware and edge routes behave consistently across regions.
FAQ
Do I still need a global state library?
Often no. Compose small client islands and lift state only when necessary. For complex apps, use a store at the nearest boundary.
Is the Pages Router obsolete?
No—it’s still supported. But for new apps and long-term scalability, prefer App Router.
How do I avoid layout shift during streaming?
Reserve space with skeletons or fixed aspect ratios; keep typography metrics stable with next/font.
Next steps
- Audit routes for cache intent (static vs dynamic) and mark them explicitly.
- Introduce Suspense around slow widgets; add
loading.tsxto key segments. - Centralize tokens and integrate a headless component stack (Radix/shadcn).
- Add tests for route transitions and error boundaries; wire revalidation into mutations.
