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:
| Task | Tool | Command |
|---|---|---|
| Install | npm/pnpm/yarn | npx create-next-app@16.3.4 |
| Run dev | Next.js | npm run dev |
| Build | Next.js | npm run build |
| Bundler | Turbopack2 | next dev --turbo |
| Test | Vitest3 + Testing Library4 | vitest |
| E2E | Playwright5 | playwright test |
| Lint | ESLint + eslint-config-next6 | npx 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 for | Use Client Components for |
|---|---|
| Data fetching | Interactivity (onClick, onChange) |
| Backend resources access | State (useState, useReducer) |
| Sensitive data (API keys) | Effects (useEffect, useLayoutEffect) |
| Large dependencies | Browser APIs (localStorage, window) |
| SEO-critical content | Event 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:
| Option | How | Use when |
|---|---|---|
| Stream | Move the read into a child inside <Suspense fallback={...}> | The shell is useful without that data |
| Cache | Wrap the read in use cache and pass request values as arguments | The result is shareable between requests |
| Block | export const instant = false | The 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:
| API | Semantics | Use when |
|---|---|---|
updateTag(tag) | Expires and refreshes in the same request | A Server Action whose user must see their own write |
revalidateTag(tag, 'max') | Stale-while-revalidate for up to a year | Content where a short delay is acceptable |
revalidateTag(tag, { expire: 0 }) | Next read blocks on a cache miss | A 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 Case | Pattern |
|---|---|
| Authentication | Check tokens, redirect to login |
| Authorization | Role-based access control |
| Redirects | A/B testing, localization |
| Bot detection | User-Agent analysis |
| Rate limiting | Request 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
| Algorithm | Use 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.
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.
| Situation | Library |
|---|---|
| New project | Better Auth 1.7.3 |
| Existing Auth.js v5 deployment that works | Auth.js, pinned to 5.0.0-beta.32 |
| Existing Auth.js v4 deployment | Migrate 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
| Strategy | Pros | Cons |
|---|---|---|
| JWT | Edge-compatible, no DB lookups, horizontal scaling | Cannot revoke until expiry |
| Database | Immediate revocation, audit trail | Requires 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
Vercel (Recommended)
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
| Service | Best For | Pricing Model |
|---|---|---|
| Pusher13 | Quick integration, simple API | Per message/connection |
| Ably14 | Enterprise, presence, history | Per message |
| Supabase Realtime | PostgreSQL integration | Per project |
| Liveblocks | Collaborative features | Per 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
| Service | Best For | Vercel Compatible |
|---|---|---|
| Vercel Cron18 | Simple scheduled tasks | Native |
| Inngest16 | Complex workflows, AI pipelines | Yes |
| Trigger.dev17 | Long-running tasks, integrations | Yes |
| BullMQ19 | Self-hosted, Redis-based | No (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
| State | Behavior |
|---|---|
| Closed | Requests pass through normally |
| Open | Requests fail immediately (no network call) |
| Half-Open | One 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
| Provider | Best For | Free Tier |
|---|---|---|
| Statsig22 | Experimentation, analytics | Unlimited flags |
| LaunchDarkly23 | Enterprise, compliance | Limited |
| Vercel Edge Config | Simple flags, fast reads | Included |
| OpenFeature | Vendor-agnostic | N/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
| Strategy | Build Time | User Wait | Data Freshness | Best For |
|---|---|---|---|---|
| SSG | Pre-render | None | Build-time | Marketing pages, docs |
| ISR | Pre-render + revalidate | None (cache hit) | Configurable | Blogs, product pages |
| Cache Components | Static shell + use cache | Streaming | Per-tag/lifetime | Dashboards, personalized |
| Dynamic | On request | Full | Real-time | Auth-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
| Strategy | URL Pattern | Best For |
|---|---|---|
| Subdirectory | /en/about, /fr/about | Most sites, SEO-friendly |
| Domain | en.example.com, fr.example.com | Enterprise, regional sites |
| Cookie/Header | Same URL, detect locale | SPAs, APIs |
Subdirectory Routing (Recommended)
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
- TypeScript Style Guide - Base TypeScript conventions
- Testing Guide - Testing best practices
- CI Guide - Continuous integration patterns
References
Footnotes
-
August 2026 Security Release - Critical AVIF image-optimization and Windows RCE fixes in 16.3.3 and 15.5.24 ↩ ↩2
-
Turbopack - Incremental bundler optimized for JavaScript and TypeScript ↩
-
React Testing Library - Simple and complete React DOM testing utilities ↩
-
Playwright - End-to-end testing for modern web apps ↩
-
eslint-config-next - Next.js ESLint configuration with framework-specific rules ↩
-
Cache Components - Enables Partial Prerendering and the
use cachedirective ↩ -
Upstash Ratelimit - Rate limiting library for serverless runtimes using HTTP-based Redis ↩
-
Better Auth - Framework-agnostic TypeScript authentication library, recommended by Auth.js for new projects ↩
-
Auth.js - Authentication for the Web, formerly NextAuth.js ↩
-
Vercel - Platform for deploying Next.js applications with zero configuration ↩
-
Pusher - Managed real-time messaging service with pub/sub channels ↩ ↩2
-
Ably - Enterprise real-time messaging platform with presence and history ↩ ↩2
-
Socket.IO - Bidirectional event-based communication library (requires persistent server) ↩
-
Inngest - Workflow orchestration platform for background jobs and AI pipelines ↩ ↩2
-
Trigger.dev - Open-source platform for background jobs and long-running tasks ↩ ↩2
-
Vercel Cron Jobs - Native scheduled tasks for Vercel deployments ↩ ↩2
-
BullMQ - Redis-based background job processing for self-hosted environments ↩
-
Opossum - Node.js circuit breaker with 70,000+ weekly downloads ↩
-
Vercel Flags SDK - Framework-agnostic feature flags with provider adapters ↩
-
Statsig - Feature flags and experimentation platform with unlimited free flags ↩
-
LaunchDarkly - Enterprise feature management platform ↩
-
next-intl - App Router internationalization with typed messages, ICU formatting, and locale-aware navigation ↩