What’s Changed & Upgrading
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Next.js 16.3 (the latest release on 2026-10-03 is 16.3.8, and the official documentation
reports @doc-version 16.3.8), React 19.3 and Node.js 20.9 or later, verified on 2026-10-03. It covers the App Router
and the Pages Router: the change log applies to both, the code examples are App Router code unless a Pages Router
difference is called out, and the caching entries label the two models, the previous model (the default, with
cacheComponents off) and Cache Components (cacheComponents: true, opt-in). The upgrade commands, the codemod
output, the build errors and the curl results below were produced with Next.js 16.3.8 and Node.js 24.16 on macOS in
scratch projects (an older 15.5 project that was upgraded, a Create React App style single-page app, and a copy of the
Bookshelf catalogue that the rest of this section grows).
Most Next.js tutorials, answers and books were written for a version that no longer exists. Two books were used while preparing this section, and they are about Next.js 12 and 13 and the Pages Router; this page is where the differences between that world and the current one are written down once, so the other pages of the section can stay focused on how things work today. It has four parts: a change log by major version, the tools that automate an upgrade, the two official migration guides for single-page apps, and the collected lists of everything in the books that is outdated or wrong. The concepts themselves are explained in depth on the pages linked from each entry, for instance Caching, Redirects, Rewrites & Proxy and Turbopack & the Compiler.
The Version Baseline
| Item | Value |
|---|---|
Next.js |
16.3 (16.3.0 released on 2026-08-03; the latest patch is 16.3.8, a security release of 2026-09-30). Earlier 16.x releases: 16.0 on 2025-10-21, 16.1 on 2025-12-18 and 16.2 on 2026-03-18. The canary channel is on 16.4. |
Support policy |
16.x is Active LTS and 15.x is Maintenance LTS (backports reach 15.5.27); 14.x and older are unsupported, so security fixes do not reach them. See the support policy. |
React |
React 19.3 is the latest |
Requirements |
Node.js 20.9 or later (Node.js 18 is no longer supported), TypeScript 5.1 or later, Chrome, Edge and Firefox 111+, Safari 16.4+. |
Opt-in today, planned as future defaults |
|
The Change Log: 13, 14, 15 and 16.3
Next.js has a major version about once a year, and each one has changed what "the normal way" looks like. The table is the map; each version then gets a section with the breaking changes and where the topic is covered.
| Version | Date | Headline |
|---|---|---|
13.0 |
2022-10-25 |
The |
13.4 |
2023-05-04 |
The App Router is stable. Turbopack in beta for dev, Server Actions in alpha. |
14.0 |
2023-10-26 |
Server Actions stable, Partial Prerendering as a preview, Turbopack improvements. Minimum Node.js 18.17; |
14.2 |
2024-04-11 |
Turbopack for development as a release candidate, |
15.0 |
2024-10-21 |
React 19, async request APIs, caching off by default for |
15.1 |
2024-12-10 |
React 19 stable, |
15.5 |
2025-08-18 |
Turbopack builds in beta, Node.js Middleware stable, typed routes stable, |
16.0 |
2025-10-21 |
Turbopack default for |
16.1 |
2025-12-18 |
Turbopack file-system cache for |
16.2 |
2026-03-18 |
Hydration diff indicator, Adapters stable, Server Function logging, |
16.3 |
2026-08-03 |
Instant Navigations (opt-in), less dev memory, build file-system cache by default, |
Source: the Next.js release posts for 13, 13.4, 14, 14.2, 15, 15.1, 15.5, 16, 16.1, 16.2 and 16.3 (listed in the references)
Next.js 13 and 13.4: the App Router Arrives
Next.js 13 is the version the Pages Router books stop at. It introduced the app directory in beta with layouts,
React Server Components and streaming, and the App Router was declared stable in 13.4 (May 2023). The release notes
list the first set of breaking changes that tutorials from 2021 and 2022 trip over:
-
the minimum React version became 18.2 and the minimum Node.js version 14.6;
-
next/imagewas renamednext/legacy/imageand the new image component took the namenext/image; -
a
next/linkchild can no longer be an<a>(the temporarylegacyBehaviorprop keeps the old form); -
the
targetoption ofnext.config.jswas removed, Internet Explorer was dropped as a target, andswcMinifybecametrue; -
@next/fontreplaced the CSS font loading recipes (13.2 made it built in asnext/font).
The Pages Router was not removed. Both routers coexist in one application, and the Pages Router remains supported; it is simply no longer where the new features land. The pages of this section that touch it say so where it differs.
Source: Next.js 13 and Next.js 13.4
Next.js 14: Server Actions and next export
Next.js 14 (2023-10-26) is a small upgrade guide. Its summary is four lines:
-
the minimum Node.js version moved from 16.14 to 18.17;
-
the
next exportcommand was removed in favour ofoutput: 'export'innext.config; -
ImageResponsemoved fromnext/servertonext/og; -
the
@next/fontpackage was removed in favour of the built-innext/font.
Server Actions became stable, and Partial Prerendering appeared as a preview. Version 14.2 added the first staleTimes
experiment for the client router cache, which Next.js 15 used to change a default (see below).
Running next export in a current project shows what the removal looks like.
next export in Next.js 16.3.8$ npx next export
⨯
`next export` has been removed in favor of 'output: export' in next.config.js.
Learn more: https://nextjs.org/docs/app/building-your-application/deploying/static-exports
Source: Upgrading from 13 to 14 and Static Exports
Next.js 15: Async Request APIs and Caching Off by Default
Version 15 (2024-10-21) made the two changes that break the most old code. Both are described in the version 15 upgrade guide.
Request-time APIs became asynchronous. cookies, headers, draftMode, the params prop of layout, page,
route, default and the metadata image files, and the searchParams prop of page are Promises. Version 15 kept a
temporary synchronous compatibility mode that logged a warning in development; Next.js 16 removed it. The reason is
rendering: a Promise lets Next.js prerender everything before the first access to request data, which is what Partial
Prerendering and Cache Components build on.
import { cookies } from 'next/headers'
// Before (13 and 14)
const cookieStore = cookies()
const token = cookieStore.get('token')
// After (15, required in 16)
const cookieStore = await cookies()
const token = cookieStore.get('token')
Source: Upgrading from 14 to 15: Async Request APIs and Next.js 15: why the APIs became asynchronous
The page-level form is the one tutorials get wrong most often, because the destructured argument is now a Promise object:
// Before
type Props = { params: { id: string }; searchParams: { lang?: string } }
export default function Page({ params, searchParams }: Props) {
const theme = cookies().get('theme')?.value ?? 'light'
/* ... */
}
// After
type Props = {
params: Promise<{ id: string }>
searchParams: Promise<{ lang?: string }>
}
export default async function Page(props: Props) {
const searchParams = await props.searchParams
const params = await props.params
const theme = (await cookies()).get('theme')?.value ?? 'light'
/* ... */
}
A synchronous Client Component page cannot await; it reads the Promise with React’s use hook instead
(const params = use(props.params)). Next.js 15.5 added npx next typegen, which generates the global PageProps,
LayoutProps and RouteContext helpers so that these types need not be written by hand; they are used throughout this
section as PageProps<'/[locale]/books/[id]'>.
next typegen in the scratch Bookshelf app$ npx next typegen
Generating route types...
✓ Types generated successfully
Caching defaults moved to "uncached". In versions 13 and 14 a fetch was cached by default, a GET Route Handler was
cached unless it used a dynamic function, and the client router cache reused page segments for about 30 seconds.
Version 15 reversed all three: fetch requests and GET handlers are not cached unless asked to be (cache:
'force-cache', export const dynamic = 'force-static'), and the client router cache no longer reuses page segments on
navigation (staleTimes.dynamic is 0), although it still reuses them for back and forward navigation and for shared
layouts. The full account of the layers, in both models, is on the Caching page; the
shape of the change is this:
| Behaviour | Next.js 13 and 14 | Next.js 15 and later |
|---|---|---|
|
Cached ( |
Not cached |
|
Cached when it uses no dynamic function |
Not cached; opt in with |
Client router cache for page segments |
About 30 seconds for dynamic pages |
Not reused ( |
Other breaking changes in 15: the minimum Node.js version became 18.18; React 19 became the minimum for the App Router
(the Pages Router accepts React 18 or 19); useFormState was replaced by useActionState; next/dynamic with
ssr: false is disallowed in Server Components; the runtime value experimental-edge is an error (use edge);
revalidateTag and revalidatePath throw if called during render; geo and ip were removed from NextRequest;
Speed Insights auto-instrumentation was removed; experimental.bundlePagesExternals became
bundlePagesRouterDependencies and experimental.serverComponentsExternalPackages became serverExternalPackages.
Version 15 also made next.config.ts a supported file, shipped <Form>, stabilised instrumentation.js, and improved
hydration error messages. Version 15.1 added after (stable) and forbidden/unauthorized (experimental).
Source: Next.js 15 and Next.js 15.1
Next.js 16.0: Turbopack, Proxy and the End of the Compatibility Period
Version 16 (2025-10-21) is the largest cleanup since 13. Its upgrade guide is long, but the changes group into five themes.
Turbopack by default. next dev and next build use Turbopack; the --turbopack flag in package.json scripts is
no longer needed. A project with a custom webpack function and no turbopack key fails next build on purpose, so
that nobody ships a build that silently ignores their webpack configuration. The three ways out are --webpack to opt
out, --turbopack to build anyway, or an (empty or real) turbopack key. The turbopack configuration also moved out
of experimental.
$ npx next build
▲ Next.js 16.3.8 (Turbopack)
Creating an optimized production build ...
⨯ ERROR: This build is using Turbopack, with a `webpack` config and no `turbopack` config.
This may be a mistake.
As of Next.js 16 Turbopack is enabled by default and
custom webpack configurations may need to be migrated to Turbopack.
[...]
`--turbopack` or `--webpack` flag explicitly or simply setting an
empty turbopack config in your Next config file (e.g. `turbopack: {}`).
The moved key is detected by the configuration validator. This is the project that the upgrade codemod (below) had
just processed, whose next.config.mjs still had the Next.js 15 shape.
experimental.turbopack after the upgrade, built with Next.js 16.3.8▲ Next.js 16.3.8 (Turbopack)
⚠ Invalid next.config.mjs options detected:
⚠ Unrecognized key(s) in object: 'turbopack' at "experimental"
⚠ See more info here: https://nextjs.org/docs/messages/invalid-next-config
turbopack at the top level// next.config.mjs
export default {
turbopack: {}, // was: experimental: { turbopack: {} }
}
Synchronous request APIs removed. The version 15 compatibility mode is gone, so code that still reads params,
searchParams, cookies(), headers() or draftMode() synchronously fails to type-check and fails at runtime. In
Next.js 16 the props of opengraph-image, twitter-image, icon, apple-icon and sitemap (the id) became Promises
as well.
revalidateTag, after the upgrade, built with Next.js 16.3.8 Running TypeScript ...
app/books/[id]/page.tsx(6,27): error TS2339: Property 'get' does not exist on type 'Promise<ReadonlyRequestCookies>'.
lib/cache.ts(15,3): error TS2554: Expected 2 arguments, but got 1.
Failed to type check.
Caching APIs. revalidateTag takes a second argument, a cacheLife profile, and the single-argument form is
deprecated (a TypeScript error, as above); updateTag is new, for Server Actions only, and gives read-your-writes
semantics; refresh refreshes the client router from a Server Action; cacheLife and cacheTag are stable, so the
unstable_ prefix and the aliased imports go away. experimental.ppr, the experimental_ppr segment export,
experimental.dynamicIO and experimental.useCache were removed in favour of the top-level cacheComponents flag.
Enabling cacheComponents is not a rename: it can surface build errors for uncached data outside <Suspense>, and
the migration path is the Migrating to Cache Components
guide. The mechanics are on the Caching and
Partial Prerendering & Cache Components pages.
// Before
import {
unstable_cacheLife as cacheLife,
unstable_cacheTag as cacheTag,
} from 'next/cache'
revalidateTag('posts')
// After (16): stable names, a profile on revalidateTag
import { cacheLife, cacheTag, revalidateTag } from 'next/cache'
revalidateTag('posts', 'max') // stale-while-revalidate
// For read-your-writes inside a Server Action
import { updateTag } from 'next/cache'
updateTag(`user-${userId}`)
A 'use server' file that calls updateTag, refresh and the two-argument revalidateTag compiled and type-checked
with next build in the scratch project.
The fixed one-argument call, exercised in a Route Handler of the scratch Bookshelf app (POST /api/revalidate calling
revalidateTag("books", "max")), after next build and next start -p 3903:
curl -s -X POST http://localhost:3903/api/revalidate{"revalidated":true}
Source: revalidateTag
middleware became proxy. The file is proxy.ts, the exported function is proxy, and the runtime is Node.js and
cannot be changed; the Edge runtime is not supported in the Proxy, so projects that need it keep middleware.ts. The
flags that contained the word "middleware" were renamed (skipMiddlewareUrlNormalize is now skipProxyUrlNormalize).
middleware.ts renamed to proxy.ts by the middleware-to-proxy codemoddiff --git a/middleware.ts b/proxy.ts
similarity index 83%
rename from middleware.ts
rename to proxy.ts
--- a/middleware.ts
+++ b/proxy.ts
@@ -1,6 +1,6 @@
import { NextResponse, type NextRequest } from 'next/server'
-export function middleware(request: NextRequest) {
+export function proxy(request: NextRequest) {
if (request.nextUrl.pathname === '/old-shelf') {
return NextResponse.redirect(new URL('/books', request.url))
}
The renamed file was built and started with next start -p 3903; curl -I http://localhost:3903/old-shelf answered
307 Temporary Redirect with location: /books, and the build’s route table printed ƒ Proxy (Middleware). The
behaviour of the Proxy (matcher, order of evaluation, what it should and should not do) is on the
Redirects, Rewrites & Proxy page.
Removals. These were deprecated earlier and are gone:
-
AMP: the
ampconfiguration,next/ampanduseAmp, andexport const config = { amp: true }; -
next lintand theeslintoption ofnext.config;next buildno longer lints; -
serverRuntimeConfigandpublicRuntimeConfig(use environment variables); -
the
devIndicatorsoptionsappIsrStatus,buildActivityandbuildActivityPosition; -
experimental.dynamicIO,experimental.useCache,experimental.pprandexperimental_ppr; -
unstable_rootParams(replaced bynext/root-params, which shipped in 16.3); -
the size and First Load JS columns of the
next buildoutput, which were inaccurate with Server Components.
Each removal has a visible failure mode. Importing next/amp, setting a removed key, or running the old command gives
these messages in 16.3.8.
$ npx next lint
Invalid project directory provided, no such directory: /path/to/project/lint
./pages/amp.tsx:1:1
Error: Module not found: Can't resolve 'next/amp'
⚠ `eslint` configuration in next.config.mjs is no longer supported.
⚠ Invalid next.config.mjs options detected:
⚠ Unrecognized key(s) in object: 'serverRuntimeConfig', 'eslint'
Error: Fatal next config errors found in next.config.mjs that must be fixed:
Unrecognized key(s) in object: 'dynamicIO' at "experimental"
`experimental.dynamicIO` has been replaced by `cacheComponents`. Please update your next.config file accordingly.
The replacement for serverRuntimeConfig and publicRuntimeConfig is plain environment variables: read secrets on
the server, prefix only genuinely public values with NEXT_PUBLIC_, and call connection() before reading
process.env when a value must be read at request time rather than inlined at build time (see
Configuration, TypeScript & Environment Variables).
Behaviour changes and defaults. These do not break compilation but change output:
-
Parallel routes need
default.js. Every parallel route slot needs adefault.js(returnnullor callnotFound()). In the scratch test a slot without one built successfully, and the request that did not match the slot answered 404; addingdefault.tsxreturningnullmade the same request answer 200. The upgrade guide says builds fail without it, so treat the file as required (see Parallel & Intercepting Routes). -
next/imagedefaults.minimumCacheTTLwent from 60 seconds to 4 hours;16left the defaultimageSizes;qualitiesdefaults to[75]; local IPs are blocked unlessdangerouslyAllowLocalIPis set;maximumRedirectsdefaults to 3; local images with a query string needlocalPatternswith asearchentry.next/legacy/imageandimages.domainsare deprecated; usenext/imageandremotePatterns. See Images, Fonts & Scripts. -
Dev and build directories.
next devwrites to.next/dev, sodevandbuildcan run at the same time, and a lockfile prevents twonext devor twonext buildprocesses on one project. -
ESLint flat config.
@next/eslint-plugin-nextdefaults to the flat config format. -
Scroll behaviour. Next.js no longer overrides a global
scroll-behavior: smoothduring navigation; adddata-scroll-behavior="smooth"to<html>to get the old override. -
Navigation. The prefetch and navigation layer was rewritten (layout deduplication, incremental prefetching): more, smaller prefetch requests with no code changes.
-
next devconfig load. The config file is no longer loaded by thenext devlauncher, so a check such asprocess.argv.includes('dev')innext.config.jsreturnsfalse; checkNODE_ENVor the configphaseinstead. -
Sass.
sass-loader16 and the modern Sass API; Turbopack does not support the legacy~prefix in Sass imports.
// next.config.mjs
export default {
turbopack: {},
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'covers.example.com', pathname: '/books/**' },
],
localPatterns: [{ pathname: '/assets/**', search: '?v=1' }],
qualities: [50, 75, 100],
minimumCacheTTL: 60, // the pre-16 default; the new default is 14400 (4 hours)
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], // 16 restored
},
}
default.tsx// app/@modal/default.tsx
export default function Default() {
return null
}
Also in 16.0. React 19.2 features arrive through the bundled canary (Activity, useEffectEvent,
ViewTransition); the React Compiler option reactCompiler is stable but off by default; the Next.js DevTools MCP
server lets coding agents inspect a running app; unstable_ prefixes were dropped from stabilised APIs; and the first
alpha of the Build Adapters API appeared (stable in 16.2). Link with legacyBehavior was announced for removal in 16
by the 15.5 deprecation notices, but a page using <Link passHref legacyBehavior><a>…</a></Link> still built without an
error on Next.js 16.3.8 in the scratch project. It is deprecated, not working-by-design: remove it with the new-link
codemod. The tools for all of this are in the next sections; the details of Turbopack and the compiler are on
Turbopack & the Compiler.
Source: Next.js 16 and Next.js 15.5
Next.js 16.1, 16.2 and 16.3: The Minors
The minor releases of 16 carry no breaking change for a project already on 16.0, but several of them change behaviour by default, and several are experiments whose names changed between minors. Anyone reading a post or a prompt from one month may find the name different in the next.
| Version | Added | Status to check |
|---|---|---|
16.1 (2025-12-18) |
Turbopack file-system caching for |
|
16.2 (2026-03-18) |
Faster dev start-up and rendering, a new default error page, Server Function logging in the dev terminal, the
hydration diff indicator (a |
|
16.3 (2026-08-03) |
Up to 90 percent less memory for |
Opt-in: |
Source: Next.js 16.1, Next.js 16.2 and Next.js 16.3
A few of these renamed or moved between minors, and the names matter when following older material:
-
catchError. In 16.2 it shipped asunstable_catchError()with anunstable_retry()callback (and anunstable_retryprop forerror.tsx). In 16.3 the stable names arecatchErrorandretry, imported fromnext/error. Theremove-unstable-prefixcodemod (checked in its source) renames onlyunstable_cacheLifeandunstable_cacheTag, so rename this one by hand. -
next/root-params. Version 16.0 removedunstable_rootParamsand promised an alternative in a minor release; that alternative isnext/root-params, which arrived in 16.3. It works in Server Components (and insideuse cachescopes) but not yet in Route Handlers or Server Actions. -
Adapters. The alpha of 16.0 read
experimental.adapterPath; 16.2 madeadapterPatha stable top-level option. -
Bundle analysis.
next experimental-analyze(16.1) replaces@next/bundle-analyzerfor Turbopack projects, and it is experimental by name and by status. -
File-system caching. Dev caching was stable in 16.1; the build cache and memory eviction became defaults in 16.3 (see Turbopack & the Compiler).
-
Agents. The DevTools MCP is from 16.0 and
get_routesfrom 16.1; bundled docs are from 16.2 and theAGENTS.mdblock thatnext devmaintains is from 16.3.
catchError in Next.js 16.3 (the 16.2 spelling was unstable_catchError and unstable_retry)// app/my-error-boundary.tsx
'use client'
import { catchError, type ErrorInfo } from 'next/error'
function ErrorFallback(props: { title: string }, { error, retry }: ErrorInfo) {
return (
<div>
<h2>{props.title}</h2>
<p>{error instanceof Error ? error.message : String(error)}</p>
<button onClick={() => retry()}>Try again</button>
</div>
)
}
export default catchError(ErrorFallback)
The snippet in the 16.3 post and in the catchError reference reads error.message directly; with the types shipped in
16.3.8 the error field of ErrorInfo is unknown, and that line failed next build with Property 'message' does not
exist on type 'unknown' until it was narrowed as above. The page below the boundary was built and served
(/es/posts/hello answered with es: hello from the root-params example and fine from the wrapped child).
Source: Next.js 16.3: Custom error boundaries and catchError
next/root-params in Next.js 16.3, replacing the removed unstable_rootParams// app/[lang]/posts/[slug]/page.tsx
import { lang } from 'next/root-params'
export default async function PostPage(props: PageProps<'/[lang]/posts/[slug]'>) {
const { slug } = await props.params
const language = await lang()
return (
<p>
{language}: {slug}
</p>
)
}
Source: next/root-params
The two opt-in flags of 16.3 are both off by default and can be turned on independently of an upgrade.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true, // Cache Components (use cache, Partial Prerendering)
partialPrefetching: true, // Instant Navigations: reusable loading shells
}
export default nextConfig
A project with both flags on, a [lang] root layout and generateStaticParams built, and its route table showed
◐ (Partial Prerender) entries next to the ○ (Static) ones.
Source: Next.js 16.3: Instant Navigations and partialPrefetching
Tools for an Upgrade
next upgrade and the upgrade codemod
Since 16.1, next upgrade is the command. Versions before 16.1 do not have it and use the separate codemod package:
# Next.js 16.1 and later
npx next upgrade
# Any earlier version (and the form the 15 and 16 upgrade guides show)
npx @next/codemod@canary upgrade latest
Source: Upgrading: latest version and next upgrade options
next upgrade [directory] [--revision <revision>] [--verbose] takes a dist tag (latest, canary, rc, beta) or an
exact version. The underlying upgrade codemod takes patch, minor (the default for stable versions) or major, a
dist tag, or an exact version, and the flags --verbose and -y, --yes. It updates next, react and react-dom,
asks which Next.js codemods to apply, and runs the React 19 codemods when the React major changes.
next upgrade --help in Next.js 16.3.8Usage: next upgrade [directory] [options]
Upgrade Next.js apps to desired versions with a single command.
Options:
--revision <revision> Specify the target Next.js version using an NPM dist
tag (e.g. "latest", "canary", "rc", "beta") or an
exact version number (e.g. "15.0.0"). (default:
"latest")
--verbose Verbose output (default: false)
-h, --help Displays this message.
Run in a project that is already on the target version, it changes nothing.
npx next upgrade on a project already at 16.3.8 Running in non-interactive mode. Every prompt will accept its default.
✓ Current Next.js version is already on the target version "v16.3.8".
To see an actual upgrade, a small project on Next.js 15.5.27 and React 19.0.0 was created with the old idioms
(middleware.ts, synchronous params and cookies(), aliased unstable_cacheLife, a one-argument revalidateTag,
experimental.turbopack, a next lint script) and upgraded with the documented command. The terminal was not
interactive, so every prompt took its default, which is what --yes forces:
npx @next/codemod@canary upgrade latest --yes on the Next.js 15.5.27 project (trimmed) Running in non-interactive mode. Every prompt will accept its default.
Detected installed versions:
- React: v19.0.0
- Next.js: v15.5.27
Applying all codemods recommended for your upgrade:
- middleware-to-proxy
- remove-unstable-prefix
- remove-experimental-ppr
- cache-components-instant-false
- remove-partial-prefetch
Upgrading your project to Next.js 16.3.8...
[... npm install ...]
✔ Codemods have been applied successfully.
Four results of that run are worth knowing before running it on a real project.
-
package.jsonwas rewritten:nextbecame 16.3.8,reactandreact-dom19.3.0,eslint-config-next16.3.8, and@types/reactwas pinned with anoverridesentry. Commit the lockfile diff separately. -
middleware.tsbecameproxy.tswith the function renamed, andunstable_cacheLife as cacheLifebecame plaincacheLife. -
The
cache-components-instant-falsecodemod, which the non-interactive run applied, insertsexport const instant = falseinto everypage,layoutanddefaultfile so that Cache Components can be enabled route by route. WithoutcacheComponents: truethat export is itself an error, as the next block shows. Accept that codemod only when adopting Cache Components, or delete the inserted lines. -
The run did not migrate
experimental.turbopackinnext.config.mjs, did not replace thenext lintscript, did not add the second argument ofrevalidateTag, and did not makeparamsorcookies()asynchronous. Those are separate codemods or manual edits.
cacheComponents, with Next.js 16.3.8./app/books/[id]/page.tsx:6:14
Error: Route segment config "instant" requires `nextConfig.cacheComponents` to be enabled.
> 6 | export const instant = false;
Source: Codemods: upgrade and Codemods: 16.3
Individual codemods
npx @next/codemod <transform> <path> runs one transform; --dry makes a dry run and --print prints the changed
output. Use --dry on a first attempt: in the scratch run --print on its own printed the new code and still wrote it to
the file. The tool refuses to run in a dirty git working tree unless --force is passed (it stops with "please stash
or commit your git changes"). A wrong path reports 0 ok instead of
failing, so check the file count in the Results summary. The names below are the ones the codemods page lists; the
ones marked with an asterisk were executed for this page and the others only confirmed in the list.
| Codemod | Since | What it does |
|---|---|---|
|
15.0 |
Makes |
|
15.0 |
Changes |
|
15.0 |
Replaces |
|
14.0 |
Rewrites |
|
14.0 |
Moves viewport settings from the |
|
13.2 |
Rewrites |
|
13.0 |
Rewrites |
|
13.0 |
Migrates to the new image component ( |
|
13.0 |
Removes the |
|
16.0 |
Removes the |
|
16.0 |
Drops the |
|
16.0 |
Renames |
|
16.0 |
Replaces |
|
16.3 |
Adds |
|
16.3 |
Removes |
|
Guide |
Sets up |
|
11 |
Bootstraps a migration of a Create React App project (see the next section for the current guide). |
Source: Codemods and the @next/codemod package
The three transforms of the 15 and 16 upgrades that a typical Pages Router era project needs are shown running below.
The project had a synchronous page, a middleware.ts and an aliased unstable_cacheLife; the three commands were run
one after another, committing between them.
npx @next/codemod@latest next-async-request-api . (summary line)Executing command: jscodeshift --parser=tsx [...] --transform [...]/transforms/next-async-request-api.js .
Processing 3 files...
Results:
0 errors
2 unmodified
0 skipped
1 ok
next-async-request-api, middleware-to-proxy, remove-unstable-prefix)--- a/app/books/[id]/page.tsx
+++ b/app/books/[id]/page.tsx
@@ -1,9 +1,11 @@
import { cookies } from 'next/headers'
-type Props = { params: { id: string }; searchParams: { lang?: string } }
+type Props = { params: Promise<{ id: string }>; searchParams: Promise<{ lang?: string }> }
-export default function Page({ params, searchParams }: Props) {
- const theme = cookies().get('theme')?.value ?? 'light'
+export default async function Page(props: Props) {
+ const searchParams = await props.searchParams;
+ const params = await props.params;
+ const theme = (await cookies()).get('theme')?.value ?? 'light'
--- a/lib/cache.ts
+++ b/lib/cache.ts
@@ -1,6 +1,6 @@
import {
- unstable_cacheLife as cacheLife,
- unstable_cacheTag as cacheTag,
+ cacheLife,
+ cacheTag,
revalidateTag,
} from 'next/cache'
The codemod left revalidateTag('books') alone; that one-argument call is the TypeScript error shown earlier, fixed
by hand with revalidateTag('books', 'max'). After the manual fixes the upgraded project built and started:
curl against next start -p 3903Route (app)
┌ ○ /_not-found
└ ƒ /books/[id]
ƒ Proxy (Middleware)
$ curl -s -H 'Cookie: theme=dark' 'http://localhost:3903/books/1?lang=es'
<p>Book <!-- -->1<!-- --> in <!-- -->es<!-- --> (<!-- -->dark<!-- -->)</p> (excerpt of the HTML)
$ curl -sI http://localhost:3903/old-shelf
HTTP/1.1 307 Temporary Redirect
location: /books
The older transforms behave the same way. On three tiny files (a @next/font/google import, an ImageResponse from
next/server, and <Link><a className="nav">Books</a></Link>), built-in-next-font, next-og-import and new-link
produced:
built-in-next-font, next-og-import and new-link-import { Inter } from '@next/font/google'
+import { Inter } from "next/font/google"
-import { ImageResponse } from 'next/server'
+import { ImageResponse } from "next/og";
- <Link href="/books">
- <a className="nav">Books</a>
+ <Link href="/books" className="nav">
+ Books
</Link>
The ESLint codemod does what it says: on the next lint project it created eslint.config.mjs (a flat config that
spreads eslint-config-next/core-web-vitals and eslint-config-next/typescript) and changed the script to
"lint": "eslint .".
npx @next/codemod@latest next-lint-to-eslint-cli .Migrating from next lint to the ESLint CLI...
Created eslint.config.mjs
Updated script "lint": "next lint" → "eslint ."
Updated package.json scripts and dependencies
Migration complete! Your project now uses the ESLint CLI.
A safe order for an upgrade
The commands above combine into a routine that works for one major at a time. Skipping versions is possible (the 16
guide is written for 15), but each guide assumes the previous one was applied, and the two compatibility periods of
Next.js (synchronous request APIs in 15, middleware in 16) are easiest to leave one at a time.
-
Commit everything and create a branch; the codemods refuse to run on a dirty tree.
-
Upgrade to the latest patch of the current major first (
npx @next/codemod upgrade patch), so that the deprecation warnings of the current version are visible. -
Read the upgrade guide of the next major from top to bottom once; most steps are one paragraph.
-
Run
npx next upgrade(16.1+) ornpx @next/codemod@canary upgrade latest, answer the prompts, and review the diff ofpackage.jsonand the lockfile on its own. -
Run
npx @next/codemod@latest next-async-request-api .when the project still reads request APIs synchronously, thennpx next typegenand replace hand-writtenparamstypes byPageProps,LayoutPropsandRouteContext. -
Fix
next.configby hand:turbopackat the top level, removed keys,imagesdefaults, and thecacheComponentsdecision. KeepcacheComponentsoff until the rest builds. -
Run
next build. Type errors and the validator messages shown in this page are the checklist; fix, rebuild, repeat. -
Run the test suite and a production-mode smoke test (
next start, thencurlor the end-to-end suite), because caching defaults changed silently between 14 and 15: code that relied on a cachedfetchis now slower, not broken. -
Only then adopt the new features (Cache Components, Partial Prefetching, the React Compiler) one at a time.
The same upgrade can be handed to a coding agent: the version 16 guide ships a prompt that asks the agent to set up
version-matched docs first (npx @next/codemod@canary agents-md, or the bundled docs and AGENTS.md block that
Next.js 16.2 and 16.3 maintain), run the codemod, explain the plan, and verify the result with the dev tools.
Migrating from Create React App and Vite
Both official guides use the same incremental strategy: first make the existing single-page app run inside Next.js as a client-only application, unchanged, then adopt Next.js features route by route. The motivation the guides give is the usual list (a client-only app downloads all its code before it can fetch data, which causes waterfalls; Next.js adds automatic code splitting, streaming, server-side data fetching, the Proxy and the built-in optimisations). Nothing about the first step needs React Server Components. The two guides are at Create React App and Vite; Create React App is no longer maintained, so that guide is the one most older projects need.
From Create React App
The steps of the guide, applied to a small Create React App style project (a src/App.tsx with state, an image import
and a REACT_APP_ variable), were:
-
Install Next.js (
npm install next@latest). -
Create
next.config.tswithoutput: 'export'anddistDir: 'build'. -
Create
app/layout.tsx: the<html>,<head>and<body>ofpublic/index.htmlbecome the root layout, and thediv#rootbecomes<div id="root">{children}</div>. -
Move the title and description into the Metadata API, and drop the
charsetandviewporttags (Next.js adds them). -
Import global CSS from the layout.
-
Create the entry page as an optional catch-all route,
app/[[…slug]]/page.tsx, withgenerateStaticParamsreturning[{ slug: [''] }]. -
Add a client-only entrypoint,
app/[[…slug]]/client.tsx, that loads the oldAppwithnext/dynamicandssr: false. -
Change image imports (they return an object; use
logo.srcor theImagecomponent). -
Rename
REACT_APP_variables toNEXT_PUBLIC_. -
Update the
package.jsonscripts and.gitignore, then clean uppublic/index.html,src/index.tsx,react-scriptsand thereportWebVitalssetup.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'export', // Outputs a Single-Page Application (SPA)
distDir: 'build', // Keeps CRA's build directory name
}
export default nextConfig
Source: Create React App guide: step 2
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Bookshelf SPA',
description: 'A Create React App site running on Next.js',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<div id="root">{children}</div>
</body>
</html>
)
}
// app/[[...slug]]/page.tsx
import { ClientOnly } from './client'
export function generateStaticParams() {
return [{ slug: [''] }]
}
export default function Page() {
return <ClientOnly />
}
// app/[[...slug]]/client.tsx
'use client'
import dynamic from 'next/dynamic'
const App = dynamic(() => import('../../src/App'), { ssr: false })
export function ClientOnly() {
return <App />
}
logo.src and NEXT_PUBLIC_)// src/App.tsx
import { useEffect, useState } from 'react'
import logo from './logo.svg'
export default function App() {
const [clicks, setClicks] = useState(0)
const [host, setHost] = useState('(server)')
useEffect(() => setHost(window.location.host), [])
return (
<main>
<img src={logo.src} alt="Bookshelf logo" width={48} height={48} />
<h1>Bookshelf SPA</h1>
<p>API: {process.env.NEXT_PUBLIC_API_URL}</p>
<p>Host: {host}</p>
<button onClick={() => setClicks(clicks + 1)}>Clicked {clicks} times</button>
</main>
)
}
The project was built with npm run build and the build directory served with a static file server on port 3903
(the start script of the guide, npx serve@latest ./build, serves the same directory).
Route (app)
┌ ○ /_not-found
└ /[[...slug]]
└ ● /
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
title: ['Bookshelf SPA']
meta description: ['A Create React App site running on Next.js']
has root div: True
has h1 text in the HTML: False
The page title and description are in the HTML (so crawlers and link previews see them), the div#root is there, and
the application itself is absent from the HTML because ssr: false makes it client-only, exactly like the original
Create React App site. Opened in a browser, the page showed the heading, API: http://localhost:4000/api (the
NEXT_PUBLIC_API_URL of .env), the host localhost:3903 filled in by the effect, and the button. From here each
route can move into its own page.tsx and out of the client bundle.
The guide’s "Additional Considerations" cover what Create React App configured implicitly: a custom homepage becomes
basePath, a service worker is registered with navigator.serviceWorker.register(new URL(…, import.meta.url)),
the proxy field of package.json becomes a rewrites entry in next.config.ts, a custom webpack or Babel
configuration needs --webpack (or a Turbopack equivalent), and next-env.d.ts must be in tsconfig.json’s `include.
Next.js uses Turbopack by default, whereas Create React App used webpack; next dev --webpack is the closest match.
From Vite
The Vite guide has the same shape (steps 1 to 9) with these differences from the Create React App one:
-
next.config.mjsusesdistDir: './dist'; -
tsconfig.jsonneeds nine specific changes (remove the project reference totsconfig.node.json, add./dist/types/*/.tsand./next-env.d.tstoinclude, thenextplugin,esModuleInterop,jsx: react-jsx,allowJs,forceConsistentCasingInFileNames,incremental); -
the layout replaces
index.html, the entry page replacesmain.tsx; -
environment variables change prefix from
VITE_toNEXT_PUBLIC_; -
Turbopack supports
import.meta.env.MODE,DEV,PROD,BASE_URLandSSRwithout changes (BASE_URLfollowsbasePathand has a trailing slash), but not customVITE_*variables, Vite modes,envPrefixorenvDir; -
Turbopack supports
import.meta.glob(16.3) with no changes, except that the Vite 5asoption (deprecated) is replaced byquery, andimport.meta.globEageris not supported (use{ eager: true }).
The two Vite APIs were exercised in the migrated SPA of the previous section by replacing App.tsx with a component that
globs three TypeScript modules and prints import.meta.env.MODE.
// src/App.tsx
const notes = import.meta.glob('./notes/*.ts', { eager: true })
export default function App() {
return (
<ul>
{Object.entries(notes).map(([path, mod]) => (
<li key={path}>
{path}: {(mod as { default: string }).default}
</li>
))}
<li>mode: {import.meta.env.MODE}</li>
</ul>
)
}
Built with next build (Turbopack) and opened from the same static server, the page listed ./notes/a.ts: first note,
./notes/b.ts: second note and mode: production. A .txt glob failed with "Unknown module type" because Turbopack
needs a registered loader for file types it does not know (the 16.3 post makes the same point for .md); and the
eager result is typed Record<string, unknown>, so the cast above is needed.
Source: Vite guide and Turbopack: import.meta.glob
After either migration the guides' "next steps" are the same: move from React Router to the App Router (see
Routing Fundamentals and
Client-Side Rendering for the single-page-app mode this leaves you in),
then adopt next/image, next/font, next/script and the ESLint config
(Images, Fonts & Scripts). The migration that starts from an existing
Pages Router application, as opposed to a single-page app, is the incremental
Pages to App Router guide, and has its own page in
this section.
Removed and Deprecated: A Quick Reference
| Feature in old material | Version | Status and replacement |
|---|---|---|
|
14 |
Removed; |
|
13.2, removed in 14 |
|
|
13 |
Became |
|
13 |
Not needed; |
|
16 |
Deprecated; use |
|
14 |
|
Synchronous |
15 deprecated, 16 removed |
Promises; |
|
15 |
Error; use |
|
15 |
Error; move it into a Client Component. |
|
16 |
Deprecated; |
|
16 |
Deprecated; |
|
16 |
|
|
16 |
Removed; |
|
16 |
Top-level |
|
16 |
Removed; the ESLint CLI with flat config. |
AMP ( |
16 |
Removed. |
|
16 |
Removed; environment variables. |
|
16 |
Removed; |
|
16.3 |
|
|
16.1 |
|
|
15 |
Removed; use the hosting provider’s values (for example |
|
not documented |
Listed as removed in older summaries, but neither the 15 nor the 16 guide documents a removal, so no removal version
is claimed here. Branch on |
Source: version 14, version 15 and version 16 upgrade guides
The two next/dynamic rows are from the same mechanism: loading a component only in the browser is a Client Component
decision. This is the 15 error, seen with a page that does it in a Server Component.
ssr: false in a Server Component, built with Next.js 16.3.8./app/dyn/page.tsx:3:15
Error: `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component.
> 3 | const Chart = dynamic(() => import('./chart'), { ssr: false })
// app/dyn/chart-client.tsx
'use client'
import dynamic from 'next/dynamic'
export const Chart = dynamic(() => import('./chart'), { ssr: false })
Source: Lazy Loading: skipping SSR
Where the Books Are Outdated
Two books were consulted while preparing this section: Real-World Next.js (Michele Riva, Packt, 2022, Next.js 12.0,
React 17, Pages Router only) and Practical Next.js for E-Commerce (Alex Libby, Apress, 2023, Next.js 13.2.4, React
18.2, Pages Router under src/pages; the App Router is explicitly skipped). Both are listed in the section
bibliography, and no text, listing or figure of either is reproduced here. The table collects, in one place, every
statement of theirs that no longer holds, with the version that changed it and the page of this section that teaches
the current approach. The same rows apply to most blog posts of the same years.
| Area | Outdated in the books, and current | Covered in |
|---|---|---|
Router and architecture |
Pages Router only. The current default is the App Router (stable in 13.4) with Server Components, layouts,
|
|
Data functions |
|
Data Fetching, Static Site Generation, Server-Side Rendering |
Request APIs |
Synchronous |
This page, Dynamic Routes & Route Groups |
Caching |
The 13 and 14 defaults ( |
|
Middleware |
|
|
Tooling |
A custom |
|
Components |
|
Linking & Navigation, Images, Fonts & Scripts, Metadata & SEO |
Removed features |
AMP and |
This page, Configuration, TypeScript & Environment Variables |
Runtime and metrics |
React 17 and 18 are superseded by React 19 (the Pages Router still accepts 18); Node 14 and 16 (end of life) by Node 20.9 or later; First Input Delay by Interaction to Next Paint (INP), which became a Core Web Vital in 2024. |
|
Internationalisation |
The |
|
Auth |
NextAuth v4 ( |
|
Ecosystem |
Redux |
|
Testing |
Manual Jest plus Babel is replaced by |
Source: the upgrade guides, INP becomes a Core Web Vital and Heroku: the next chapter; the ecosystem rows are verified on the pages linked in the references
The ecosystem row is the one that changes most quickly, so it carries the date: on 2026-10-03 next-auth was at
4.24.15 with 5.0.0-beta.32 on the beta tag (there is no stable v5 tag), @auth0/nextjs-auth0 at 4.31.0, Vitest at
5.0.3, Playwright at 1.63.0, Tailwind CSS at 4.3.3 and zod at 4.6.5. The books' version assumptions (Vitest 3 or 4 era,
NextAuth 4) are older than those.
Errata and Bad Practices Not to Reproduce
The books also contain faults that are not a matter of age. They are collected here with the correct form of each, so
that nobody copies them; the pages that teach the topic (data security, authentication, Route Handlers) repeat the
relevant one in context. The correct forms below follow the official documentation, and the ones that
could be shown as a short program were built and run in the scratch Bookshelf app with next build and next start
-p 3903.
| # | Fault | Correct form |
|---|---|---|
1 |
Secret exposed to the browser (Libby ch. 3 to 4, 7). A Sanity write token stored as |
Everything prefixed |
2 |
Secret exposed to the browser. A Stripe secret key named |
Only the publishable key may use the prefix. The secret key stays in a server-only variable and a |
3 |
Price tampering (Libby ch. 4, 7). Checkout |
The client sends product ids and quantities only. The server looks the price up by id and validates the quantity. The Route Handler below was run. |
4 |
Payment fulfilment (both books). The success redirect is treated as proof of payment. |
The redirect proves nothing. Fulfil an order from the payment provider’s signed webhook, verified on the raw body, answered quickly and idempotent. See Route Handlers & Backend for Frontend. |
5 |
Injection. The slug is interpolated into a GROQ query (Libby ch. 3). |
Pass the slug as a query parameter of the CMS client (a GROQ query uses |
6 |
Injection and open proxy (Riva ch. 4). An unvalidated path segment is interpolated into an upstream URL, and the API route forwards whatever it is given. |
Validate the segment against a strict pattern, build the upstream URL from a fixed host, and look the resource up by id. Run below. |
7 |
Auth (Riva ch. 12, Libby ch. 12). A hard-coded JWT secret and hard-coded credentials. |
Secrets come from environment variables (placeholders in the repository, real values in the hosting provider); credentials come from a user store with hashed passwords, or from an identity provider. |
8 |
Auth. No token expiry, and a cookie without |
Set |
9 |
Auth. An uncaught |
Treat a failed verification as "not signed in": catch the error and return a 401 or a redirect to sign-in. |
10 |
Auth. Pages protected only by client-side hooks ( |
A client hook hides UI; it protects nothing. Authorise on the server, in the Server Component, Route Handler or Server Action that reads the data, and use the Proxy only as an optimistic first check. See Authentication & Authorization. |
11 |
Auth. |
Auth.js v5 needs |
12 |
Other. |
An effect callback must be synchronous (it may return only a cleanup function). Put the async work in an inner function and abort the request in the cleanup. Built below. |
13 |
Other. A root-run, single-stage Dockerfile without a |
A multi-stage build, a non-root user, and a |
14 |
Other. |
The header is controlled by the caller. Build the redirect from a fixed base URL in configuration, a relative path, or an allow-list. Run below. |
15 |
Other. The claims that |
Neither is true. |
The correct forms that are short enough to show were written as Route Handlers, built, and called with curl.
app/api/quote/route.ts)import { z } from "zod";
import { getBook } from "@/lib/books";
// The client sends ids and quantities only; prices never come from the request.
const Cart = z.object({
items: z.array(z.object({ id: z.string(), quantity: z.number().int().min(1).max(10) })).min(1),
});
export async function POST(request: Request) {
const parsed = Cart.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return Response.json({ error: "invalid cart" }, { status: 400 });
}
const lines = [];
for (const { id, quantity } of parsed.data.items) {
const book = await getBook(id);
if (!book) return Response.json({ error: `unknown book ${id}` }, { status: 400 });
lines.push({ id, quantity, unitCents: book.priceCents });
}
const totalCents = lines.reduce((sum, l) => sum + l.unitCents * l.quantity, 0);
return Response.json({ lines, totalCents });
}
priceCents of 1 is ignored, and a negative quantity is rejectedcurl -s -X POST -H 'Content-Type: application/json' \
-d '{"items":[{"id":"1","quantity":2,"priceCents":1}]}' http://localhost:3903/api/quote
curl -s -w ' [%{http_code}]\n' -X POST -H 'Content-Type: application/json' \
-d '{"items":[{"id":"1","quantity":-5}]}' http://localhost:3903/api/quote
{"lines":[{"id":"1","quantity":2,"unitCents":999}],"totalCents":1998}
{"error":"invalid cart"} [400]
Source: Data security and route.js
app/api/upstream/[id]/route.ts)import { getBook } from "@/lib/books";
// Validate the segment against a strict pattern and look it up by id,
// instead of interpolating it into an upstream URL.
export async function GET(_req: Request, ctx: RouteContext<"/api/upstream/[id]">) {
const { id } = await ctx.params;
if (!/^\d{1,6}$/.test(id)) {
return Response.json({ error: "bad id" }, { status: 400 });
}
const book = await getBook(id);
return book ? Response.json(book) : Response.json({ error: "not found" }, { status: 404 });
}
$ curl -s -w ' [%{http_code}]\n' http://localhost:3903/api/upstream/2
{"id":"2","title":"Neuromancer","author":"William Gibson","priceCents":899} [200]
$ curl -s -w ' [%{http_code}]\n' 'http://localhost:3903/api/upstream/..%2Fadmin'
{"error":"bad id"} [400]
Source: route.js: RouteContext helper
app/api/demo-cookie/route.ts)import { cookies } from "next/headers";
export async function POST() {
const jar = await cookies();
jar.set("session", crypto.randomUUID(), {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60, // expires after one hour
});
return Response.json({ ok: true });
}
$ curl -si -X POST http://localhost:3903/api/demo-cookie | grep -i set-cookie
set-cookie: session=<random uuid>; Path=/; Expires=Sat, 03 Oct 2026 18:05:39 GMT; Max-Age=3600; Secure; HttpOnly; SameSite=lax
Source: cookies: options
app/api/login-redirect/route.ts)import { NextResponse } from "next/server";
// The redirect target is built from configuration, never from a request header.
const SITE_URL = process.env.SITE_URL ?? "http://localhost:3903";
export async function POST() {
return NextResponse.redirect(new URL("/en/dashboard", SITE_URL), 303);
}
$ curl -si -X POST -H 'Origin: https://evil.example' http://localhost:3903/api/login-redirect | grep -i -E '^(HTTP|location)'
HTTP/1.1 303 See Other
location: http://localhost:3903/en/dashboard
Source: NextResponse.redirect
app/[locale]/abort-effect/book-count.tsx)"use client";
import { useEffect, useState } from "react";
export function BookCount() {
const [count, setCount] = useState<number | null>(null);
useEffect(() => {
const controller = new AbortController();
// The effect callback itself stays synchronous; the async work lives inside it.
async function load() {
try {
const res = await fetch("/api/books", { signal: controller.signal });
setCount((await res.json()).length);
} catch (error) {
if ((error as Error).name !== "AbortError") throw error;
}
}
load();
return () => controller.abort();
}, []);
return <p>{count === null ? "Loading…" : `${count} books`}</p>;
}
The component was compiled and type-checked by next build, and the server-rendered HTML contained Loading…; the
effect itself runs only in the browser. The React side of the rule is on the
Effects page.
generateMetadata in a plain JavaScript file (app/[locale]/meta-js/page.jsx)export async function generateMetadata({ params }) {
const { locale } = await params
return { title: `Plain JavaScript metadata (${locale})` }
}
export default function Page() {
return <p>generateMetadata works without TypeScript.</p>
}
$ curl -s http://localhost:3903/en/meta-js | grep -o '<title>[^<]*</title>'
<title>Plain JavaScript metadata (en)</title>
Source: generateMetadata
The remaining errata (1, 2, 4, 5, 7, 9, 10, 11 and 13) are stated as rules with the page that shows the full example in context, because the correct form is a convention (where a variable lives, how a webhook is verified) rather than a program that could be shown here with a result. Items 1, 2, 7 and 11 are the same lesson: a secret belongs to the server, and the repository holds only placeholders.
New Since the Books
The books do not have these. Each is detailed on the page of the section that covers it.
-
App Router: layouts and templates, route groups, parallel and intercepting routes,
loading,error,not-found,forbiddenandunauthorized, Route Handlers, the Metadata API,next/form, typed routes and thePagePropsandLayoutPropshelpers, andnext/root-params(16.3). See Layouts, Templates & Special Files. -
React Server Components and the RSC payload,
'use client'and'use server', Server Actions withuseActionStateanduseOptimistic,after,connection, andcatchError. See Server & Client Components and Server Actions & Mutations. -
Rendering and caching: streaming with Suspense, Partial Prerendering and Cache Components (
'use cache','use cache: private','use cache: remote',cacheLife,cacheTag,updateTag,refresh), and ISR with an app shell for unknown params (16.3). See Streaming & Suspense and Incremental Static Regeneration. -
Navigation: the 16.0 prefetch rewrite (layout deduplication, incremental prefetching), Instant Navigations and Partial Prefetching (16.3),
<Activity>-preserved routes, View Transitions anduseLinkStatus. -
Tooling: Turbopack (default), the React Compiler, the hydration diff indicator (16.2), the Next.js DevTools MCP,
AGENTS.md,next upgrade,next typegen, the stable Adapters API (16.2),instrumentation-clientand@next/playwright. See Hydration and Debugging, Instrumentation & Observability.
Topics the books do not cover at all, which the official documentation supplies and the section teaches: data security
(the Data Access Layer, server-only, the taint APIs, Server Action closure encryption and allowed origins), Content
Security Policy with nonces, draft mode, MDX, multi-zones, multi-tenant apps, PWAs and offline support,
OpenTelemetry and instrumentation, self-hosting (cache handlers, version skew, deploymentId), CDN caching, memory
usage, the Edge runtime, the custom-server caveats, single-page-app mode and the pattern for preventing a flash before
hydration. The first of these is on Data Security & CSP.
Upgrade Pitfalls
| Symptom | Usual cause |
|---|---|
|
A synchronous |
|
The 16 signature; add a profile such as |
|
A |
|
The key moved to the top level in 16. |
|
The |
|
AMP was removed in 16. |
|
Move the |
A parallel route slot returns 404 on a hard refresh |
The slot has no |
|
The command was removed in 16; use |
Slower pages after upgrading from 14 to 15 |
|
More, smaller prefetch requests after upgrading to 16 |
The routing and prefetching rewrite (layout deduplication, incremental prefetching), expected by design. |
Source: invalid-next-config and the upgrade guides in the references
Summary
-
The big changes by version: 13 (the App Router), 14 (Server Actions stable,
next exportremoved), 15 (async request APIs, uncached by default, React 19), 16 (Turbopack default,proxy.ts, Cache Components, synchronous APIs and several features removed) and 16.1 to 16.3 (stable dev cache, Adapters,catchError,next/root-params, Instant Navigations opt-in). -
npx next upgrade(16.1 and later) ornpx @next/codemod@canary upgrade latestupgrades the packages and runs the recommended codemods, but not all of them: runnext-async-request-apiby hand, fixnext.configandrevalidateTagmanually, and acceptcache-components-instant-falseonly when adopting Cache Components. -
Create React App and Vite apps move to Next.js as a client-only catch-all page with
output: 'export', and then adopt features route by route. -
cacheComponentsandpartialPrefetchingremain opt-in in 16.3; the section teaches both caching models. -
Thirteen areas of the books are outdated and fifteen items are errata; the tables above list each with the correct form and the page that teaches it.