Skip to content

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:

app/
├── [locale]/
│   ├── layout.tsx
│   ├── page.tsx
│   └── products/
│       └── page.tsx

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>
  );
}

Locale-Aware Metadata

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { locale } = await params;
  const dict = await getDictionary(locale);

  return {
    title: dict.products.title,
    alternates: {
      languages: {
        en: "/en/products",
        es: "/es/products",
        pt: "/pt/products",
      },
    },
  };
}