The typed next.config.ts
The typed Next.js config surface, read once before any route runs, and serverExternalPackages.
The day you scaffold a Next.js project, next.config.ts is almost empty: a couple of flags and an export. A year into a real product, that same file declares the image domains your CDN serves from, holds a permanent redirect from a URL scheme you retired, carries a stack of security headers, and has one stubborn line for an SDK that refuses to bundle. It grew because the product grew, one concern at a time.
So each line earns a question: what does it buy, and what does it cost? Some entries are free flags you turn on once. Others are levers you reach for only when something specific breaks. This file is the project-level surface, read once before any route runs. This lesson maps everything the surface touches and which lesson owns each piece, then teaches one entry in depth: serverExternalPackages, the standard fix for an SDK that won’t bundle.
What next.config.ts is and how Next reads it
Section titled “What next.config.ts is and how Next reads it”next.config.ts is a plain Node module. Turbopack loads it once when you start the dev server and once at build time, reads the single config object it exports, and stops there. Nothing in the file ships to the browser; it only shapes how your app is built and served.
Here’s the whole shape:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { // config goes here};
export default nextConfig;A typed object literal annotated with NextConfig, exported as the default. The annotation is why the course always writes the config in TypeScript: without it, a misspelled key like cacheComponent is silently ignored, leaving you no clue why nothing changed. With NextConfig on the object, the same typo becomes a build error and a red squiggle, and you get autocomplete over the whole surface. Use import type for NextConfig, since it’s a type-only import.
A config map: five kinds of key, and where each is taught
Section titled “A config map: five kinds of key, and where each is taught”This is the chapter’s index: it shows where each concern lives, so when you need to add an image domain or a redirect weeks from now, you know which part of the file to reach into. Keys are grouped by kind, because the kind shapes how you treat the key and usually maps to the lesson.
cacheComponentstypedRoutes images redirectsrewritesheaderstrailingSlash serverExternalPackagestranspilePackages The five kinds:
- Always-on flags (
cacheComponents,typedRoutes) — scaffolding you set once and leave on; covered next, in this lesson. - Platform-pipeline config (
images) — tunes how Next processes your assets; next lesson. - Edge routing rules (
redirects,rewrites,headers,trailingSlash) — how Next handles URLs and responses before they reach your route handlers; a later lesson this chapter. The contents ofheaders()(CSP, HSTS, and the rest of the security baseline) come later still, when we harden the app for launch. - Bundling levers (
serverExternalPackages,transpilePackages) — conditional power tools; we go deep onserverExternalPackagesbelow. experimental— the holding pen for unstabilized options, which churn between releases. Never paste anexperimentalblock from a blog post without checking the key still exists in your version; that’s where older tutorials still showcacheComponentsandtypedRoutes, both since promoted to top-level.
Two always-on flags: cacheComponents and typedRoutes
Section titled “Two always-on flags: cacheComponents and typedRoutes”Two keys go on at the start of every project in this course and stay on.
cacheComponents: true opts into the Cache Components rendering model: routes are dynamic by default, and you cache deliberately with an explicit use cache where it pays off. Here you only flip the switch.
typedRoutes: true turns the set of valid paths into a typed union, so the type system knows /dashboard is a real route and /dashbord isn’t. The payoff is where a broken link gets caught:
<Link href="/dashbord">Dashboard</Link>Compiles fine, breaks at runtime. href is just a string, so nothing checks the typo. A user finds the dead link by clicking it and hitting a 404 in production, and you find out from an error report.
<Link href="/dashbord">Dashboard</Link>Caught at build time. href now accepts only known routes, so "/dashbord" is a type error: a red squiggle in the editor and a failed build. The dead link never ships.
The failure moves from runtime to compile time for one line of config. (<Link> is the App Router’s client-side navigation component; its href is what typedRoutes makes type-safe.)
const nextConfig: NextConfig = { cacheComponents: true, typedRoutes: true,};serverExternalPackages: when a package won’t bundle
Section titled “serverExternalPackages: when a package won’t bundle”Unlike the flags so far, this one is a line you add only when a specific failure shows up. The skill is recognizing that failure and judging whether this is the fix.
The failure that triggers it
Section titled “The failure that triggers it”Say your app generates PDF invoices, so you install a vendor SDK that rasterizes documents. We’ll call it @acme/pdf-engine, a stand-in for the native packages real apps pull in: a PDF renderer, a barcode generator, a native-crypto module. You import it in a route handler, hit the route, and it crashes:
⨯ Error: Could not load the native binding for @acme/pdf-engine Cannot find module './pdf_engine.linux-x64.node' Require stack: - .next/server/chunks/[turbopack]/acme-pdf-engine.js - .next/server/app/api/invoices/[id]/route.jsThe wording varies by package: “Could not load the native binding,” “Cannot find module ’./something.node’,” sometimes a raw MODULE_NOT_FOUND. The shape is constant: at runtime, inside the .next output, something the package needs alongside it is missing.
Turbopack bundles your Server Component and route-handler code with the packages it imports: it traces every import, pulls the code together, and emits the files the server runs. That works when every dependency is statically visible in the import graph. It breaks for two kinds of package. The first depends on a native binding , a .node file the bundler can’t read or fold into a JavaScript chunk, only load at runtime. The second reaches for its dependencies dynamically, through a require() computed at runtime instead of a static import the bundler can see ahead of time. Either way the bundler loses the trail, and the missing piece surfaces as a crash.
What serverExternalPackages does
Section titled “What serverExternalPackages does”serverExternalPackages is the escape hatch:
const nextConfig: NextConfig = { serverExternalPackages: ['@acme/pdf-engine'],};That line tells Next to leave the package alone: don’t bundle it, require it at runtime. Instead of folding @acme/pdf-engine into a server chunk, Next emits a thin require('@acme/pdf-engine'), and Node’s ordinary module resolution loads the real package from node_modules when the function runs, native binding and all.
Scrub through the relocation below.
// nothing yet reach
The bundle holds a copy of the package, but the compiled .node binding stayed behind — so the function crashes when it runs.
serverExternalPackages: ['@acme/pdf-engine'] One line in next.config.ts opts the package out of bundling. The copy Turbopack was folding in is dropped.
serverExternalPackages: ['@acme/pdf-engine'] Node loads the real package — native binding included — from node_modules, exactly as a plain Node script would. It works.
The flag isn’t free. The package is no longer bundled and tree-shaken with the rest of your server code, so the function output grows, and because the require runs when the function does, the cold start is marginally slower. When the SDK genuinely needs externalizing, that’s a fine trade; when it doesn’t, you’ve paid the cost for nothing.
Check the default list first
Section titled “Check the default list first”Next.js ships with a large built-in list of packages it externalizes for you. Most popular Node SDKs are already on it: @prisma/client, sharp, pg, mongodb, better-sqlite3, @aws-sdk/client-s3, puppeteer, and roughly eighty more. For any of those, install it, import it, and it works.
The worked example uses @acme/pdf-engine because it’s the rare package that isn’t on the list, the only situation where you touch this config. The decision rule:
Most packages bundle fine or are already externalized. Don’t pre-configure anything: a config line you didn’t need is pure cold-start cost.
This isn’t a bundling problem. Externalizing it would hide a genuinely missing package behind a runtime require that still fails, just later and more confusingly. Install what’s actually missing.
It’s on the default list, so Next already emits a runtime require for it. Adding it yourself is redundant.
A native or dynamic-require package that isn’t on the default list. This is the one case the config key exists for, so add the single entry and restart the dev server.
Two anti-patterns. Don’t externalize preemptively: adding a package “to be safe” only buys a slower cold start if it bundled fine anyway, and a popular SDK is probably already on the list. And don’t use serverExternalPackages to silence a missing-dependency error. A package you forgot to install also crashes with “Cannot find module,” so externalizing it can look like a fix, but it just moves the same failure from build time to runtime, where it’s harder to diagnose. Install the package instead.
serverExternalPackages vs transpilePackages
Section titled “serverExternalPackages vs transpilePackages”The neighboring key transpilePackages is easy to confuse with this one. Both control how Next treats a dependency, but they push code in opposite directions. serverExternalPackages is for a finished, compiled package that breaks when bundled, so you push it out and require it at runtime. transpilePackages is for a raw, un-compiled package that Next must pull in and compile first, typically one of your own monorepo packages shipped as plain .ts/.tsx. One package is too finished to bundle; the other isn’t finished enough.
Each package breaks Next's default bundling for a different reason. Sort it into the key that fixes it. Drag each item into the bucket it belongs to, then press Check.
.node binaryrequire() at runtime@acme/ui, shipped as raw .tsx@acme/utils package published as un-compiled TypeScriptTransitive dependencies are externalized automatically
Section titled “Transitive dependencies are externalized automatically”A package you externalize needs its own dependencies externalized too. As of Next.js 16.1 that is automatic: put a package in serverExternalPackages and Turbopack externalizes its whole dependency tree. Configs or blog posts that list an SDK’s internal dependencies one by one are now obsolete; one entry is enough.
Config edits don’t apply until you restart
Section titled “Config edits don’t apply until you restart”next.config.ts is read once, at server startup. Edit it while the dev server runs and the server keeps using the config it loaded at boot, so your change sits on disk doing nothing. Nothing errors: you add a flag, save, refresh, see no change, and start doubting a line that was correct.
A production next.config.ts, end to end
Section titled “A production next.config.ts, end to end”Here’s the config you’d ship for a new web app at this stage: small, every line defensible.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { cacheComponents: true, typedRoutes: true, serverExternalPackages: ['@acme/pdf-engine'], // Security headers (CSP, HSTS) go here once the hardening pass lands. // async headers() { ... }, // images / redirects / rewrites get added here as the product grows.};
export default nextConfig;The typed import. It’s import type because the import is type-only, which the course’s TypeScript settings require. NextConfig turns a misspelled key into a build error instead of a silently ignored option, and it’s why the file is .ts.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { cacheComponents: true, typedRoutes: true, serverExternalPackages: ['@acme/pdf-engine'], // Security headers (CSP, HSTS) go here once the hardening pass lands. // async headers() { ... }, // images / redirects / rewrites get added here as the product grows.};
export default nextConfig;The two always-on flags. cacheComponents opts into the Cache Components rendering model; typedRoutes makes a bad <Link href> a build error. Set once, left on.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { cacheComponents: true, typedRoutes: true, serverExternalPackages: ['@acme/pdf-engine'], // Security headers (CSP, HSTS) go here once the hardening pass lands. // async headers() { ... }, // images / redirects / rewrites get added here as the product grows.};
export default nextConfig;The single conditional entry, here only because @acme/pdf-engine is native, crashed when bundled, and isn’t on Next’s default list. Drop any of those and the line wouldn’t exist. Orange marks a lever that earns its cost.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { cacheComponents: true, typedRoutes: true, serverExternalPackages: ['@acme/pdf-engine'], // Security headers (CSP, HSTS) go here once the hardening pass lands. // async headers() { ... }, // images / redirects / rewrites get added here as the product grows.};
export default nextConfig;Signposts, not code. The commented headers() marks where the security baseline lands later; the trailing comment marks where image config and routing rules will grow the file.
Check your understanding
Section titled “Check your understanding”An SDK you just installed crashes at build with “Could not load the native binding.” What’s the right first move?
serverExternalPackages straight away — a native-binding crash is exactly what that key is for.transpilePackages so Next compiles the package for you.unoptimized in the config to skip the bundling step.transpilePackages solves the opposite problem (raw TypeScript to compile in), and unoptimized belongs to the images config — it does nothing for bundling.You add typedRoutes: true to a next.config.ts typed with NextConfig, save, and refresh the page in your already-running next dev — but a <Link href="/dashbord"> still compiles without a squiggle. What’s the one thing to check first?
.mjs, since .ts configs can’t carry stable flags.typedRoutes still needs to be nested under an experimental block.NextConfig annotation (it’d be a type error, not a silent no-op), .ts is the correct and only supported form for this ESM project, and typedRoutes graduated to a stable top-level key — nesting it under experimental would be the actual mistake.External resources
Section titled “External resources”The most useful page to bookmark is the serverExternalPackages reference. It carries the live default list, which is the thing you’ll actually want to check before adding a package by hand.
The config key reference — and the live list of packages Next externalizes by default.
When and how Next bundles dependencies — externalizing, transpiling, and analyzing what ships.
The flag that turns your route set into a typed union, so a bad <Link href> won't compile.
Every configuration option in one place, each linking to its own page.