Next.js App Router has been stable since Next.js 13.4 and is now the default for every new project. But most tutorials stop at the basics - a few routes, a data fetch, a layout file. Production apps have harder problems: nested layouts that don't re-render on navigation, type-safe route params, loading states that don't flash, parallel data fetches that don't waterfall, and client/server boundaries that don't leak. Here are the patterns that actually matter.
1. Route groups keep your folder structure sane
Route groups (folders wrapped in parentheses) let you organise routes without affecting the URL. The most common use case: a marketing site and an app that share a domain but need completely different layouts.
app/
(marketing)/
layout.tsx ← nav + footer for public pages
page.tsx ← /
pricing/page.tsx ← /pricing
blog/page.tsx ← /blog
(app)/
layout.tsx ← sidebar layout for authenticated app
dashboard/page.tsx ← /dashboard
settings/page.tsx ← /settings
(auth)/
layout.tsx ← minimal layout for login/signup
login/page.tsx ← /login
signup/page.tsx ← /signupThe URL for /dashboard is just /dashboard - the (app) folder name is invisible to the router. But the layout file is scoped to that group, so your sidebar only wraps authenticated pages and your marketing nav only wraps public pages. Without route groups, you end up with a single root layout that tries to render both - and a mess of conditional rendering logic.
2. Default to Server Components - reach for 'use client' only when you need it
Server Components are the default in App Router. They render on the server, have zero JavaScript sent to the client, and can access backend resources directly. The instinct to add 'use client' to every component is wrong - it should be an intentional decision based on what the component actually needs.
| Use Server Components for | Use Client Components for |
|---|---|
| Data fetching from database or API | useState, useReducer, useEffect |
| Reading environment variables (server-only) | Browser APIs (localStorage, window) |
| Large dependencies (markdown parsers, etc.) | Event listeners (onClick, onChange) |
| Static content that doesn't interact | Real-time subscriptions |
| SEO-critical content | Drag and drop, canvas, animations |
A key pattern: push 'use client' as far down the tree as possible. A page can be a Server Component that fetches data and passes it to a small Client Component that handles interaction. Don't make the whole page a Client Component just because one button needs onClick.
// ✅ Server Component fetches, Client Component handles interaction
// app/dashboard/page.tsx (Server Component - no 'use client')
import { getMetrics } from "@/lib/metrics";
import { MetricCard } from "@/components/metric-card";
import { ExportButton } from "@/components/export-button"; // 'use client'
export default async function DashboardPage() {
const metrics = await getMetrics(); // direct DB/API call, no useEffect
return (
<div>
<MetricCard data={metrics} /> {/* server rendered */}
<ExportButton data={metrics} /> {/* client island */}
</div>
);
}3. Parallel data fetching prevents waterfalls
The biggest performance mistake in App Router: awaiting fetches sequentially when they don't depend on each other. Each await blocks the next, turning two 200ms requests into 400ms total.
// ❌ Sequential - 400ms total if each takes 200ms
const user = await getUser(id);
const posts = await getPosts(id);
// ✅ Parallel - 200ms total
const [user, posts] = await Promise.all([
getUser(id),
getPosts(id),
]);
// ✅ For non-blocking UI: start fetching before the await
// app/dashboard/page.tsx
export default async function DashboardPage() {
const metricsPromise = getMetrics(); // starts immediately
const usersPromise = getUsers(); // starts immediately
const [metrics, users] = await Promise.all([metricsPromise, usersPromise]);
return <Dashboard metrics={metrics} users={users} />;
}4. Loading and error boundaries at the right level
App Router uses loading.tsx and error.tsx files that wrap entire route segments. The mistake is putting a single loading.tsx at the root - it means every navigation shows a full-page loader even for tiny route transitions. Scope your loading states to the segment that actually needs them.
app/
(app)/
dashboard/
loading.tsx ← only shows when /dashboard is loading
error.tsx ← only catches errors in /dashboard
page.tsx
analytics/
loading.tsx ← separate loader for /analytics
page.tsxFor granular loading within a page, use React Suspense boundaries directly around the slow component. This lets the rest of the page render immediately while a specific slow section shows a skeleton:
// app/dashboard/page.tsx
import { Suspense } from "react";
import { MetricsSkeleton } from "@/components/skeletons";
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1> {/* renders immediately */}
<Suspense fallback={<MetricsSkeleton />}>
<SlowMetricsSection /> {/* streams in when ready */}
</Suspense>
</div>
);
}5. Type-safe route params and search params
Route params and search params in App Router are typed as Promise<...> in Next.js 15+. The pattern that causes subtle bugs: destructuring params synchronously when they should be awaited.
// ❌ Outdated pattern (Next.js 14 and below)
export default function PostPage({ params }: { params: { slug: string } }) {
return <Post slug={params.slug} />;
}
// ✅ Next.js 15+ - params is a Promise
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <Post post={post} />;
}
// ✅ Search params follow the same pattern
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const { q = "", page = "1" } = await searchParams;
return <Results query={q} page={parseInt(page)} />;
}6. generateStaticParams for dynamic routes that can be pre-rendered
If a dynamic route has a known set of values at build time - blog posts, product pages, documentation - use generateStaticParams to pre-render them statically. The result is HTML that's ready to serve from a CDN with no server processing on every request.
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
// generateMetadata for per-page SEO - runs at build time for static pages
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.description,
openGraph: { title: post.title, description: post.description },
};
}
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
return <BlogPost post={post} />;
}7. Metadata API instead of manual head tags
App Router has a built-in Metadata API that's type-safe, composable across layouts, and handles deduplication automatically. Stop putting <title> tags directly in JSX - use export const metadata or generateMetadata instead.
// app/layout.tsx - site-wide defaults
export const metadata: Metadata = {
metadataBase: new URL("https://yoursite.com"),
title: {
default: "YourSite",
template: "%s | YourSite", // page titles become "Post Title | YourSite"
},
description: "Default site description",
openGraph: {
type: "website",
siteName: "YourSite",
},
twitter: { card: "summary_large_image" },
};
// app/blog/[slug]/page.tsx - page-specific, overrides defaults
export async function generateMetadata({ params }) {
const post = await getPost((await params).slug);
return {
title: post.title, // becomes "Post Title | YourSite"
description: post.description,
openGraph: {
title: post.title,
images: [{ url: post.ogImage }],
},
};
}8. Environment variables: server vs client boundary
App Router makes the server/client boundary explicit in environment variables. Variables without the NEXT_PUBLIC_ prefix are server-only - they are never sent to the browser. Variables with NEXT_PUBLIC_ are embedded in the client bundle. Putting a secret API key in a NEXT_PUBLIC_ variable exposes it to every visitor.
// .env.local
DATABASE_URL=postgres://... ← server only, never exposed
STRIPE_SECRET_KEY=sk_live_... ← server only, never exposed
NEXT_PUBLIC_STRIPE_KEY=pk_live_ ← safe to expose, needed in browser
NEXT_PUBLIC_SITE_URL=https://... ← safe to expose
// To enforce server-only at compile time:
// lib/server-only-module.ts
import "server-only"; // throws if imported in a Client Component
export async function getSecretData() {
return db.query(process.env.DATABASE_URL);
}9. Middleware for auth and redirects - keep it lean
Middleware runs on every request matching the matcher config, before any page renders. It's the right place for auth checks and redirects - but it should do as little work as possible. Read a session cookie or JWT; don't make database calls. Middleware that queries a database on every request adds 50-200ms to every page load.
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const sessionToken = request.cookies.get("session")?.value;
const isProtected = request.nextUrl.pathname.startsWith("/dashboard");
if (isProtected && !sessionToken) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
// Only run on matched paths - don't run on _next/static, images, etc.
};10. The patterns that separate production code from tutorial code
- Use route groups from day one - retrofitting them later means moving files and fixing all import paths
- Every page.tsx should have generateMetadata - it takes 10 minutes to set up and is essentially free SEO
- Scope loading.tsx to the segment that needs it, not the root layout
- Default to server components - add 'use client' only when you actually need browser APIs or React state
- Await params and searchParams - they are Promises in Next.js 15+ and destructuring them synchronously is a bug waiting to happen
- Use Promise.all for independent data fetches - sequential awaits are the most common performance mistake in App Router apps
- Never put secrets in NEXT_PUBLIC_ variables - use server-only imports to enforce the boundary at compile time
All TheKitBase templates follow these patterns out of the box: route groups for marketing vs app vs auth layouts, server-first data fetching, Suspense boundaries, generateMetadata on every page, and flash-free dark mode. Production architecture from day one.
Browse Next.js templates built on App Router best practices - SaaS dashboards, AI landing pages, portfolios, agency sites. From $39, one-time purchase.
Browse Templates