Next.js Advanced Patterns¶
Advanced patterns and techniques for Next.js 14+ App Router — server actions, caching, streaming, middleware, and more.
Server Actions¶
Server Actions let you define async functions that run on the server, callable directly from Client Components. No manual API routes needed for mutations.
Basic Server Action¶
// app/products/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
export async function createProduct(formData: FormData) {
const name = formData.get("name") as string;
const price = parseFloat(formData.get("price") as string);
await prisma.product.create({
data: { name, price },
});
revalidatePath("/products");
}
Form Integration¶
// app/products/new/page.tsx
import { createProduct } from "../actions";
export default function NewProductPage() {
return (
<form action={createProduct}>
<input name="name" placeholder="Product name" required />
<input name="price" type="number" step="0.01" required />
<button type="submit">Create</button>
</form>
);
}
Pending States with useActionState¶
"use client";
import { useActionState } from "react";
import { createProduct } from "../actions";
interface ActionState {
error?: string;
success?: boolean;
}
export function CreateProductForm() {
const [state, formAction, isPending] = useActionState(
async (_prev: ActionState, formData: FormData): Promise<ActionState> => {
try {
await createProduct(formData);
return { success: true };
} catch {
return { error: "Failed to create product" };
}
},
{} as ActionState
);
return (
<form action={formAction}>
<input name="name" placeholder="Product name" required />
<input name="price" type="number" step="0.01" required />
{state.error && <p className="text-red-500">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create"}
</button>
</form>
);
}
Revalidation Strategies¶
| Function | Scope |
|---|---|
revalidatePath("/products") |
Revalidate all data for a specific path |
revalidatePath("/products", "layout") |
Revalidate everything below a layout |
revalidateTag("products") |
Revalidate all fetches tagged with "products" |
// Tag a fetch
const products = await fetch("https://api.sartiq.com/v1/products", {
next: { tags: ["products"] },
});
// Revalidate by tag in a Server Action
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}
Parallel & Intercepting Routes¶
Parallel Routes¶
Parallel routes render multiple pages in the same layout simultaneously using named slots (@slot):
app/
├── layout.tsx
├── page.tsx
├── @analytics/
│ ├── page.tsx # Shown alongside main page
│ └── default.tsx # Fallback when slot has no match
└── @team/
├── page.tsx
└── default.tsx
// app/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div>
{children}
<div className="grid grid-cols-2 gap-4">
{analytics}
{team}
</div>
</div>
);
}
Always provide default.tsx
Without default.tsx, a parallel route slot will return 404 on hard navigation when the slot has no matching segment.
Intercepting Routes¶
Intercepting routes let you load a route within the current layout — commonly used for modals:
app/
├── products/
│ ├── page.tsx # Product list
│ └── [id]/
│ └── page.tsx # Full product page (/products/123)
├── @modal/
│ ├── default.tsx # Empty default
│ └── (.)products/[id]/
│ └── page.tsx # Intercepted: opens as modal
└── layout.tsx
Convention prefixes:
| Prefix | Matches |
|---|---|
(.) |
Same level |
(..) |
One level up |
(..)(..) |
Two levels up |
(...) |
Root |
// app/@modal/(.)products/[id]/page.tsx
import { Modal } from "@/components/Modal";
export default async function ProductModal({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
return (
<Modal>
<h2>{product.name}</h2>
<p>{product.description}</p>
</Modal>
);
}
Soft navigation (clicking a <Link>) shows the modal. Hard navigation (direct URL or refresh) shows the full page.
Route Handlers¶
Route Handlers are the App Router equivalent of API routes, defined in route.ts files.
Basic Handler¶
// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = parseInt(searchParams.get("page") ?? "1");
const limit = parseInt(searchParams.get("limit") ?? "20");
const products = await prisma.product.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: "desc" },
});
return NextResponse.json(products);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const product = await prisma.product.create({
data: body,
});
return NextResponse.json(product, { status: 201 });
}
Dynamic Route Handlers¶
// app/api/products/[id]/route.ts
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const product = await prisma.product.findUnique({ where: { id } });
if (!product) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(product);
}
Cookies and Headers¶
import { cookies, headers } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get("session")?.value;
const headerList = await headers();
const userAgent = headerList.get("user-agent");
return NextResponse.json({ token: !!token, userAgent });
}
CORS Headers¶
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "https://app.sartiq.com",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
Streaming & Suspense¶
Progressive Rendering with Suspense¶
Wrap slow components in <Suspense> to stream the rest of the page immediately:
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Renders immediately */}
<StaticWelcome />
{/* Streams in when ready */}
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
// This async component streams in once data is ready
async function AnalyticsChart() {
const data = await getAnalytics(); // Slow query
return <Chart data={data} />;
}
loading.tsx vs Granular Suspense¶
| Approach | Scope | Use When |
|---|---|---|
loading.tsx |
Entire route segment | All content in the segment depends on the same data |
<Suspense> |
Individual components | Different parts of the page load at different speeds |
Granular <Suspense> is preferred — it gives users something useful faster while slow data loads in the background.
Nested Suspense Boundaries¶
<Suspense fallback={<PageSkeleton />}>
<UserProfile />
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</Suspense>
</Suspense>
The outer boundary resolves first, then inner boundaries stream progressively.
Partial Prerendering (PPR)¶
PPR combines static and dynamic rendering in a single route — a static shell is served instantly, then dynamic parts stream in.
Enabling PPR¶
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
ppr: "incremental",
},
};
export default nextConfig;
// app/products/page.tsx
export const experimental_ppr = true;
export default function ProductsPage() {
return (
<div>
{/* Static — prerendered at build time */}
<h1>Products</h1>
<StaticFilters />
{/* Dynamic — streams in at request time */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
</div>
);
}
Three Rendering Layers¶
| Layer | When | Content |
|---|---|---|
| Static shell | Build time | HTML outside Suspense boundaries |
| Dynamic streaming | Request time | Components inside Suspense boundaries |
| Client-side | Hydration | Interactive Client Components |
'use cache' Directive¶
The 'use cache' directive marks functions or components for caching:
"use cache";
export async function getProducts() {
const products = await prisma.product.findMany();
return products;
}
import { cacheLife } from "next/cache";
export async function getCachedProducts() {
"use cache";
cacheLife("hours"); // Built-in: "seconds", "minutes", "hours", "days", "weeks", "max"
return prisma.product.findMany();
}
Middleware¶
Middleware runs before a request is completed, enabling redirects, rewrites, header modification, and more.
Basic Middleware¶
// middleware.ts (root of project)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Check auth
const token = request.cookies.get("session")?.value;
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
// Only run on specific paths
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*"],
};
Matcher Patterns¶
export const config = {
matcher: [
// Match all paths except static files and _next
"/((?!_next/static|_next/image|favicon.ico).*)",
// Match specific paths
"/dashboard/:path*",
"/api/:path*",
],
};
Adding Headers¶
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Add security headers
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
// Add request ID for tracing
response.headers.set("X-Request-Id", crypto.randomUUID());
return response;
}
Locale Detection¶
import { match } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";
const locales = ["en", "es", "pt"];
const defaultLocale = "en";
function getLocale(request: NextRequest): string {
const headers = { "accept-language": request.headers.get("accept-language") ?? "" };
const languages = new Negotiator({ headers }).languages();
return match(languages, locales, defaultLocale);
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (!hasLocale) {
const locale = getLocale(request);
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}
}
Caching Deep Dive¶
Next.js has four caching layers. Understanding their interactions is critical for correct data freshness.
The Four Layers¶
| Layer | Where | What | Duration |
|---|---|---|---|
| Request Memoization | Server | Deduplicates identical fetch calls in a single render |
Per-request |
| Data Cache | Server | Caches fetch responses across requests |
Persistent (until revalidated) |
| Full Route Cache | Server | Caches rendered HTML and RSC payload for static routes | Persistent (until revalidated) |
| Router Cache | Client | Caches RSC payloads in the browser during navigation | Session (30s dynamic, 5min static) |
Revalidation Strategies¶
// Time-based revalidation
fetch("https://api.sartiq.com/products", {
next: { revalidate: 3600 }, // Revalidate every hour
});
// On-demand revalidation (in Server Actions or Route Handlers)
import { revalidatePath, revalidateTag } from "next/cache";
revalidatePath("/products"); // By path
revalidateTag("products"); // By tag
// Opt out of caching entirely
fetch("https://api.sartiq.com/user", {
cache: "no-store",
});
Segment-Level Caching Config¶
// Force dynamic rendering for an entire route
export const dynamic = "force-dynamic";
// Set revalidation interval for all fetches in a segment
export const revalidate = 60;
// Force static rendering
export const dynamic = "force-static";
Cache Interaction Map¶
Request → Router Cache (client)
↓ miss
Full Route Cache (server)
↓ miss
Render → Data Cache (server fetch results)
↓ miss
Origin (actual fetch to external API)
| Action | Router Cache | Full Route Cache | Data Cache |
|---|---|---|---|
revalidatePath |
Invalidated | Invalidated | Invalidated |
revalidateTag |
Invalidated | Invalidated | Invalidated |
router.refresh() |
Invalidated | — | — |
cookies.set() |
Invalidated | Invalidated | — |
| Time-based revalidation | — | Invalidated | Invalidated |
Performance¶
Dynamic Imports¶
Lazy-load heavy libraries that aren't needed on initial render:
"use client";
import dynamic from "next/dynamic";
const HeavyEditor = dynamic(() => import("@/components/RichTextEditor"), {
loading: () => <EditorSkeleton />,
ssr: false, // Skip SSR for client-only components
});
export function PostForm() {
return (
<form>
<input name="title" />
<HeavyEditor />
</form>
);
}
Bundle Analysis¶
# Install the analyzer
bun add @next/bundle-analyzer
# Analyze the bundle
ANALYZE=true bun run build
// next.config.ts
import type { NextConfig } from "next";
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
const nextConfig: NextConfig = {
// ...
};
export default withBundleAnalyzer(nextConfig);
Code Splitting via Suspense¶
Every <Suspense> boundary is an automatic code-splitting point for Server Components:
import { Suspense } from "react";
export default function Page() {
return (
<>
{/* Each Suspense boundary creates a separate chunk */}
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<HeavyContent />
</Suspense>
<Suspense fallback={<FooterSkeleton />}>
<Footer />
</Suspense>
</>
);
}
Core Web Vitals Checklist¶
| Metric | Target | Key Actions |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | Use priority on hero images, minimize server response time |
| INP (Interaction to Next Paint) | < 200ms | Avoid heavy JS on interaction paths, use useTransition |
| CLS (Cumulative Layout Shift) | < 0.1 | Set explicit width/height on images, use next/font |
Internationalization¶
Sub-path Routing¶
Organize routes under locale prefixes:
Dictionary Pattern¶
// lib/dictionaries.ts
const dictionaries = {
en: () => import("@/dictionaries/en.json").then((m) => m.default),
es: () => import("@/dictionaries/es.json").then((m) => m.default),
pt: () => import("@/dictionaries/pt.json").then((m) => m.default),
};
export type Locale = keyof typeof dictionaries;
export async function getDictionary(locale: Locale) {
return dictionaries[locale]();
}
// dictionaries/en.json
{
"nav": {
"home": "Home",
"products": "Products"
},
"products": {
"title": "Our Products",
"empty": "No products found"
}
}
Using Dictionaries in Pages¶
// app/[locale]/products/page.tsx
import { getDictionary, type Locale } from "@/lib/dictionaries";
interface Props {
params: Promise<{ locale: Locale }>;
}
export default async function ProductsPage({ params }: Props) {
const { locale } = await params;
const dict = await getDictionary(locale);
return (
<div>
<h1>{dict.products.title}</h1>
{/* ... */}
</div>
);
}
Static Generation for All Locales¶
// app/[locale]/layout.tsx
export async function generateStaticParams() {
return [{ locale: "en" }, { locale: "es" }, { locale: "pt" }];
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}