Back to All Cheatsheet Libraries cheatsheets

Next.js

App Router file conventions, data fetching and caching rules, Server Actions, config and metadata, and self-host/Docker/Vercel deploy recipes for Next.js 15 and 16.

Next.js is React with the server half built in

A React framework for full-stack web apps: file-based routing, React Server Components, server-side rendering and static generation, API routes, image/font optimisation, and a production build that runs on Node, a Docker container, or Vercel. Since v13 the App Router (app/) is the default; the older Pages Router (pages/) still works and both can coexist in one project. Everything on this page is App Router unless it says otherwise, and targets Next.js 15/16 with React 19.

Mental model in one line: every file in app/ is a Server Component until you write 'use client' at the top. Server code (database calls, secrets, heavy libraries) never ships to the browser; client code is only what needs state, effects, or browser APIs.

1

Create a project

The interactive installer asks about TypeScript, ESLint, Tailwind, the src/ folder, the App Router, Turbopack, and an import alias. Say yes to TypeScript and the App Router unless you have a reason not to. Requires Node 18.18+ (Next 15) or Node 20.9+ (Next 16).

npx create-next-app@latest my-site cd my-site && npm run dev # http://localhost:3000 # non-interactive, opinionated defaults npx create-next-app@latest my-site --ts --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm
2

Know the project layout

Only app/ (or src/app/), public/, and next.config.* are special. Everything else is yours.

my-site/ ├── app/ # routes: every folder = URL segment, page.tsx = the page │ ├── layout.tsx # root layout (required): <html><body>, global nav, fonts │ ├── page.tsx # "/" │ ├── globals.css │ ├── about/page.tsx # "/about" │ ├── blog/[slug]/page.tsx # "/blog/anything" │ └── api/contact/route.ts # "/api/contact" (GET/POST handlers) ├── components/ # your React components (not routed) ├── lib/ # db clients, helpers, server-only code ├── public/ # static files served from "/" (favicon.ico, robots.txt, images) ├── next.config.ts # framework config ├── .env.local # secrets — never committed ├── tsconfig.json └── package.json
3

The scripts you'll run all day

npm run dev # dev server, hot reload (Turbopack by default in 15+) npm run build # production build → .next/ (fails on type/lint errors — that's a feature) npm run start # serve the production build on :3000 npx next build --debug # why is this page dynamic? print route/render info npx next info # versions for bug reports npx next telemetry disable npx @next/codemod@latest upgrade latest # guided version upgrade with codemods
4

Your first page, layout, and client component

Layouts wrap pages and persist across navigation (they don't re-render when you move between child routes). Link prefetches routes in the viewport, which is why Next apps feel instant.

// app/layout.tsx — Server Component, wraps every page import Link from 'next/link'; import './globals.css'; export const metadata = { title: 'Acme', description: 'Acme Co.' }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <nav><Link href="/">Home</Link> <Link href="/about">About</Link></nav> {children} </body> </html> ); } // app/about/page.tsx — Server Component: can be async and hit a DB directly export default async function AboutPage() { const team = await db.team.findMany(); return <main><h1>About</h1><TeamList team={team} /></main>; } // components/Counter.tsx — Client Component: state, effects, onClick live here 'use client'; import { useState } from 'react'; export default function Counter() { const [n, setN] = useState(0); return <button onClick={() => setN(n + 1)}>{n}</button>; }
Server Component (default) → renders on the server, zero JS shipped | 'use client' → hydrates in the browser

Server Components can

async/await data, read files, query a DB, use secrets, import heavy libs. Cannot use hooks, state, event handlers, or browser APIs.

Client Components can

useState/useEffect, onClick, window, third-party UI libs. Everything they import is bundled to the browser.

Composition rule

Server can render Client; Client can't import Server — but can receive Server Components as children/props. Push 'use client' to the leaves.

Keep secrets on the server

import 'server-only' at the top of a module makes it a build error to import it from client code.

Vocabulary you'll meet in the docs

RSC — React Server Components SSR — render per request SSG — render at build ISR — re-render on a timer PPR — partial prerendering Server Action Route Handler Route Segment Hydration Turbopack

Version landmarks

13 — App Router, RSC 14 — Server Actions stable 15 — React 19, async request APIs, fetch uncached by default, Turbopack dev 16 — Turbopack builds, proxy.ts replaces middleware.ts, "use cache", next lint removed

Routing is the filesystem. A folder is a URL segment; a handful of reserved filenames inside it decide what renders. Files that aren't on this list (components, tests, styles) can live anywhere in app/ without becoming routes.

Showing 0 conventions
File / folder URL / role Example Notes
page.tsxThe UI for a route. Makes the folder publicly reachable.app/pricing/page.tsx → /pricingDefault export a component. Only page and route make URLs; a folder with neither is just organisation.
layout.tsxShared shell for a segment and everything under it.app/dashboard/layout.tsxReceives children. Persists across sibling navigation (state kept, no re-render). Root layout must render <html> and <body>. Layouts don't get searchParams.
template.tsxLike layout, but re-mounts on every navigation.app/template.tsxUse for enter animations or per-page effects that must re-run. Rare.
loading.tsxInstant loading UI (Suspense boundary) while the segment's data loads.app/blog/loading.tsxReturn a skeleton. Wraps page automatically; streams the real page in when ready.
error.tsxError boundary for the segment.'use client'; export default function Error({ error, reset })Must be a Client Component. reset() re-renders the segment. Doesn't catch errors in the same-level layout — put error.tsx one level up, or use global-error.tsx for the root.
not-found.tsx404 UI; triggered by notFound() or an unmatched URL.import { notFound } from 'next/navigation'; if (!post) notFound();Root-level one handles every unmatched route on the site.
route.tsAPI endpoint (Route Handler). Web-standard Request/Response.app/api/contact/route.ts → POST /api/contactExport GET, POST, PUT, DELETE… Can't sit next to a page.tsx in the same folder. See the Data tab for a full example.
[slug]Dynamic segment.app/blog/[slug]/page.tsx → /blog/helloRead via the params prop — a Promise since Next 15: const { slug } = await params;
[...parts]Catch-all: one or more segments.app/docs/[...parts]/page.tsx → /docs/a/b/cparts is an array. Docs sites, CMS-driven trees.
[[...parts]]Optional catch-all: zero or more segments.app/shop/[[...parts]]/page.tsx → /shop and /shop/x/ySame page handles the index and every depth.
(group)Route group: organise without affecting the URL.app/(marketing)/about/page.tsx → /aboutEach group can have its own layout.tsx — e.g. (marketing) with a big header, (app) with a sidebar. Multiple root layouts are allowed this way.
_folderPrivate folder: never routed, even with a page inside.app/_components/Button.tsxColocate components with the routes that use them without any risk of exposing them.
@slotParallel route: render two pages in one layout at once.app/dashboard/@stats/page.tsx + @feed/page.tsxLayout receives them as props ({ children, stats, feed }). Each slot needs a default.tsx for hard navigations. Dashboards, modals.
(.)path / (..)pathIntercepting route: show a route "inside" the current one (modal), full page on refresh.app/feed/(..)photo/[id]/page.tsxThe Instagram photo-modal pattern. (.) same level, (..) one up, (...) from root. Combine with a parallel @modal slot.
default.tsxFallback for a parallel slot when the URL doesn't match it.app/dashboard/@feed/default.tsxReturn null to render nothing.
proxy.ts (16) / middleware.ts (≤15)Runs before every matched request: redirects, rewrites, auth gates, headers, geo/A-B.export function proxy(req: NextRequest) { … } export const config = { matcher: ['/admin/:path*'] };Project root (or src/), one file only. Keep it light — it runs on every request in the matcher. Next 16 renamed it to proxy.ts (export proxy) and runs it on Node by default; the codemod migrates it.
instrumentation.tsRuns once at server start.export async function register() { … }Sentry/OpenTelemetry init, warm a DB pool.
sitemap.ts / robots.tsGenerate /sitemap.xml and /robots.txt from code.export default async function sitemap(): Promise<MetadataRoute.Sitemap>Pull slugs from the DB/CMS so the sitemap is never stale. Static files in public/ work too.
opengraph-image.tsx / icon.tsxGenerate OG/social images and favicons per route.import { ImageResponse } from 'next/og';JSX → PNG at request/build time. Or drop static opengraph-image.png / favicon.ico in the folder.
public/Static assets served from the site root.public/logo.svg → /logo.svgNot processed, not hashed — bump filenames for cache-busting or set headers in config.
pages/ (Pages Router)The legacy router. pages/about.tsx → /about, pages/api/*.ts for APIs.getStaticProps / getServerSideProps / getStaticPathsStill supported. Data fetching via exported functions instead of async components. Can coexist with app/ during a migration.
Navigation API Usage Notes
<Link>import Link from 'next/link'; <Link href="/blog/hi" prefetch={false}>Client-side navigation + viewport prefetch. Works in Server Components. replace, scroll={false} props.
useRouter()'use client'; const r = useRouter(); r.push('/x'); r.replace(); r.back(); r.refresh();From next/navigation (not next/router — that's Pages Router). refresh() re-fetches Server Components without losing client state.
usePathname() / useSearchParams()const path = usePathname(); const q = useSearchParams().get('q');Client hooks. Active-link styling, reading filters. useSearchParams needs a Suspense boundary or the page opts out of static rendering.
useParams()const { slug } = useParams<{ slug: string }>();Dynamic segment values in a Client Component (Server Components get them via the params prop).
redirect() / permanentRedirect()import { redirect } from 'next/navigation'; if (!user) redirect('/login');Server side (components, actions, handlers). Throws — don't wrap in try/catch. 307 vs 308.
notFound()if (!post) notFound();Renders the nearest not-found.tsx with a 404 status.
generateStaticParams()export async function generateStaticParams() { return posts.map(p => ({ slug: p.slug })); }Pre-build dynamic routes at build time (SSG). Pair with dynamicParams = false to 404 anything else.

Fetch on the server, mutate with Server Actions, cache on purpose

In the App Router you fetch data inside async Server Components — no useEffect, no loading-state boilerplate, no API round-trip from the browser. Mutations go through Server Actions (functions marked 'use server' that forms and buttons call directly). And rendering mode isn't something you pick per page so much as something Next infers: a route is static unless it reads request data (cookies, headers, searchParams) or opts into dynamic behaviour.

The one thing to get right: caching changed in Next 15. fetch() is no longer cached by default, route handlers are uncached, and the client router cache no longer reuses page segments. If a tutorial tells you to add cache: 'no-store' everywhere, it's for Next 13/14.

1

Read data in a Server Component

// app/blog/[slug]/page.tsx import { notFound } from 'next/navigation'; export default async function Post({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; // params is a Promise in 15+ const res = await fetch(`https://cms.example.com/posts/${slug}`, { next: { revalidate: 3600, tags: ['posts'] }, // ISR: cache 1h, tag for on-demand purge }); if (res.status === 404) notFound(); const post = await res.json(); return <article><h1>{post.title}</h1></article>; } // Or skip HTTP entirely — a DB client works directly in Server Components: // const post = await prisma.post.findUnique({ where: { slug } });
2

Write data with a Server Action

A form posts straight to a server function. Works before JS loads (progressive enhancement). revalidatePath/revalidateTag refresh the cached pages that showed the old data.

// app/actions.ts 'use server'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { z } from 'zod'; export async function createPost(prev: unknown, formData: FormData) { const parsed = z.object({ title: z.string().min(3) }).safeParse({ title: formData.get('title') }); if (!parsed.success) return { error: 'Title too short' }; await db.post.create({ data: parsed.data }); revalidatePath('/blog'); redirect('/blog'); } // app/blog/new/page.tsx (Client Component for pending state) 'use client'; import { useActionState } from 'react'; import { createPost } from '@/app/actions'; export default function NewPost() { const [state, action, pending] = useActionState(createPost, null); return ( <form action={action}> <input name="title" /> {state?.error && <p>{state.error}</p>} <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button> </form> ); }
3

Expose an API with a Route Handler

For webhooks, mobile clients, or anything that isn't your own React UI. Server Actions are usually better for your own forms.

// app/api/contact/route.ts import { NextRequest, NextResponse } from 'next/server'; export async function POST(req: NextRequest) { const body = await req.json(); if (!body.email) return NextResponse.json({ error: 'email required' }, { status: 400 }); await sendMail(body); return NextResponse.json({ ok: true }); } export async function GET(req: NextRequest) { const q = req.nextUrl.searchParams.get('q'); return NextResponse.json({ q }, { headers: { 'Cache-Control': 's-maxage=60' } }); } // Dynamic handler: app/api/posts/[id]/route.ts export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; … }
4

Read the request: cookies, headers, searchParams

All async since 15. Using any of them makes the route dynamic (rendered per request).

import { cookies, headers } from 'next/headers'; export default async function Page({ searchParams }: { searchParams: Promise<{ page?: string }> }) { const { page = '1' } = await searchParams; const theme = (await cookies()).get('theme')?.value; const ua = (await headers()).get('user-agent'); … } // set a cookie — only in a Server Action or Route Handler, not in a component (await cookies()).set('theme', 'dark', { httpOnly: true, path: '/' });
5

Stream slow parts with Suspense

Send the shell immediately; stream the slow component in when its data arrives. loading.tsx does this for a whole segment; <Suspense> does it per component.

import { Suspense } from 'react'; export default function Page() { return ( <> <Hero /> {/* instant */} <Suspense fallback={<ReviewsSkeleton />}> <Reviews /> {/* async Server Component, streams in */} </Suspense> </> ); }
Showing 0 controls
Control Syntax Effect
Static (default)— nothing to add —A route with no request-time data is rendered once at next build and served as HTML. Fastest possible.
fetch with revalidate (ISR)fetch(url, { next: { revalidate: 60 } })Cache the response for 60s, then regenerate in the background on the next hit. Stale-while-revalidate for pages.
fetch cached foreverfetch(url, { cache: 'force-cache' })Cache until manually revalidated. The old Next 13/14 default; explicit now.
fetch never cachedfetch(url, { cache: 'no-store' })Already the default in 15+. Also makes the route dynamic.
Cache tagsfetch(url, { next: { tags: ['posts'] } }) → revalidateTag('posts')Label cached data; purge every page that used it in one call (from a Server Action or a webhook route handler when the CMS publishes).
revalidatePathrevalidatePath('/blog'); revalidatePath('/blog/[slug]', 'page');Purge one route (or every instance of a dynamic route). Call after a mutation.
Segment: revalidateexport const revalidate = 3600;Page/layout-level ISR interval for everything in it. 0 = dynamic. false = cache forever.
Segment: dynamicexport const dynamic = 'force-dynamic' | 'force-static' | 'error' | 'auto';Force per-request rendering (dashboards), force static (and error if something dynamic sneaks in), or let Next infer.
Segment: dynamicParamsexport const dynamicParams = false;With generateStaticParams: unknown slugs 404 instead of rendering on demand.
Segment: runtimeexport const runtime = 'nodejs' | 'edge';Edge = smaller, faster cold start, but no Node APIs (fs, most DB drivers). Default Node. Rarely worth switching.
unstable_cache / "use cache"'use cache'; export async function getPosts() { … } // 16 with cacheComponents; cacheLife('hours')Cache the result of non-fetch work (DB queries) with the same revalidate/tag semantics. In 15 the API is unstable_cache(fn, keys, { revalidate, tags }).
React cache()import { cache } from 'react'; export const getUser = cache(async (id) => db.user.find(id));Dedupe the same call across components in one request (fetch is deduped automatically; DB calls aren't).
Client router cacheexperimental.staleTimes: { dynamic: 30, static: 180 } // next.configHow long the browser reuses a visited segment on back/forward or re-navigation. 15 sets dynamic pages to 0 (always fresh).
Partial Prerendering (PPR)experimental.ppr: 'incremental'; export const experimental_ppr = true;Static shell served instantly, dynamic holes streamed in — one route, both modes. Experimental in 15; the direction everything is heading (Cache Components in 16).
Opt out of everythingimport { connection } from 'next/server'; await connection();"Wait for a real request before rendering" — for Math.random()/Date.now() pages that must not be baked at build (replaces unstable_noStore).
Showing 0 items
Thing How Notes
next.config.tsimport type { NextConfig } from 'next'; const config: NextConfig = { … }; export default config;TypeScript config supported since 15. Restart dev after editing.
Env: server secrets.env.local → DATABASE_URL=… process.env.DATABASE_URLOnly readable in server code. .env.local is gitignored by the starter; .env, .env.production are committed defaults.
Env: browser-visibleNEXT_PUBLIC_SITE_URL=https://acme.comThe NEXT_PUBLIC_ prefix inlines it into the client bundle at build time. Never put a secret here. Changing it needs a rebuild.
Imagesimport Image from 'next/image'; <Image src="/hero.jpg" width={1200} height={600} alt="…" priority />Auto WebP/AVIF, responsive srcset, lazy-load, no layout shift. Remote hosts must be allow-listed: images.remotePatterns: [{ hostname: 'cdn.acme.com' }]. Use fill + a sized parent when dimensions are unknown.
Fontsimport { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'] }); <body className={inter.className}>Downloads at build, self-hosts, zero layout shift, no request to Google at runtime. next/font/local for your own files. Expose as a CSS variable with variable: '--font-inter' for Tailwind.
Metadata (static)export const metadata: Metadata = { title: { default: 'Acme', template: '%s | Acme' }, description, openGraph: { … } };In any layout/page. Merged top-down. Also metadataBase for absolute OG URLs, alternates.canonical, robots.
Metadata (dynamic)export async function generateMetadata({ params }): Promise<Metadata> { const p = await getPost((await params).slug); return { title: p.title }; }Per-post titles/OG. Fetches are deduped with the page's own.
Scriptsimport Script from 'next/script'; <Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXX" strategy="afterInteractive" />Strategies: beforeInteractive, afterInteractive (default), lazyOnload. Google Analytics/Tag Manager helpers exist in @next/third-parties.
CSSimport './globals.css' | import s from './Card.module.css' | Tailwind | Sass (npm i sass)Global CSS only in the root layout. CSS Modules anywhere. Tailwind v4 is the starter's default when you say yes.
Redirects / rewrites / headersasync redirects() { return [{ source: '/old', destination: '/new', permanent: true }]; }In next.config. rewrites proxy silently (/api/:path* → https://api.acme.com/:path*); headers for CSP/HSTS. Dynamic rules go in proxy.ts.
basePath / assetPrefixbasePath: '/docs' | assetPrefix: 'https://cdn.acme.com'Serve the app under a sub-path; serve _next/static from a CDN.
Import aliasimport Button from '@/components/Button'From tsconfig.json paths: { "@/*": ["./src/*"] }. Set by the installer.
Typed routestypedRoutes: true<Link href="/blgo"> becomes a type error. Stable in 15.5+.
Lint & formatnpx eslint . | npx prettier --write .Next 16 removed next lint; run ESLint directly (eslint-config-next is still the shared config). Biome is a popular single-tool alternative.
Bundle analysisnpm i -D @next/bundle-analyzer → ANALYZE=true npm run buildFind the 400 KB date library you imported into a Client Component.
Turbopacknext dev (default 15+) | next build --turbopack (15) / default (16) | --webpack to opt out (16)Rust bundler replacing webpack. Custom webpack() config in next.config won't apply under Turbopack — use turbopack options or opt out.
Hydration mismatch
"Text content does not match server-rendered HTML." The server and browser rendered different things — usually Date.now(), window, localStorage, or a browser extension editing the DOM. Move browser-only values into useEffect, or render them after mount.
"You're importing a component that needs useState"
A hook or event handler in a file without 'use client'. Add the directive — to the smallest component that needs it, not the page.
"params should be awaited"
Next 15 made params, searchParams, cookies(), headers() async. npx @next/codemod@latest next-async-request-api . fixes a whole codebase.
"hostname is not configured under images"
next/image refuses remote URLs you haven't allow-listed. Add the host to images.remotePatterns, or use unoptimized for a one-off.

Three ways to ship, from zero-config to your own VPS

Vercel (the company behind Next) is push-to-deploy with every feature on. Self-hosting on Node gets you everything except a few Vercel-only niceties, on any $6 VPS — pair it with the rsync/PM2/nginx routine from the Linux cheatsheet. Static export turns the site into plain HTML for any host at all, at the cost of every server feature. Pick by what the site needs, not by fashion.

1

Build and read the route table

The build output tells you what each route is: ○ static, ● SSG with params, ƒ dynamic. If a page you expected to be static shows ƒ, something in it reads request data.

npm run build # Route (app) Size First Load JS # ○ / … … # ● /blog/[slug] … … # ƒ /dashboard … … npm run start -- -p 3000
2

Self-host on a Linux server (standalone build + PM2 + nginx)

output: 'standalone' produces a self-contained folder with only the node_modules it needs — a few MB instead of hundreds. Copy it, run it, reverse-proxy it.

// next.config.ts export default { output: 'standalone' } satisfies NextConfig;
# build locally (or in CI), then ship only what's needed npm run build rsync -avz --delete .next/standalone/ acme:/var/www/acme-app/ rsync -avz --delete .next/static/ acme:/var/www/acme-app/.next/static/ rsync -avz --delete public/ acme:/var/www/acme-app/public/ # on the server: run it under PM2 so it restarts on crash/reboot ssh acme "cd /var/www/acme-app && PORT=3000 pm2 start server.js --name acme && pm2 save" ssh acme "pm2 restart acme" # after each deploy
# /etc/nginx/sites-available/acme.com server { listen 443 ssl http2; server_name acme.com; # certbot lines here location /_next/static/ { alias /var/www/acme-app/.next/static/; expires 1y; access_log off; } location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; # HMR/websockets if you ever proxy dev proxy_set_header Connection "upgrade"; } }
3

Docker (the same standalone build, containerised)

# Dockerfile FROM node:22-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM node:22-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build FROM node:22-alpine AS run WORKDIR /app ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0 COPY --from=build /app/.next/standalone ./ COPY --from=build /app/.next/static ./.next/static COPY --from=build /app/public ./public USER node EXPOSE 3000 CMD ["node", "server.js"]
docker build -t acme-app . && docker run -p 3000:3000 --env-file .env.production acme-app
4

Static export (plain HTML for any host)

Everything is prerendered to out/. You lose Server Actions, Route Handlers that aren't static, ISR, proxy.ts, and default image optimisation — fine for brochure sites and docs.

// next.config.ts export default { output: 'export', images: { unoptimized: true }, trailingSlash: true } satisfies NextConfig;
npm run build && rsync -avz --delete out/ acme:/var/www/acme.com/
5

Vercel

npm i -g vercel vercel # link + preview deploy from this folder vercel --prod # production vercel env pull # sync env vars into .env.local

Or connect the GitHub repo in the dashboard: every push to main deploys production, every PR gets a preview URL. Set env vars per environment in Project → Settings.

Production checklist
  • Secrets only in server code. Grep the built client bundle for anything sensitive: grep -r "sk_live" .next/static should find nothing. import 'server-only' in every DB/secret module.
  • Server Actions are public endpoints. Anyone can call them with any input. Authenticate and validate (zod) inside the action, not just in the form.
  • Set metadataBase or OG images resolve to localhost.
  • Handle the process properly when self-hosting: PM2 or systemd, NODE_ENV=production, a health-check route, and log rotation. next start is a single Node process — put nginx or a CDN in front for TLS, gzip/brotli, and static caching.
  • Cache headers for the self-hosted static folder: /_next/static/* is content-hashed — serve it with Cache-Control: public, max-age=31536000, immutable. Never cache the HTML that long.
  • ISR on self-host uses the local filesystem cache. Multiple instances (containers, PM2 cluster) need a shared cacheHandler (Redis) or they'll disagree about what's fresh.
  • Upgrade with the codemod (npx @next/codemod@latest upgrade), read the version's upgrade guide, run the build. Major versions move things (async APIs in 15, proxy.ts in 16).

Official docs & further reading

The primary sources at nextjs.org. Every claim on this page can be checked against one of these — and the framework moves fast, so when this page and the docs disagree, the docs win.