Next.js App Router has been stable since 13.4 and is the default for every new project. But most tutorials stop at the basics. Production apps have harder problems: nested layouts, type-safe route params, loading states that don't flash, and data fetches that don't waterfall. 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
(app)/
layout.tsx ← sidebar layout for authenticated app
dashboard/page.tsx ← /dashboard
(auth)/
layout.tsx ← minimal layout for login/signup
login/page.tsx ← /login
The (app) folder name is invisible to the router - /dashboard is just /dashboard. Without route groups, you end up with a single root layout doing conditional rendering for every possible page state.
2. Default to Server Components - reach for 'use client' only when you need it
| Use Server Components for | Use Client Components for |
|---|---|
| Data fetching from DB or API | useState, useReducer, useEffect |
| Reading server-only env vars | Browser APIs (localStorage, window) |
| Large dependencies | Event listeners (onClick, onChange) |
| SEO-critical content | Real-time subscriptions |
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:
// app/dashboard/page.tsx (Server Component - no 'use client')
import { getMetrics } from "@/lib/metrics";
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 most common performance mistake: awaiting fetches sequentially when they don't depend on each other.
// ❌ 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)]);
4. Scope loading.tsx to the segment that needs it
A single loading.tsx at the root means every navigation shows a full-page loader. Scope loading states to the segment:
app/
(app)/
dashboard/
loading.tsx ← only shows when /dashboard is loading
page.tsx
analytics/
loading.tsx ← separate loader for /analytics
page.tsx
For granular loading within a page, use Suspense directly:
import { Suspense } from "react";
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 in Next.js 15+
Params are Promise<...> in Next.js 15+. Destructuring them synchronously is a bug:
// ❌ 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} />;
}
6. generateStaticParams for known dynamic routes
If a dynamic route has a known set of values at build time, pre-render them statically:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }) {
const post = await getPost((await params).slug);
return {
title: post.title,
description: post.description,
openGraph: { title: post.title, images: [{ url: post.ogImage }] },
};
}
7. Metadata API instead of manual head tags
// app/layout.tsx - site-wide defaults
export const metadata: Metadata = {
metadataBase: new URL("https://yoursite.com"),
title: {
default: "YourSite",
template: "%s | YourSite",
},
description: "Default site description",
openGraph: { type: "website", siteName: "YourSite" },
twitter: { card: "summary_large_image" },
};
8. Middleware: auth checks only - no database calls
Middleware runs on every matched request. Database calls here add 50-200ms to every page load:
// middleware.ts - read cookies/JWTs only, never query a DB
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*"],
};
The patterns worth internalising
- Use route groups from day one - retrofitting them means moving files and fixing all import paths
- Every
page.tsxshould havegenerateMetadata- free SEO in 10 minutes - Await params and searchParams - they are Promises in Next.js 15+
-
Promise.allfor independent fetches - sequential awaits are the most common performance mistake - Never put secrets in
NEXT_PUBLIC_variables - useimport "server-only"to enforce the boundary
Originally published at thekitbase.app
Top comments (0)