Next.js App Router Patterns That Actually Matter
Layouts, templates, parallel routes, intercepting routes — the patterns that separate a good App Router codebase from a messy one.
The App Router shipped a mental model shift that most codebases haven't fully absorbed. After building NodWatch and Noddev on top of it, here are the patterns that made the biggest difference.
Layouts vs Templates
A layout.tsx preserves state across navigations — the component mounts once and stays mounted. A template.tsx remounts on every navigation. This sounds subtle but has real consequences.
Use layouts for persistent UI: navbars, sidebars, authenticated shells. Use templates when you need fresh state on each navigation — onboarding flows where you want animations to retrigger, or dashboards where you want scroll position to reset.
// app/(main)/layout.tsx — mounts once
export default function MainLayout({ children }) {
return (
<div className="flex">
<Sidebar />
<main>{children}</main>
</div>
);
}
// app/onboarding/template.tsx — remounts on each step
export default function OnboardingTemplate({ children }) {
return <motion.div key={Math.random()}>{children}</motion.div>;
}Parallel Routes
Parallel routes let you render two pages in the same layout simultaneously using named slots (@slotname). The canonical use case is a modal that sits alongside content — the list page stays mounted while the modal opens.
app/
layout.tsx ← receives both @modal and children
@modal/
(..)post/[id]/
page.tsx ← intercepted modal
posts/
page.tsx ← list stays mounted behind modal
In NodWatch, the admin feedback inbox uses this — the list view stays interactive while you open a report detail in a modal overlay.
Intercepting Routes
The (..) and (...) conventions intercept navigations at different depths. When intercepted, you see the modal version. On direct URL access or refresh, you see the full page. This gives you sharable URLs without losing the modal UX.
app/
@modal/
(..)photos/[id]/page.tsx ← intercepted: modal
photos/
[id]/page.tsx ← direct: full page
This is how Instagram-style photo expansion works in Next.js — same URL, two different presentations.
Server vs Client Boundary Design
The most important decision in App Router is where you draw the "use client" line. The default is server. Every "use client" directive creates a new bundle boundary — anything imported below it becomes client JavaScript.
Good pattern: keep the page as a server component that fetches data and passes it down. Only wrap the interactive parts in client components.
// app/dashboard/page.tsx — server, no bundle cost
export default async function DashboardPage() {
const data = await db.query.stats.findMany();
return <StatsGrid data={data} />;
}
// components/stats-grid.tsx — client only for interactivity
"use client";
export function StatsGrid({ data }) {
const [filter, setFilter] = useState("all");
// ...
}Avoid putting "use client" on wrapper components that don't need interactivity — you'll drag down everything they import into the client bundle.
Route Groups for Layout Isolation
Route groups (name) don't affect the URL but let you opt segments in or out of layouts. We use this in NodWatch to have auth pages (login, register) bypass the main authenticated layout entirely.
app/
(main)/
layout.tsx ← authenticated shell
dashboard/
profile/
(auth)/
login/
register/
layout.tsx ← root layout only
Loading and Suspense
loading.tsx is syntactic sugar for a Suspense boundary around the page. It streams immediately while the page's async data fetches. Granular Suspense inside the page gives better perceived performance than a single page-level loading state.
// app/posts/page.tsx
export default async function PostsPage() {
return (
<div>
<Suspense fallback={<HeaderSkeleton />}>
<PageHeader />
</Suspense>
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
);
}The App Router's mental model rewards composition over configuration. Once the layout/template and server/client distinctions click, the rest follows naturally.