Skip to content
Chapter 34Lesson 1

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:

next.config.ts
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.

next.config.ts
Always-on flags
cacheComponentstypedRoutes
this lesson, below
Platform pipeline
images
next lesson
Edge routing rules
redirectsrewritesheaderstrailingSlash
later this chapter — security headers later still
Bundling levers
serverExternalPackagestranspilePackages
this lesson, in depth
experimental
staging area for unstable options
changes between releases — check before copying
Everything `next.config.ts` touches in a 2026 SaaS, and where this chapter teaches each piece.

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 of headers() (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 on serverExternalPackages below.
  • experimental — the holding pen for unstabilized options, which churn between releases. Never paste an experimental block from a blog post without checking the key still exists in your version; that’s where older tutorials still show cacheComponents and typedRoutes, 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.

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.)

next.config.ts
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.

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:

Terminal window
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.js

The 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.

serverExternalPackages is the escape hatch:

next.config.ts
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.

next.config.ts // nothing yet
.next — server function
@acme/pdf-engine bundled
can't
reach
node_modules
@acme/pdf-engine + pdf_engine.node

The bundle holds a copy of the package, but the compiled .node binding stayed behind — so the function crashes when it runs.

Default: Turbopack bundles the package into the server function. The native binding it depends on doesn't come along, so the function crashes at runtime.
added serverExternalPackages: ['@acme/pdf-engine']
.next — server function
@acme/pdf-engine no longer bundled
node_modules
@acme/pdf-engine + pdf_engine.node

One line in next.config.ts opts the package out of bundling. The copy Turbopack was folding in is dropped.

Add the package to serverExternalPackages. Next stops bundling it.
in effect serverExternalPackages: ['@acme/pdf-engine']
.next — server function
require('@acme/pdf-engine') resolved at runtime
resolves
node_modules
@acme/pdf-engine + pdf_engine.node

Node loads the real package — native binding included — from node_modules, exactly as a plain Node script would. It works.

Now the function output holds only a thin require('@acme/pdf-engine'). At runtime Node resolves the real package — native binding included — from node_modules. 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.

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:

Should this package go in serverExternalPackages?

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.

serverExternalPackages Bundle out — require at runtime
transpilePackages Compile in before bundling
A native image-processing library with a .node binary
A database driver that does a dynamic require() at runtime
A vendor PDF SDK shipped with a compiled native addon
Your monorepo’s @acme/ui, shipped as raw .tsx
An internal @acme/utils package published as un-compiled TypeScript

Transitive 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.

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.

1 / 1

An SDK you just installed crashes at build with “Could not load the native binding.” What’s the right first move?

Check whether Next already externalizes it on its default list, and confirm it actually needs externalizing, before touching the config.
Add it to serverExternalPackages straight away — a native-binding crash is exactly what that key is for.
Move it to transpilePackages so Next compiles the package for you.
Set the package to unoptimized in the config to skip the bundling step.

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?

Whether you’ve bounced the dev server since the edit — it’s still serving the config it parsed when it booted.
Whether the key name got mistyped, which would let Next ignore it without complaint.
Whether the file should be .mjs, since .ts configs can’t carry stable flags.
Whether typedRoutes still needs to be nested under an experimental block.

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.