crefft Source 2026.10.4

Next.js Style Guide

crefft > Frameworks > Next.js

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Extends TypeScript style guide with Next.js-specific conventions.

Target Version: Next.js 15+ with App Router and React 19. Code examples are written for and verified against Next.js 16.3.4 with React 19.2.8; APIs introduced in 16 are marked where they appear. Projects MUST run at least 16.3.3 or 15.5.24, the releases that fix the August 2026 critical vulnerabilities1.

Quick Reference

All TypeScript tooling applies. Additional Next.js-specific tools:

TaskToolCommand
Installnpm/pnpm/yarnnpx create-next-app@16.3.4
Run devNext.jsnpm run dev
BuildNext.jsnpm run build
BundlerTurbopack2next dev --turbo
TestVitest3 + Testing Library4vitest
E2EPlaywright5playwright test
LintESLint + eslint-config-next6npx eslint@9.39.5 .

Linting

Next.js 16 removed the next lint command, and next build no longer runs linting. Projects MUST invoke the linter directly and MUST run it as a separate CI step.

Why

Linting is no longer part of the Next.js lifecycle. A project that relies on next lint has no linting at all on Next.js 16: the command exits non-zero because lint is parsed as a project directory. A project that relies on next build to lint has silently unlinted code.

Configuration

@next/eslint-plugin-next ships flat config by default in Next.js 16.

npm install --save-dev eslint@9.39.5 eslint-config-next@16.3.4

ESLint MUST be pinned to the 9.x line. eslint-config-next@16.3.4 bundles eslint-plugin-react@^7.37.0, which uses the pre-ESLint-10 rule context API; under eslint@10.10.0 every lint run aborts with TypeError: Error while loading rule 'react/display-name': contextOrFilename.getFilename is not a function.

// eslint.config.mjs
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';

export default defineConfig([
  ...nextVitals,
  globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
]);
// package.json
{
  "scripts": {
    "lint": "eslint .",
    "lint:fix": "eslint . --fix"
  }
}

Don’t — removed in Next.js 16, and no substitute runs during next build:

{
  "scripts": {
    "lint": "next lint"
  }
}

Do — the linter runs on its own, in its own CI step:

{
  "scripts": {
    "lint": "eslint ."
  }
}

Projects that use Biome as their linter MUST run biome check . instead and MUST NOT expect Next.js-specific rules from it; @next/eslint-plugin-next has no Biome equivalent.

The eslint key in next.config.ts was removed alongside the command and MUST be deleted during upgrades.

Why Next.js?

Next.js7 is a full-stack React framework with server-side rendering, static site generation, and built-in optimizations for production applications.

Key advantages:

  • Server and Client Components for optimal performance
  • Built-in routing with file-based conventions
  • Automatic code splitting and image optimization
  • Full-stack capabilities with Server Actions
  • Zero-config TypeScript support
  • Excellent developer experience with Fast Refresh

Use Next.js for React applications requiring SEO, server rendering, or full-stack features. For client-only SPAs, consider Vite + React. For static sites, consider Astro.

Project Structure

Projects MUST use the App Router directory structure:

my-app/
├── app/
│   ├── layout.tsx              # Root layout
│   ├── page.tsx                # Home page
│   ├── globals.css
│   ├── (marketing)/            # Route group (URL-less)
│   │   ├── about/
│   │   │   └── page.tsx
│   │   └── layout.tsx
│   ├── dashboard/
│   │   ├── page.tsx
│   │   ├── loading.tsx         # Loading UI
│   │   ├── error.tsx           # Error boundary
│   │   └── settings/
│   │       └── page.tsx
│   └── api/
│       └── users/
│           └── route.ts        # API route handler
├── components/
│   ├── ui/                     # Reusable UI components
│   │   ├── button.tsx
│   │   └── card.tsx
│   └── features/               # Feature-specific components
│       └── user-profile.tsx
├── lib/
│   ├── db.ts                   # Database client
│   ├── actions.ts              # Server Actions
│   └── utils.ts                # Utilities
├── public/
│   ├── images/
│   └── fonts/
├── middleware.ts               # Edge middleware
├── next.config.ts
├── tsconfig.json
└── package.json

Why: The App Router co-locates routes with their components, making the file system the API. Route groups organize pages without affecting URLs, and special files (loading.tsx, error.tsx) provide consistent UX patterns.

File-Based Routing

Routes MUST be defined using the file system conventions:

// app/page.tsx - Home page at /
export default function HomePage() {
  return <h1>Home</h1>;
}

// app/blog/page.tsx - Blog index at /blog
export default function BlogPage() {
  return <h1>Blog</h1>;
}

// app/blog/[slug]/page.tsx - Dynamic route at /blog/:slug
export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return <h1>Post: {slug}</h1>;
}

// app/blog/[...slug]/page.tsx - Catch-all route at /blog/*
export default async function BlogCatchAll({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;
  return <h1>Path: {slug.join('/')}</h1>;
}

Asynchronous Request APIs

params, searchParams, cookies(), headers(), and draftMode() are promises. Next.js 15 accepted synchronous access with a deprecation warning; Next.js 16 removed that compatibility layer. Every consumer MUST await them.

Don’t — params is a promise, so params.slug is undefined:

export default function BlogPostPage({
  params,
}: {
  params: { slug: string };
}) {
  return <h1>Post: {params.slug}</h1>;
}

Do:

export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return <h1>Post: {slug}</h1>;
}

Why: A Route Handler with a synchronous params type fails the build with TS2344. A page does not: it compiles, renders, and silently substitutes undefined for every dynamic segment. The failure is invisible until production output is inspected.

Client Components cannot be async. They MUST unwrap the promise with React’s use hook:

// app/blog/[slug]/tag-filter.tsx
'use client';

import { use } from 'react';

export function TagFilter({
  searchParams,
}: {
  searchParams: Promise<{ tag?: string }>;
}) {
  const { tag } = use(searchParams);
  return <span>Filtering by: {tag ?? 'all'}</span>;
}

Projects SHOULD run npx next@16.3.4 typegen and use the generated PageProps, LayoutProps, and RouteContext helpers instead of hand-writing the promise types:

// app/blog/[slug]/page.tsx
export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params;
  return <h1>Post: {slug}</h1>;
}

Why: File-based routing eliminates routing configuration, makes page discovery intuitive, and enables automatic code splitting per route.

Route Groups and Parallel Routes

Projects SHOULD use route groups for organization:

app/
├── (marketing)/
│   ├── layout.tsx        # Marketing layout
│   ├── about/page.tsx
│   └── pricing/page.tsx
├── (app)/
│   ├── layout.tsx        # App layout (with auth)
│   ├── dashboard/page.tsx
│   └── settings/page.tsx
└── @modal/               # Parallel route (named slot)
    └── login/page.tsx
// app/(app)/layout.tsx - Layout for app routes
export default function AppLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <div>
      <nav>App Navigation</nav>
      {children}
      {modal}
    </div>
  );
}

Why: Route groups organize files without adding URL segments. Parallel routes enable advanced patterns like modals, split views, and conditional rendering.

Server vs Client Components

Components MUST be Server Components by default. Client Components MUST be explicitly marked with 'use client'.

// app/components/user-profile.tsx - Server Component (default)
import { getUser } from '@/lib/db';

export default async function UserProfile({ userId }: { userId: string }) {
  const user = await getUser(userId); // Direct database access

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}
// app/components/counter.tsx - Client Component
'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

When to Use Each

Use Server Components forUse Client Components for
Data fetchingInteractivity (onClick, onChange)
Backend resources accessState (useState, useReducer)
Sensitive data (API keys)Effects (useEffect, useLayoutEffect)
Large dependenciesBrowser APIs (localStorage, window)
SEO-critical contentEvent listeners

Why: Server Components reduce JavaScript bundle size, enable direct backend access, and improve initial page load. Client Components provide interactivity where needed without forcing the entire app client-side.

Partial Prerendering and Cache Components

Next.js 16 removed the experimental Partial Prerendering8 controls. Projects MUST NOT set experimental.ppr and MUST NOT export experimental_ppr from a route segment. Partial Prerendering is now the default App Router behavior once Cache Components is enabled, which projects SHOULD do:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

Don’t — next build aborts on Next.js 16 with experimental.ppr` has been merged into `cacheComponents:

const nextConfig: NextConfig = {
  experimental: {
    ppr: 'incremental',
  },
};

There is no per-route opt-in any more. With cacheComponents enabled, data fetching is dynamic by default, every route gets a prerendered static shell, and you choose what to cache:

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserGreeting } from './user-greeting';
import { RecentActivity } from './recent-activity';

// Static shell renders immediately, dynamic parts stream in
export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>  {/* Static - part of the prerendered shell */}

      <Suspense fallback={<div>Loading user...</div>}>
        <UserGreeting />  {/* Request-time data - streams in */}
      </Suspense>

      <Suspense fallback={<div>Loading activity...</div>}>
        <RecentActivity />  {/* Request-time data - streams in */}
      </Suspense>
    </div>
  );
}

Cache a component or function with the use cache directive, and give it a lifetime and invalidation tags:

// app/dashboard/popular-posts.tsx
import { cacheLife, cacheTag } from 'next/cache';
import { getPopularPosts } from '@/lib/db';

export async function PopularPosts() {
  'use cache';
  cacheLife('hours');
  cacheTag('posts');

  const posts = await getPopularPosts();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

A use cache scope MUST NOT read cookies(), headers(), params, or searchParams; doing so throws next-request-in-use-cache. Read those outside the cached scope and pass the values in as arguments, which also makes them part of the cache key.

cacheLife and cacheTag are stable in Next.js 16 and MUST be imported without the unstable_ prefix.

Enabling Cache Components Is a Breaking Change

Once cacheComponents is on, every route that reads request data at the top level fails the build:

Error: Route "/dashboard": Next.js encountered uncached or runtime data during
prerendering. `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`,
or `connection()` accessed outside of <Suspense> prevents the route from being
prerendered.

Each such route MUST take one of three options:

OptionHowUse when
StreamMove the read into a child inside <Suspense fallback={...}>The shell is useful without that data
CacheWrap the read in use cache and pass request values as argumentsThe result is shareable between requests
Blockexport const instant = falseThe whole page is meaningless without the data

The route-segment exports dynamic, revalidate, and dynamicParams are rejected outright: Route segment config "dynamicParams" is not compatible with nextConfig.cacheComponents.

Why: Traditional SSR waits for all data before sending HTML. Partial Prerendering sends a static shell instantly (best TTFB) while streaming dynamic content as it resolves, giving the speed of static sites with the personalization of dynamic pages. Cache Components replaces the removed per-route flag with an explicit, composable choice of what is cached and for how long.

Projects that have not adopted Cache Components MAY continue to use the route-segment caching model described under Static Site Generation (SSG) and ISR; it remains supported.

Data Fetching in Server Components

Server Components MUST fetch data using async/await:

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/db';

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound(); // Renders 404 page
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

// Generate static params at build time
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

Request Deduplication

Next.js automatically deduplicates identical fetch requests:

// These three fetches result in only ONE network request
async function Header() {
  const user = await fetch('/api/user').then(r => r.json());
  return <div>{user.name}</div>;
}

async function Sidebar() {
  const user = await fetch('/api/user').then(r => r.json());
  return <div>{user.email}</div>;
}

async function Content() {
  const user = await fetch('/api/user').then(r => r.json());
  return <div>{user.bio}</div>;
}

Why: Request deduplication prevents waterfall fetching and redundant network calls, improving performance without manual caching logic.

Fetch Caching and Revalidation

Projects MUST configure caching behavior explicitly:

// Static data (cached indefinitely)
const response = await fetch('https://api.example.com/data');

// Revalidate every 60 seconds
const response = await fetch('https://api.example.com/data', {
  next: { revalidate: 60 },
});

// Never cache (always fresh)
const response = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});

// Revalidate on-demand via tag
const response = await fetch('https://api.example.com/data', {
  next: { tags: ['posts'] },
});
// app/actions.ts - Revalidate tagged data
'use server';

import { revalidatePath, updateTag } from 'next/cache';

export async function updatePost(id: string) {
  await db.posts.update({ id });
  updateTag('posts'); // Expire now and refresh in the same request
  revalidatePath('/blog'); // Revalidate /blog route
}

Why: Explicit cache control balances performance with data freshness. Tag-based revalidation enables surgical cache invalidation without rebuilding the entire site.

Tag Invalidation APIs

Next.js 16 made the profile argument of revalidateTag mandatory. Calls MUST select the API that matches the consistency the caller needs:

APISemanticsUse when
updateTag(tag)Expires and refreshes in the same requestA Server Action whose user must see their own write
revalidateTag(tag, 'max')Stale-while-revalidate for up to a yearContent where a short delay is acceptable
revalidateTag(tag, { expire: 0 })Next read blocks on a cache missA Route Handler or webhook that needs the data gone now

updateTag is available only inside Server Actions. Route Handlers MUST use revalidateTag with an explicit profile.

Don’t — fails to compile with TS2554: Expected 2 arguments, but got 1:

revalidateTag('posts');

Do:

// Server Action: the editor sees their own change immediately
updateTag('posts');

// Anywhere: readers get stale content while the refresh runs
revalidateTag('posts', 'max');

// Webhook Route Handler: drop the entry, next reader waits for fresh data
revalidateTag('posts', { expire: 0 });

Why: The single-argument form still runs if the TypeScript error is suppressed, but the vendor documents that behavior as deprecated and subject to removal. Choosing explicitly also forces the decision that the old call hid: whether a reader may be served stale data after a mutation.

Server Actions

Projects SHOULD use Server Actions for mutations. Every Server Action MUST authenticate the caller, MUST authorize the specific operation, MUST validate its input, and MUST return only the data the client needs.

Why

Next.js compiles each Server Action into a public HTTP endpoint with a stable action ID. Anyone who can send that POST can invoke the action directly: the surrounding form, the page that renders it, and the file’s location under app/ are not access controls. A <form> that only an administrator can see is still an endpoint that an anonymous client can call.

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { getCurrentUser } from '@/lib/auth-utils';
import { z } from 'zod';

const createPostSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(1),
});

type CreatePostResult =
  | { ok: true; postId: string }
  | { ok: false; error: string };

export async function createPost(
  _previous: CreatePostResult | null,
  formData: FormData
): Promise<CreatePostResult> {
  // 1. Authenticate - the action is reachable without the form
  const user = await getCurrentUser();
  if (!user) {
    return { ok: false, error: 'Unauthorized' };
  }

  // 2. Authorize this specific operation
  if (!user.permissions.includes('post:create')) {
    return { ok: false, error: 'Forbidden' };
  }

  // 3. Validate; never forward the raw payload
  const parsed = createPostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  });
  if (!parsed.success) {
    return { ok: false, error: 'Invalid input' };
  }

  // 4. Derive ownership on the server, not from the payload
  const post = await db.posts.create({
    data: { ...parsed.data, authorId: user.id },
  });

  revalidatePath('/blog');

  // 5. Return the minimum the client needs
  return { ok: true, postId: post.id };
}

Don’t — no identity check, no permission check, parse throws an unhandled error, and the client is trusted to supply well-formed fields:

export async function createPost(formData: FormData) {
  const parsed = createPostSchema.parse({
    title: formData.get('title'),
    content: formData.get('content'),
  });
  const post = await db.posts.create({ data: parsed });
  revalidatePath('/blog');
  return { success: true, postId: post.id };
}
// app/blog/new/post-form.tsx
'use client';

import { useActionState } from 'react';
import { createPost } from '@/app/actions';

export function NewPostForm() {
  const [state, formAction] = useActionState(createPost, null);

  return (
    <form action={formAction}>
      <input name="title" required />
      <textarea name="content" required />
      {state?.ok === false && <p role="alert">{state.error}</p>}
      <button type="submit">Create Post</button>
    </form>
  );
}
// app/blog/new/page.tsx
import { NewPostForm } from './post-form';

export default function NewPostPage() {
  return <NewPostForm />;
}

useActionState keeps the form progressively enhanced: without JavaScript the browser posts the form and the server re-renders the page with the returned state.

Progressive Enhancement

Server Actions work without JavaScript:

// With useFormStatus for loading states
'use client';

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create Post'}
    </button>
  );
}

export default function NewPostForm({ action }) {
  return (
    <form action={action}>
      <input name="title" required />
      <SubmitButton />
    </form>
  );
}

Why: Server Actions eliminate the need for API routes for simple mutations, provide type safety, and work without client-side JavaScript (progressive enhancement).

Middleware

Projects SHOULD use middleware for cross-cutting concerns:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Authentication redirect
  const token = request.cookies.get('auth-token');

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // Add custom headers
  const response = NextResponse.next();
  response.headers.set('x-custom-header', 'value');

  return response;
}

// Specify which routes to run middleware on
export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*'],
};

Middleware Use Cases

Use CasePattern
AuthenticationCheck tokens, redirect to login
AuthorizationRole-based access control
RedirectsA/B testing, localization
Bot detectionUser-Agent analysis
Rate limitingRequest throttling

Why: Middleware runs on the Edge before rendering, enabling fast authentication checks, redirects, and request modification without full server computation.

Rate Limiting

Projects SHOULD use Upstash Ratelimit9 for serverless-compatible rate limiting:

Why

Traditional rate limiting libraries require persistent connections and server-side state. Upstash Ratelimit uses HTTP-based Redis, making it the only connectionless rate limiting solution designed for serverless environments (Vercel, AWS Lambda, Cloudflare Workers).

Edge Middleware Implementation

NextRequest.ip was removed in Next.js 15. Limiters MUST key on a verified identity where one exists, and otherwise on a client address supplied by the hosting platform. They MUST NOT read a client-supplied x-forwarded-for header directly unless every proxy in front of the application is known to overwrite it, and MUST NOT collapse unidentifiable callers into a single shared bucket.

Don’t — request.ip no longer exists (TS2339), the header fallback is attacker-controlled on an untrusted chain, and every unknown client shares one 127.0.0.1 bucket:

const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? '127.0.0.1';
const { success } = await ratelimit.limit(ip);
npm install @upstash/ratelimit@2.0.8 @upstash/redis@1.38.4 @vercel/functions@3.9.5
// middleware.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
import { ipAddress } from '@vercel/functions';
import { NextRequest, NextResponse } from 'next/server';
import { getVerifiedSubject } from '@/lib/auth-utils';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
  analytics: true,
  prefix: '@upstash/ratelimit',
});

async function identify(request: NextRequest): Promise<string | null> {
  // Authenticated callers are limited per account, not per network address:
  // one office NAT must not share a bucket with unrelated users.
  const subject = await getVerifiedSubject(request);
  if (subject) {
    return `user:${subject}`;
  }

  // Anonymous callers: the platform's client address, never a raw header.
  const ip = ipAddress(request);
  return ip ? `ip:${ip}` : null;
}

export async function middleware(request: NextRequest) {
  const identifier = await identify(request);

  if (identifier === null) {
    // No trustworthy identity: reject rather than share one global bucket.
    return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
  }

  const { success, limit, reset, remaining } = await ratelimit.limit(
    identifier,
    {
      ip: ipAddress(request),
      userAgent: request.headers.get('user-agent') ?? undefined,
      country: request.headers.get('x-vercel-ip-country') ?? undefined,
    }
  );

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests' },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
        },
      }
    );
  }

  const response = NextResponse.next();
  response.headers.set('X-RateLimit-Limit', limit.toString());
  response.headers.set('X-RateLimit-Remaining', remaining.toString());
  return response;
}

export const config = {
  matcher: '/api/:path*',
};

Why: ipAddress() reads x-real-ip, which the Vercel edge network sets on every inbound request; the platform, not the client, controls its value. Projects on other hosts MUST substitute that host’s equivalent helper and MUST confirm the edge overwrites rather than appends the forwarding header — an appended value is caller-controlled and lets one attacker mint an unlimited number of buckets. Keying authenticated traffic on the account identifier also keeps a shared corporate address from throttling legitimate users.

Rate Limiting Algorithms

AlgorithmUse Case
slidingWindow(limit, window)Smooth rate limiting, prevents bursts
tokenBucket(refillRate, maxTokens)Allow controlled bursts
fixedWindow(limit, window)Simple, lowest cost

Multi-Region Configuration

Projects with global users SHOULD use regional Redis instances:

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(100, '60 s'),
  // Automatically routes to nearest region
  enableProtection: true,
});

With enableProtection enabled, callers MUST pass the request metadata shown above (ip, userAgent, country) to limit(). Upstash’s deny lists match on those fields, and an omitted field cannot be matched.

Why: Edge middleware blocks traffic before it reaches your backend, reducing costs and protecting against abuse. Multi-region Redis minimizes latency globally.

API Routes (Route Handlers)

Projects MAY use Route Handlers for API endpoints. A Route Handler is a public endpoint: it MUST authenticate the caller, MUST authorize the specific resource and operation, MUST validate its body, and MUST return a projection rather than a whole database row.

// app/api/users/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getCurrentUser } from '@/lib/auth-utils';
import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.email(),
});

// Return only fields the caller is allowed to see
const publicUser = (user: { id: string; name: string; email: string }) => ({
  id: user.id,
  name: user.name,
  email: user.email,
});

export async function GET() {
  const actor = await getCurrentUser();
  if (!actor) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  if (!actor.permissions.includes('user:list')) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  const users = await db.users.findMany();
  return NextResponse.json(users.map(publicUser));
}

export async function POST(request: Request) {
  const actor = await getCurrentUser();
  if (!actor) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  if (!actor.permissions.includes('user:create')) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  const parsed = createUserSchema.safeParse(await request.json());
  if (!parsed.success) {
    return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
  }

  // Role is assigned by the server; it is never read from the request
  const user = await db.users.create({
    data: { ...parsed.data, role: 'user' },
  });
  return NextResponse.json(publicUser(user), { status: 201 });
}

Don’t — an anonymous caller can create a user, and the raw body reaches the database, so any extra field (role: 'admin') is written verbatim:

export async function POST(request: Request) {
  const body = await request.json();
  const user = await db.users.create({ data: body });
  return NextResponse.json(user, { status: 201 });
}
// app/api/users/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;

  const actor = await getCurrentUser();
  if (!actor) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  // Resource-level check: reading yourself differs from reading anyone
  if (actor.id !== id && !actor.permissions.includes('user:read')) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  const user = await db.users.findUnique({ where: { id } });

  if (!user) {
    return NextResponse.json({ error: 'User not found' }, { status: 404 });
  }

  return NextResponse.json(publicUser(user));
}

export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;

  const actor = await getCurrentUser();
  if (!actor) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  if (!actor.permissions.includes('user:delete')) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  await db.users.delete({ where: { id } });
  return new NextResponse(null, { status: 204 });
}

Why: Route Handlers provide REST API endpoints when needed. For form submissions and mutations, prefer Server Actions for better type safety and progressive enhancement.

Why these checks: An authorization check performed in a page, a layout, or middleware protects only that render path. DELETE /api/users/:id is reachable directly with curl, so the identity and permission checks MUST live in the handler body. Returning db.users.findUnique(...) verbatim also leaks every column added to the table later — including password hashes and tokens — which is why the handler returns an explicit projection.

Route Handler params is a promise. A synchronous { params: { id: string } } context type fails next build with TS2344.

Testing

Unit and Integration Tests

Projects MUST test Server and Client Components separately:

// components/counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import Counter from './counter';

describe('Counter', () => {
  it('increments count on click', () => {
    render(<Counter />);
    const button = screen.getByRole('button');

    expect(button).toHaveTextContent('Count: 0');
    fireEvent.click(button);
    expect(button).toHaveTextContent('Count: 1');
  });
});
// components/user-profile.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import UserProfile from './user-profile';

vi.mock('@/lib/db', () => ({
  getUser: vi.fn().mockResolvedValue({
    name: 'John Doe',
    email: 'john@example.com',
  }),
}));

describe('UserProfile', () => {
  it('renders user data', async () => {
    render(await UserProfile({ userId: '1' }));
    expect(screen.getByText('John Doe')).toBeInTheDocument();
    expect(screen.getByText('john@example.com')).toBeInTheDocument();
  });
});

E2E Tests

Projects SHOULD use Playwright for end-to-end testing:

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('user can log in', async ({ page }) => {
    await page.goto('/login');

    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('h1')).toContainText('Dashboard');
  });

  test('protected route redirects to login', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page).toHaveURL('/login');
  });
});

Why: Vitest provides fast unit testing with React Server Component support. Playwright enables reliable E2E testing across browsers with automatic waiting and screenshot/video recording.

Authentication

New projects SHOULD use Better Auth10. Projects with a working Auth.js11 deployment MAY stay on it; Auth.js v5 remains a prerelease and MUST be pinned to a tested version.

Why

The Auth.js project itself now recommends Better Auth for new projects, while committing to security patches and critical fixes for Auth.js. Auth.js v5 has been in beta since 2023 and its latest npm tag still points at v4; Better Auth publishes a stable line. Migrating a working Auth.js deployment is not urgent, so this guide keeps both paths rather than mandating a rewrite.

SituationLibrary
New projectBetter Auth 1.7.3
Existing Auth.js v5 deployment that worksAuth.js, pinned to 5.0.0-beta.32
Existing Auth.js v4 deploymentMigrate to Better Auth rather than to v5 beta

Shared Role Definitions

Both paths need one canonical role list. Define it once and import it everywhere, including type declarations:

// lib/roles.ts
export const ROLES = ['user', 'moderator', 'admin'] as const;

export type Role = (typeof ROLES)[number];

export function isRole(value: unknown): value is Role {
  return typeof value === 'string' && (ROLES as readonly string[]).includes(value);
}

export const PERMISSIONS: Record<Role, readonly string[]> = {
  user: ['post:create'],
  moderator: ['post:create', 'user:list', 'user:read'],
  admin: ['post:create', 'user:list', 'user:read', 'user:create', 'user:delete'],
};

Better Auth (New Projects)

npm install better-auth@1.7.3
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { nextCookies } from 'better-auth/next-js';
import { prisma } from '@/lib/db';

export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: 'postgresql' }),
  emailAndPassword: { enabled: true },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
    },
  },
  user: {
    additionalFields: {
      role: {
        type: 'string',
        required: true,
        defaultValue: 'user',
        input: false, // Clients MUST NOT be able to set their own role
      },
    },
  },
  plugins: [nextCookies()], // MUST be last in the array
});
// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';

export const { GET, POST } = toNextJsHandler(auth);

Why input: false: without it, role is part of the sign-up payload and any anonymous caller can register as an administrator.

Auth.js (Existing Projects)

npm install next-auth@5.0.0-beta.32

@auth/core MUST NOT be installed at a version other than the one next-auth depends on (5.0.0-beta.32 pins @auth/core@0.41.3). A second copy at a different version splits the shared interfaces and silently breaks the type augmentation below.

// auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/db';

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GitHub, // Uses AUTH_GITHUB_ID and AUTH_GITHUB_SECRET automatically
    Google,
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        // Validate credentials against database
        const user = await validateUser(credentials);
        return user ?? null;
      },
    }),
  ],
  session: { strategy: 'jwt' }, // Required for Edge Runtime/middleware
  callbacks: {
    authorized({ auth, request }) {
      const isLoggedIn = !!auth?.user;
      const isProtected = request.nextUrl.pathname.startsWith('/dashboard');
      if (isProtected && !isLoggedIn) return false;
      return true;
    },
    jwt({ token, user }) {
      if (user) {
        token.role = user.role;
      }
      return token;
    },
    session({ session, token }) {
      session.user.role = token.role;
      return session;
    },
  },
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;

Type Augmentation for Custom Fields

Auth.js types custom session and token fields through module augmentation. Projects that add a field such as role MUST declare it, and MUST include the declaration file in tsconfig.json.

Don’t — user.role and session.user.role fail with TS2339: Property 'role' does not exist, and token.role is typed unknown because JWT only carries an index signature:

jwt({ token, user }) {
  if (user) {
    token.role = user.role;
  }
  return token;
},

Do:

// types/next-auth.d.ts
import type { DefaultSession } from 'next-auth';
import type { Role } from '@/lib/roles';

declare module 'next-auth' {
  interface User {
    role: Role;
  }

  interface Session {
    user: { role: Role } & DefaultSession['user'];
  }
}

// The JWT interface is declared in @auth/core/jwt; next-auth only re-exports it
declare module '@auth/core/jwt' {
  interface JWT {
    role: Role;
  }
}

export {};

Augmenting next-auth/jwt MUST NOT be used: that module is a bare export * from '@auth/core/jwt', so a declare module 'next-auth/jwt' block creates a new interface instead of merging into the original. JWT keeps its Record<string, unknown> index signature, token.role stays unknown, and the session callback fails with TS2322: Type 'unknown' is not assignable to type '"user" | "moderator" | "admin"'.

// tsconfig.json
{
  "include": ["next-env.d.ts", "types/**/*.d.ts", "**/*.ts", "**/*.tsx"]
}

The database model MUST supply the same field, or the adapter returns a User without role and the augmentation is a lie:

// prisma/schema.prisma
model User {
  id    String @id @default(cuid())
  email String @unique
  role  String @default("user")
}

Middleware-Based Protection

// middleware.ts
import { auth } from '@/auth';

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAuthPage = req.nextUrl.pathname.startsWith('/login');

  if (isAuthPage && isLoggedIn) {
    return Response.redirect(new URL('/dashboard', req.nextUrl));
  }

  if (!isLoggedIn && req.nextUrl.pathname.startsWith('/dashboard')) {
    return Response.redirect(new URL('/login', req.nextUrl));
  }
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Using Auth in Server Components

// app/dashboard/page.tsx
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth-utils';

export default async function DashboardPage() {
  const user = await getCurrentUser();

  if (!user) {
    redirect('/login');
  }

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <p>Role: {user.role}</p>
    </div>
  );
}

Role-Based Access Control (RBAC)

One module resolves the session for pages, Server Actions, Route Handlers, and the rate limiter, so every entry point enforces the same rules:

// lib/auth-utils.ts
import { headers } from 'next/headers';
import type { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { isRole, PERMISSIONS, type Role } from '@/lib/roles';

export type AppUser = {
  id: string;
  name: string;
  role: Role;
  permissions: readonly string[];
};

export async function getCurrentUser(): Promise<AppUser | null> {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) {
    return null;
  }

  // Narrow at the boundary: the column is a string in the database
  const role: Role = isRole(session.user.role) ? session.user.role : 'user';

  return {
    id: session.user.id,
    name: session.user.name,
    role,
    permissions: PERMISSIONS[role],
  };
}

export async function requireRole(allowedRoles: Role[]): Promise<AppUser> {
  const user = await getCurrentUser();

  if (!user) {
    throw new Error('Unauthorized');
  }

  if (!allowedRoles.includes(user.role)) {
    throw new Error('Forbidden');
  }

  return user;
}

// Rate limiting runs before the page renders and has no `headers()` scope
export async function getVerifiedSubject(
  request: NextRequest
): Promise<string | null> {
  const session = await auth.api.getSession({ headers: request.headers });
  return session?.user.id ?? null;
}
// app/admin/page.tsx
import { requireRole } from '@/lib/auth-utils';

export default async function AdminPage() {
  const user = await requireRole(['admin']);

  return <AdminDashboard user={user} />;
}

Session Strategies

StrategyProsCons
JWTEdge-compatible, no DB lookups, horizontal scalingCannot revoke until expiry
DatabaseImmediate revocation, audit trailRequires DB access, more latency

Projects MUST use JWT strategy when using middleware auth checks (Edge Runtime cannot access databases directly).

Why: Better Auth and Auth.js both provide type-safe authentication with zero vendor lock-in. JWT sessions work in Edge Runtime while database sessions enable immediate session revocation for security-critical applications.

Deployment

Projects deployed to Vercel12 SHOULD use automatic deployments:

# Install Vercel CLI
npm i -g vercel

# Deploy to production
vercel --prod

# Preview deployments happen automatically on git push
// next.config.ts
import type { NextConfig } from 'next';

const config: NextConfig = {
  // Vercel-specific optimizations are automatic
  images: {
    // AVIF omitted deliberately - see Image Format Security
    formats: ['image/webp'],
  },
};

export default config;

Why: Vercel provides zero-config deployments, automatic HTTPS, edge functions, and built-in analytics. Preview deployments for every pull request enable safe testing before production.

Self-Hosted

Projects self-hosting MUST use the standalone output:

// next.config.ts
const config: NextConfig = {
  output: 'standalone',
};
# Dockerfile
# node:20-alpine; registry manifest digest.
FROM node@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS base

# Dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# Builder
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Runner
FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]
# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/myapp
    depends_on:
      - db

  db:
    # postgres:17; registry manifest digest.
    image: postgres@sha256:2d2b8998d31037bf721cfdf764d76ba74171b4fab3431b7f72c27c56ddbdf9e3
    environment:
      POSTGRES_PASSWORD: pass
      POSTGRES_USER: user
      POSTGRES_DB: myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Why: Standalone output creates a minimal Node.js server with only required dependencies. Docker enables consistent deployments across environments with isolated dependencies.

Performance Optimization

Turbopack

Projects SHOULD use Turbopack for faster development builds:

// package.json
{
  "scripts": {
    "dev": "next dev --turbo",
    "build": "next build"
  }
}

Why: Turbopack provides 700x faster updates than Webpack in development. Production builds still use Webpack for maximum optimization.

Image Optimization

Projects MUST use Next.js Image component:

import Image from 'next/image';

// Local images (imported)
import heroImage from '@/public/hero.jpg';

export function Hero() {
  return (
    <Image
      src={heroImage}
      alt="Hero image"
      priority // LCP optimization
      placeholder="blur" // Automatic blur placeholder
    />
  );
}

// Remote images
export function Avatar({ src }: { src: string }) {
  return (
    <Image
      src={src}
      alt="User avatar"
      width={40}
      height={40}
      // Remote images require dimensions
    />
  );
}
// next.config.ts
const config: NextConfig = {
  images: {
    // AVIF omitted deliberately - see Image Format Security below
    formats: ['image/webp'],
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
        pathname: '/images/**',
      },
    ],
  },
};

Why: Next.js Image automatically optimizes images, serves modern formats (WebP), generates responsive sizes, and lazy loads by default. remotePatterns is scoped to a single host and path prefix so the optimizer cannot be pointed at arbitrary URLs.

Image Format Security

Projects MUST run Next.js 16.3.3 or later on the 16.x line, or 15.5.24 or later on the 15.x line. Projects MUST NOT list image/avif in images.formats until a Next.js release re-enables AVIF optimization with the fixed upstream libheif.

Why: The August 2026 security release1 documents an unauthenticated remote code execution (GHSA-2xp9-vwfh-vxw4 / GHSA-g89c-p67h-r497): a flaw in the libheif library used by sharp is reachable whenever Next.js optimizes an attacker-controlled AVIF image. Any remote image the optimizer accepts is attacker-controlled input.

Removing image/avif from formats is not a substitute for the patch. formats governs the output format the optimizer prefers, not the input formats it will decode; only the patched releases disable AVIF optimization. Upgrade first, then treat the format list as defense in depth.

// package.json - pin at or above the patched release
{
  "dependencies": {
    "next": "16.3.4"
  }
}

Font Optimization

Projects SHOULD use next/font for automatic font optimization:

// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});

const robotoMono = Roboto_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-roboto-mono',
});

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
      <body>{children}</body>
    </html>
  );
}
/* app/globals.css */
body {
  font-family: var(--font-inter), sans-serif;
}

code {
  font-family: var(--font-roboto-mono), monospace;
}

Why: next/font automatically self-hosts Google Fonts (eliminating external requests), optimizes loading with CSS size-adjust, and prevents layout shift.

Metadata and SEO

Projects MUST define metadata for SEO:

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: {
    template: '%s | My App',
    default: 'My App',
  },
  description: 'My awesome Next.js app',
  openGraph: {
    title: 'My App',
    description: 'My awesome Next.js app',
    images: ['/og-image.jpg'],
  },
};
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

Why: Next.js automatically generates SEO tags from metadata, supports dynamic per-page metadata, and provides type safety for Open Graph and Twitter Cards.

Real-Time Communication

Serverless platforms like Vercel MUST NOT use WebSockets directly (serverless functions are ephemeral and cannot maintain persistent connections). Projects SHOULD use managed real-time services:

Why

Vercel and other serverless platforms run ephemeral functions that scale up and down. WebSocket connections require persistent server processes. Managed services like Pusher13 and Ably14 handle connection infrastructure while your serverless functions communicate via their APIs.

Pusher Integration

npm install pusher@5.3.4 pusher-js@8.6.0
// lib/pusher.ts (server-side)
import Pusher from 'pusher';

export const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
  useTLS: true,
});
// app/api/messages/route.ts
import { pusher } from '@/lib/pusher';
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const { message, channelId } = await request.json();

  await pusher.trigger(`channel-${channelId}`, 'new-message', {
    message,
    timestamp: Date.now(),
  });

  return NextResponse.json({ success: true });
}
// components/chat.tsx
'use client';

import { useEffect, useState } from 'react';
import PusherClient from 'pusher-js';

export function Chat({ channelId }: { channelId: string }) {
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    const pusher = new PusherClient(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
      cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
    });

    const channel = pusher.subscribe(`channel-${channelId}`);
    channel.bind('new-message', (data: { message: string }) => {
      setMessages((prev) => [...prev, data.message]);
    });

    return () => {
      channel.unbind_all();
      pusher.unsubscribe(`channel-${channelId}`);
    };
  }, [channelId]);

  return (
    <ul>
      {messages.map((msg, i) => (
        <li key={i}>{msg}</li>
      ))}
    </ul>
  );
}

Real-Time Service Comparison

ServiceBest ForPricing Model
Pusher13Quick integration, simple APIPer message/connection
Ably14Enterprise, presence, historyPer message
Supabase RealtimePostgreSQL integrationPer project
LiveblocksCollaborative featuresPer user

Self-Hosted Alternative

For self-hosted deployments with persistent servers, Socket.IO15 MAY be used:

// server.ts (custom server, NOT compatible with Vercel)
import { createServer } from 'http';
import { Server } from 'socket.io';
import next from 'next';

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  const server = createServer((req, res) => {
    handle(req, res);
  });

  const io = new Server(server);

  io.on('connection', (socket) => {
    socket.on('message', (data) => {
      io.emit('message', data);
    });
  });

  server.listen(3000);
});

Why: Managed services handle WebSocket infrastructure, scaling, and global presence. They integrate cleanly with serverless functions while providing reliable real-time delivery.

Background Jobs

Projects running on serverless platforms SHOULD use managed background job services:

Why

Serverless functions have execution time limits (Vercel: 10s default, 60s max on Pro). Long-running tasks require dedicated infrastructure. Managed services like Inngest16 and Trigger.dev17 handle orchestration, retries, and monitoring.

Vercel Cron Jobs

For simple scheduled tasks, use Vercel Cron18:

// vercel.json
{
  "crons": [
    {
      "path": "/api/cron/daily-report",
      "schedule": "0 9 * * *"
    },
    {
      "path": "/api/cron/cleanup",
      "schedule": "0 0 * * 0"
    }
  ]
}
// app/api/cron/daily-report/route.ts
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  // Verify cron secret (prevent unauthorized access)
  const authHeader = request.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  await generateDailyReport();
  return NextResponse.json({ success: true });
}

Inngest for Complex Workflows

npm install inngest@4.24.0
// inngest/client.ts
import { Inngest } from 'inngest';

export const inngest = new Inngest({ id: 'my-app' });
// inngest/functions.ts
import { inngest } from './client';

export const processOrder = inngest.createFunction(
  { id: 'process-order', retries: 3 },
  { event: 'order/created' },
  async ({ event, step }) => {
    // Step 1: Validate inventory
    const inventory = await step.run('validate-inventory', async () => {
      return await checkInventory(event.data.items);
    });

    // Step 2: Charge payment (with automatic retry)
    const payment = await step.run('charge-payment', async () => {
      return await chargePayment(event.data.paymentMethod, event.data.total);
    });

    // Step 3: Send confirmation email
    await step.run('send-confirmation', async () => {
      await sendEmail(event.data.email, {
        orderId: event.data.orderId,
        total: event.data.total,
      });
    });

    return { success: true, paymentId: payment.id };
  }
);
// app/api/inngest/route.ts
import { serve } from 'inngest/next';
import { inngest } from '@/inngest/client';
import { processOrder } from '@/inngest/functions';

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [processOrder],
});

Background Job Service Comparison

ServiceBest ForVercel Compatible
Vercel Cron18Simple scheduled tasksNative
Inngest16Complex workflows, AI pipelinesYes
Trigger.dev17Long-running tasks, integrationsYes
BullMQ19Self-hosted, Redis-basedNo (needs server)

Why: Managed background job services provide retries, observability, and durability. Inngest and Trigger.dev run tasks outside your serverless function limits while integrating with your Next.js codebase.

Circuit Breakers

Projects calling external APIs SHOULD implement circuit breakers with Opossum20:

Why

Circuit breakers prevent cascading failures when external services are unavailable. When failures exceed a threshold, the circuit “opens” and fails fast instead of waiting for timeouts. This protects both your application and the failing service.

Implementation

npm install opossum@10.0.0
// lib/circuit-breaker.ts
import CircuitBreaker from 'opossum';

const options = {
  timeout: 3000, // 3 second timeout
  errorThresholdPercentage: 50, // Open circuit when 50% of requests fail
  resetTimeout: 30000, // Try again after 30 seconds
};

async function fetchExternalApi(endpoint: string) {
  const response = await fetch(`https://api.external-service.com${endpoint}`);
  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }
  return response.json();
}

export const apiBreaker = new CircuitBreaker(fetchExternalApi, options);

// Fallback when circuit is open
apiBreaker.fallback(() => ({
  data: null,
  cached: true,
  message: 'Service temporarily unavailable',
}));

// Logging
apiBreaker.on('open', () => console.log('Circuit opened'));
apiBreaker.on('halfOpen', () => console.log('Circuit half-open'));
apiBreaker.on('close', () => console.log('Circuit closed'));
// app/api/external-data/route.ts
import { NextResponse } from 'next/server';
import { apiBreaker } from '@/lib/circuit-breaker';

export async function GET() {
  try {
    const data = await apiBreaker.fire('/data');
    return NextResponse.json(data);
  } catch (error) {
    return NextResponse.json(
      { error: 'Service unavailable', cached: true },
      { status: 503 }
    );
  }
}

Circuit States

StateBehavior
ClosedRequests pass through normally
OpenRequests fail immediately (no network call)
Half-OpenOne request allowed through to test recovery

Serverless Considerations

// For serverless environments, initialize state from external store
const breaker = new CircuitBreaker(fetchExternalApi, {
  ...options,
  // Enable state export for serverless
  volumeThreshold: 5, // Minimum requests before opening
});

// Export/import state for serverless persistence
export async function getCircuitState() {
  return {
    state: breaker.status.stats,
    isOpen: breaker.opened,
  };
}

Why: Circuit breakers prevent your application from repeatedly calling failing services. Opossum is the most mature Node.js circuit breaker with 70,000+ weekly downloads and serverless support.

Feature Flags

Projects SHOULD use the Vercel Flags SDK21 for feature flag integration:

Why

Feature flags enable gradual rollouts, A/B testing, and kill switches. The Vercel Flags SDK provides a framework-agnostic interface that works with any provider (LaunchDarkly, Statsig, custom) while optimizing for Edge Runtime performance.

Installation

npm install flags@4.3.1 @flags-sdk/statsig@0.3.1  # or @flags-sdk/launchdarkly@1.1.1

Configuration with Statsig

// flags.ts
import { flag } from 'flags/next';
import { statsigAdapter } from '@flags-sdk/statsig';

export const showNewDashboard = flag<boolean>({
  key: 'show-new-dashboard',
  adapter: statsigAdapter(),
  defaultValue: false,
  description: 'Enable the redesigned dashboard UI',
  options: [
    { value: true, label: 'Enabled' },
    { value: false, label: 'Disabled' },
  ],
});

export const pricingTier = flag<'basic' | 'premium' | 'enterprise'>({
  key: 'pricing-tier',
  adapter: statsigAdapter(),
  defaultValue: 'basic',
});

Using Flags in Server Components

// app/dashboard/page.tsx
import { showNewDashboard } from '@/flags';

export default async function DashboardPage() {
  const useNewDashboard = await showNewDashboard();

  if (useNewDashboard) {
    return <NewDashboard />;
  }

  return <LegacyDashboard />;
}

Using Flags in Middleware

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { showNewDashboard } from '@/flags';

export async function middleware(request: NextRequest) {
  const useNewDashboard = await showNewDashboard();

  if (useNewDashboard && request.nextUrl.pathname === '/dashboard') {
    return NextResponse.rewrite(new URL('/dashboard-v2', request.url));
  }

  return NextResponse.next();
}

Feature Flag Provider Comparison

ProviderBest ForFree Tier
Statsig22Experimentation, analyticsUnlimited flags
LaunchDarkly23Enterprise, complianceLimited
Vercel Edge ConfigSimple flags, fast readsIncluded
OpenFeatureVendor-agnosticN/A (standard)

Flags with Edge Config (Fastest)

// For simple flags with minimal latency
import { get } from '@vercel/edge-config';

export async function getFeatureFlags() {
  return {
    maintenanceMode: await get('maintenance_mode'),
    newPricing: await get('new_pricing_enabled'),
  };
}

Why: The Flags SDK provides a unified interface across providers while Vercel Edge Config bootstraps flags without network requests. This combination enables <1ms flag evaluation at the edge.

Static Site Generation (SSG) and ISR

This section describes the route-segment caching model, the model that applies when cacheComponents is not enabled. It remains supported in Next.js 16. Projects that have enabled Cache Components MUST NOT mix the two: use use cache, cacheLife, and cacheTag instead of the dynamic, revalidate, and dynamicParams segment exports.

Projects SHOULD use Static Site Generation (SSG) and Incremental Static Regeneration (ISR) for content that doesn’t change frequently.

Why SSG and ISR

  • Performance: Pre-rendered pages serve instantly from CDN edge nodes
  • Cost: Reduce server compute by serving static files
  • SEO: Complete HTML available for crawlers without JavaScript
  • Reliability: Static pages work even if backend services fail

Rendering Strategies Comparison

StrategyBuild TimeUser WaitData FreshnessBest For
SSGPre-renderNoneBuild-timeMarketing pages, docs
ISRPre-render + revalidateNone (cache hit)ConfigurableBlogs, product pages
Cache ComponentsStatic shell + use cacheStreamingPer-tag/lifetimeDashboards, personalized
DynamicOn requestFullReal-timeAuth-required, search

Full Static Generation

// app/blog/[slug]/page.tsx
// Fully static - built at build time
export async function generateStaticParams() {
  const posts = await getPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <Article post={post} />;
}

// This page is 100% static - no server execution after build
export const dynamic = 'force-static';

Incremental Static Regeneration (ISR)

// app/products/[id]/page.tsx
// Static with time-based revalidation
export const revalidate = 3600; // Revalidate every hour

export async function generateStaticParams() {
  // Pre-generate top 100 products at build
  const products = await getTopProducts(100);
  return products.map((p) => ({ id: p.id.toString() }));
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  // Cached and revalidated every hour
  const product = await getProduct(id);
  return <ProductDetail product={product} />;
}

// Dynamic params (not pre-generated) will be SSR then cached
export const dynamicParams = true; // default

On-Demand Revalidation

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const { secret, path, tag } = await request.json();

  // Verify webhook secret
  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Revalidate by path
  if (path) {
    revalidatePath(path);
    return Response.json({ revalidated: true, path });
  }

  // Revalidate by tag: a webhook cannot use updateTag, so expire immediately
  if (tag) {
    revalidateTag(tag, { expire: 0 });
    return Response.json({ revalidated: true, tag });
  }

  return Response.json({ error: 'No path or tag provided' }, { status: 400 });
}
// lib/api.ts - Tag-based caching
export async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: {
      tags: ['products'],
      revalidate: 3600,
    },
  });
  return res.json();
}

export async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: {
      tags: ['products', `product-${id}`],
      revalidate: 3600,
    },
  });
  return res.json();
}

ISR with fallback loading

// app/posts/[slug]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';

// Pre-generate some pages at build time
export async function generateStaticParams() {
  const posts = await getRecentPosts(50);
  return posts.map((post) => ({ slug: post.slug }));
}

// Generate other slugs on-demand
export const dynamicParams = true;

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments postId={post.id} />
      </Suspense>
    </article>
  );
}

Static Export

// next.config.ts - Full static export (no server needed)
import type { NextConfig } from 'next';

const config: NextConfig = {
  output: 'export',
  // Optional: Change output directory
  distDir: 'out',
  // Optional: Trailing slashes for static hosts
  trailingSlash: true,
};

export default config;

Why: SSG provides the best performance and lowest cost for content that changes infrequently. ISR adds flexibility to update content without full rebuilds, making it ideal for content-heavy sites.

Internationalization (i18n)

Projects with multilingual content SHOULD implement i18n using Next.js routing patterns and message catalogs.

Why i18n

  • Market reach: Serve users in their preferred language
  • SEO: Proper hreflang tags improve search visibility per locale
  • UX: Users engage more with localized content
  • Legal: Some regions require content in local languages

Routing Strategies

StrategyURL PatternBest For
Subdirectory/en/about, /fr/aboutMost sites, SEO-friendly
Domainen.example.com, fr.example.comEnterprise, regional sites
Cookie/HeaderSame URL, detect localeSPAs, APIs
app/
├── [lang]/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── about/
│   │   └── page.tsx
│   └── blog/
│       └── [slug]/
│           └── page.tsx
├── dictionaries/
│   ├── en.json
│   └── fr.json
└── middleware.ts

Locale Configuration

One module MUST own the supported locale list, and every other file MUST import it. Duplicated lists drift, and a locale that exists in the middleware but not in the dictionary map becomes a runtime crash.

// lib/i18n-config.ts
export const LOCALES = ['en', 'fr', 'de', 'es'] as const;

export type Locale = (typeof LOCALES)[number];

export const DEFAULT_LOCALE: Locale = 'en';

export function isLocale(value: string): value is Locale {
  return (LOCALES as readonly string[]).includes(value);
}

Middleware for Language Detection

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
import { DEFAULT_LOCALE, LOCALES } from '@/lib/i18n-config';

function getLocale(request: NextRequest): string {
  const headers = { 'accept-language': request.headers.get('accept-language') || '' };
  const languages = new Negotiator({ headers }).languages();
  return match(languages, [...LOCALES], DEFAULT_LOCALE);
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Check if pathname already has locale
  const pathnameHasLocale = LOCALES.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) return;

  // Redirect to locale-prefixed path
  const locale = getLocale(request);
  request.nextUrl.pathname = `/${locale}${pathname}`;
  return NextResponse.redirect(request.nextUrl);
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Dictionary Loading

// lib/dictionaries.ts
import 'server-only';
import type { Locale } from '@/lib/i18n-config';

const dictionaries = {
  en: () => import('@/dictionaries/en.json').then((m) => m.default),
  fr: () => import('@/dictionaries/fr.json').then((m) => m.default),
  de: () => import('@/dictionaries/de.json').then((m) => m.default),
  es: () => import('@/dictionaries/es.json').then((m) => m.default),
};

export type Dictionary = Awaited<ReturnType<typeof dictionaries.en>>;

// Indexing by Locale makes a locale without a dictionary a compile error
export async function getDictionary(locale: Locale): Promise<Dictionary> {
  return dictionaries[locale]();
}

Typing the parameter as Locale rather than string means a locale added to LOCALES without a matching dictionary fails to compile (TS7053: Property 'it' does not exist) instead of crashing in production.

// dictionaries/en.json
{
  "navigation": {
    "home": "Home",
    "about": "About",
    "contact": "Contact"
  },
  "home": {
    "title": "Welcome to our site",
    "description": "We build great products"
  }
}
// dictionaries/fr.json
{
  "navigation": {
    "home": "Accueil",
    "about": "À propos",
    "contact": "Contact"
  },
  "home": {
    "title": "Bienvenue sur notre site",
    "description": "Nous créons de super produits"
  }
}

Using Dictionaries in Components

The [lang] segment is an arbitrary URL string. Pages MUST validate it at runtime before using it as a dictionary key, and SHOULD set dynamicParams = false when only the generated locales are valid.

// app/[lang]/page.tsx
import { notFound } from 'next/navigation';
import { getDictionary } from '@/lib/dictionaries';
import { isLocale, LOCALES } from '@/lib/i18n-config';

// Route-segment model only: anything outside generateStaticParams is a 404.
// Omit this line when cacheComponents is enabled - the two are incompatible.
export const dynamicParams = false;

export default async function Home({
  params,
}: {
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;

  if (!isLocale(lang)) {
    notFound();
  }

  const dict = await getDictionary(lang);

  return (
    <main>
      <h1>{dict.home.title}</h1>
      <p>{dict.home.description}</p>
    </main>
  );
}

export function generateStaticParams() {
  return LOCALES.map((lang) => ({ lang }));
}

Don’t — the annotation is erased at build time, so an unsupported segment reaches the lookup and throws TypeError: dictionaries[locale] is not a function, producing a 500 instead of a 404:

export default async function Home({
  params,
}: {
  params: Promise<{ lang: Locale }>;
}) {
  const { lang } = await params;
  const dict = await getDictionary(lang);
  // ...
}

Why: The language-detection middleware redirects unprefixed paths, but it does not police the segment. Its own matcher excludes /api, /_next/*, and favicon.ico, so a request such as /api-docs is never rewritten and still matches app/[lang]/page.tsx with lang = 'api-docs'. Without the guard, Next.js 16.3.4 returns HTTP 500 with TypeError: dictionaries[locale] is not a function; with it, the same request returns 404. Never rely on a TypeScript annotation to constrain a value that arrives from a URL.

Applications needing message extraction, pluralization, ICU formatting, or typed navigation helpers SHOULD use next-intl24 instead of extending this hand-rolled catalog.

Language Switcher

// components/language-switcher.tsx
'use client';

import { usePathname, useRouter } from 'next/navigation';
import { LOCALES, type Locale } from '@/lib/i18n-config';

const LOCALE_LABELS: Record<Locale, string> = {
  en: 'English',
  fr: 'Français',
  de: 'Deutsch',
  es: 'Español',
};

export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
  const pathname = usePathname();
  const router = useRouter();

  const switchLocale = (locale: Locale) => {
    // Replace current locale in pathname
    const newPath = pathname.replace(`/${currentLocale}`, `/${locale}`);
    router.push(newPath);
  };

  return (
    <select
      value={currentLocale}
      onChange={(e) => switchLocale(e.target.value as Locale)}
      aria-label="Select language"
    >
      {LOCALES.map((locale) => (
        <option key={locale} value={locale}>
          {LOCALE_LABELS[locale]}
        </option>
      ))}
    </select>
  );
}

SEO with hreflang

// app/[lang]/layout.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { isLocale, LOCALES } from '@/lib/i18n-config';

const baseUrl = 'https://example.com';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ lang: string }>;
}): Promise<Metadata> {
  const { lang } = await params;

  return {
    alternates: {
      canonical: `${baseUrl}/${lang}`,
      languages: Object.fromEntries(
        LOCALES.map((locale) => [locale, `${baseUrl}/${locale}`])
      ),
    },
  };
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;

  return (
    <html lang={lang}>
      <body>{children}</body>
    </html>
  );
}

Date and Number Formatting

// lib/formatters.ts
import { type Locale } from './i18n-config';

export function formatDate(date: Date, locale: Locale): string {
  return new Intl.DateTimeFormat(locale, {
    dateStyle: 'long',
  }).format(date);
}

export function formatCurrency(
  amount: number,
  locale: Locale,
  currency: string = 'USD'
): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
  }).format(amount);
}

export function formatNumber(number: number, locale: Locale): string {
  return new Intl.NumberFormat(locale).format(number);
}
// Usage in component
import { notFound } from 'next/navigation';
import { formatDate, formatCurrency } from '@/lib/formatters';
import { isLocale } from '@/lib/i18n-config';

export default async function ProductPage({
  params,
}: {
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;

  if (!isLocale(lang)) {
    notFound();
  }

  const product = await getProduct();

  return (
    <article>
      <h1>{product.name}</h1>
      <p>{formatCurrency(product.price, lang, 'EUR')}</p>
      <p>Added: {formatDate(product.createdAt, lang)}</p>
    </article>
  );
}

Why: Next.js App Router’s segment-based routing provides clean i18n implementation without external libraries. Server Components enable per-request dictionary loading with zero client bundle impact.

See Also

References

Footnotes

  1. August 2026 Security Release - Critical AVIF image-optimization and Windows RCE fixes in 16.3.3 and 15.5.24 ↩ ↩2

  2. Turbopack - Incremental bundler optimized for JavaScript and TypeScript ↩

  3. Vitest - Blazing fast unit test framework powered by Vite ↩

  4. React Testing Library - Simple and complete React DOM testing utilities ↩

  5. Playwright - End-to-end testing for modern web apps ↩

  6. eslint-config-next - Next.js ESLint configuration with framework-specific rules ↩

  7. Next.js - The React Framework for the Web ↩

  8. Cache Components - Enables Partial Prerendering and the use cache directive ↩

  9. Upstash Ratelimit - Rate limiting library for serverless runtimes using HTTP-based Redis ↩

  10. Better Auth - Framework-agnostic TypeScript authentication library, recommended by Auth.js for new projects ↩

  11. Auth.js - Authentication for the Web, formerly NextAuth.js ↩

  12. Vercel - Platform for deploying Next.js applications with zero configuration ↩

  13. Pusher - Managed real-time messaging service with pub/sub channels ↩ ↩2

  14. Ably - Enterprise real-time messaging platform with presence and history ↩ ↩2

  15. Socket.IO - Bidirectional event-based communication library (requires persistent server) ↩

  16. Inngest - Workflow orchestration platform for background jobs and AI pipelines ↩ ↩2

  17. Trigger.dev - Open-source platform for background jobs and long-running tasks ↩ ↩2

  18. Vercel Cron Jobs - Native scheduled tasks for Vercel deployments ↩ ↩2

  19. BullMQ - Redis-based background job processing for self-hosted environments ↩

  20. Opossum - Node.js circuit breaker with 70,000+ weekly downloads ↩

  21. Vercel Flags SDK - Framework-agnostic feature flags with provider adapters ↩

  22. Statsig - Feature flags and experimentation platform with unlimited free flags ↩

  23. LaunchDarkly - Enterprise feature management platform ↩

  24. next-intl - App Router internationalization with typed messages, ICU formatting, and locale-aware navigation ↩