Next.js route handler generation with build-time rewrites and dev-time request routing
A configuration-driven package for analyzing content page trees, generating route-specific handlers, and routing traffic into those handlers — either through build-time rewrites (production) or a request-time proxy (development).
- Overview
- Getting Started
- Quick Start
- Usage
- Operation Modes
- Configuration Reference
- Architecture
- Capabilities
- Next.js Integration Points
- Two Operation Modes: Rewrite mode for production builds, proxy mode for development — each optimized for its environment.
- Config-Driven Targets: Declare one or more route spaces such as
docsandblogwith app-level and target-level settings. - Build-Time Generation: Discover content pages, resolve component metadata, classify heavy routes, and emit dedicated handler pages before the app build.
- Dev-Time Proxy Routing: A generated
proxy.tsintercepts requests and delegates heavy/light routing decisions into a long-lived worker session. - Lazy Discovery and Reuse: In proxy mode, heavy routes are classified on
first request and cached under
.next/cachefor fast subsequent requests and dev restarts. - Optional Startup Prewarm: Opt into
app.routing.workerPrewarm: 'instrumentation'to bootstrap the dev worker during Next startup instead of waiting for the first proxied request. - Locale-Aware Routing: Support for locale detection based on filenames or a default-locale routing model.
- Multi-Target: Support multi-target setups such as
docsplusblogin one configuration file.
Content-heavy route spaces such as docs and blogs often benefit from splitting
"heavy" routes (pages with interactive components) from "light" routes (pages
with only standard markdown elements). next-slug-splitter manages that split:
- In production:
next buildgenerates dedicated handler pages for heavy routes and installs rewrites that route matching traffic into those handlers. - In development: A proxy discovers heavy routes lazily on first request, reuses cached route-capture facts across dev restarts, and can optionally prewarm its worker session during startup.
The configuration lives in one app-owned file, the integration is a single
withSlugSplitter(...) wrapper, and the routing strategy adapts automatically
to the current Next.js phase.
- MDX only — content pages must be
.mdxfiles. Standard.tsx/.jsxpages are not analyzed. Support for non-MDX content sources may be added later. - Pages Router — currently has the fuller feature set, including the
existing dev proxy path and the
getStaticProps/getStaticPaths-based integration underpages/. - App Router — catch-all page routes under
app/support build-time generation plus rewrite-based routing in production, and proxy-based lazy routing in development through the same worker architecture as the Pages Router path. Seedocs/architecture/app-router-boundary-files.md. For a current Pages-vs-App behavior comparison around dev proxy quirks and safeguards, seedocs/architecture/router-behavior-matrix.md.
npm install next-slug-splitter next
# or
pnpm add next-slug-splitter nextnext-slug-splitter requires Next.js 16.2.0 or newer and installs the
stable top-level adapterPath option.
import { withSlugSplitter } from 'next-slug-splitter/next';
const nextConfig = {
i18n: {
locales: ['en', 'de'],
defaultLocale: 'en'
}
};
export default withSlugSplitter(nextConfig, {
configPath: './route-handlers-config.mjs'
});For single-locale Pages Router setups, omit Next i18n entirely. The library
normalizes the missing i18n block into its internal single-locale
LocaleConfig automatically.
// route-handlers-config.mjs
// @ts-check
import process from 'node:process';
import path from 'node:path';
import {
createCatchAllRouteHandlersPreset,
relativeModule
} from 'next-slug-splitter/next';
import { routeHandlerBindings } from 'site-route-handlers/config';
const rootDir = process.cwd();
/** @type {import('next-slug-splitter/next').DynamicRouteParam} */
const docsRouteParam = {
name: 'slug',
kind: 'catch-all'
};
/** @type {import('next-slug-splitter/next').DynamicRouteParam} */
const blogRouteParam = {
name: 'slug',
kind: 'single'
};
/** @type {import('next-slug-splitter/next').RouteHandlersConfig} */
export const routeHandlersConfig = {
app: {
rootDir,
routing: {
// Default: 'proxy' in development, rewrites in production
development: 'proxy'
}
},
targets: [
createCatchAllRouteHandlersPreset({
routeSegment: 'docs',
handlerRouteParam: docsRouteParam,
contentDir: path.resolve(rootDir, 'docs/src/pages'),
routeContract: relativeModule('pages/docs/[...slug]'),
handlerBinding: routeHandlerBindings.docs
}),
createCatchAllRouteHandlersPreset({
routeSegment: 'blog',
handlerRouteParam: blogRouteParam,
contentDir: path.resolve(rootDir, 'blog/src/pages'),
routeContract: relativeModule('pages/blog/[slug]'),
contentLocaleMode: 'default-locale',
handlerBinding: routeHandlerBindings.blog
})
]
};Each preset resolves generatedRootDir for you from its routeSegment, then
the library appends the canonical generated-handlers/ leaf during target
resolution.
For Pages Router targets, point routeContract at the catch-all page module
itself, for example pages/docs/[...slug].tsx, because generated heavy
handler pages reuse that page's getStaticProps contract rather than
introducing a second data-loading entrypoint. Route enumeration still stays on
that catch-all page through getStaticPaths.
No separate generation command is required for the standard integration path.
next build runs route-handler generation automatically through the installed
adapter.
Use createAppCatchAllRouteHandlersPreset(...) when the public catch-all route
lives under app/ and you want the current App Router path.
// route-handlers-config.mjs
// @ts-check
import process from 'node:process';
import path from 'node:path';
import {
createAppCatchAllRouteHandlersPreset,
relativeModule
} from 'next-slug-splitter/next';
import { routeHandlerBindings } from 'site-route-handlers/config';
const rootDir = process.cwd();
/** @type {import('next-slug-splitter/next').DynamicRouteParam} */
const docsRouteParam = {
name: 'slug',
kind: 'catch-all'
};
/** @type {import('next-slug-splitter/next').RouteHandlersConfig} */
export const routeHandlersConfig = {
routerKind: 'app',
app: {
rootDir
},
targets: [
createAppCatchAllRouteHandlersPreset({
routeSegment: 'docs',
handlerRouteParam: docsRouteParam,
contentDir: path.resolve(rootDir, 'content/pages'),
contentLocaleMode: 'default-locale',
routeContract: relativeModule('app/docs/[...slug]/route-contract'),
handlerBinding: {
...routeHandlerBindings.docs,
pageDataCompilerImport: relativeModule(
'config-variants/javascript/content-compiler.mjs'
)
}
})
]
};This preset resolves generatedRootDir for you from routeSegment, and the
library later resolves the final generated-handlers/ directory under that
root.
Unlike the Pages Router path, App Router usually keeps the route contract in a
dedicated sibling file such as app/docs/[...slug]/route-contract.ts. The
public page and the generated heavy pages both call into that one contract
module, and that same file also owns route enumeration through
getStaticParams.
The App-specific fields are:
routeContract— the dedicated App route-contract module imported by the light page and generated heavy pageshandlerBinding.pageDataCompilerImport— the app-owned compiler module that the library executes in an isolated worker for page-data compilationapp.localeConfig— optional multi-locale semantics used for App-side worker routing and static-param filtering
app.localeConfig is a library routing contract, not a direct mirror of
Next.js i18n settings:
- omit
app.localeConfigfor single-locale App Router setups - provide
app.localeConfigonly for multi-locale App Router setups localeslists every locale identity the library should reason aboutdefaultLocalemust be included inlocales
If the App tree needs route groups or another custom filesystem placement for
generated handlers, use manual target config and set generatedRootDir
directly there. The catch-all preset stays on the conventional
app/<routeSegment> branch on purpose.
Concrete comparison:
| Aspect | Pages Router | App Router |
|---|---|---|
| Public light route file | pages/docs/[...slug].tsx |
app/docs/[...slug]/page.tsx |
| Route contract location | Usually the catch-all page module itself | Usually a dedicated sibling file such as app/docs/[...slug]/route-contract.ts |
| Route enumeration | getStaticPaths stays on the catch-all page |
getStaticParams lives on the dedicated route contract |
| Shared page-data contract | Generated heavy handlers reuse the catch-all page's getStaticProps |
The light page and generated heavy pages share loadPageProps from the route contract |
| Optional metadata / revalidation | Follows normal Pages Router page exports around the catch-all page surface | generatePageMetadata and revalidate live on the dedicated route contract |
| Locale semantics | Uses normal Pages Router i18n when multi-locale |
Uses app.localeConfig for multi-locale App routing semantics |
If your processors, factories, or component registries already live behind
package exports, use packageModule(...) early instead of building manual
filesystem paths into your route-handler config.
This is especially useful in processors:
- the package manager and Node resolve the shared package through
node_modules - the processor does not need to maintain app-specific absolute or relative paths for shared component modules
- the same package export can be reused from both
handlerBindingandcomponentImport.source
For workspace packages, packageModule(...) only works when the package is
reachable through the app's node_modules resolution path, which usually means
declaring it in the root app package.json so the workspace package is hoisted
or otherwise installed where Node can resolve it. A complete processor example
appears in Step 3 below.
withSlugSplitter(nextConfigExport, options) resolves the app-owned route
handlers config and installs the adapter entry into adapterPath.
Two registration modes:
// File-based (recommended for most apps)
withSlugSplitter(nextConfig, {
configPath: './route-handlers-config.mjs'
});
// Direct object (useful for monorepos or programmatic setups)
withSlugSplitter(nextConfig, {
routeHandlersConfig: myConfig
});The route handlers config is the app-owned source of truth for route handler generation. A target typically describes:
- the public route segment such as
docsorblog - the dynamic route parameter kind
- the content page directory
- the binding that provides the processor module for route planning
createCatchAllRouteHandlersPreset(...) is the shortest way to configure
catch-all targets without hand-assembling all path values.
The handler binding tells the library which processor module to load:
{
handlerBinding: {
processorImport: relativeModule('lib/handler-processor');
}
}The processor is the single source of truth for component imports and factory
selection. It is exported from the module referenced by processorImport.
This is also a common place to use packageModule(...) for shared component
registries or UI packages, because the processor can rely on package exports
instead of maintaining manual filesystem paths to those component modules.
import { packageModule, relativeModule } from 'next-slug-splitter/next';
const componentsModule = packageModule('@site/components');
export const routeHandlersConfig = {
app: {
rootDir
},
targets: [
{
handlerBinding: {
processorImport: packageModule('site-route-handlers/docs/processor')
}
}
]
};
export const routeHandlerProcessor = {
resolve({ capturedComponentKeys }) {
// Gather what you need — registry lookups, metadata, etc. — and return
// the final generation plan directly.
const componentEntriesByKey = resolveComponentsByCapturedKey(
capturedComponentKeys
);
return {
factoryImport: relativeModule('lib/handler-factory/runtime'),
components: capturedComponentKeys.map(key => {
const entry = componentEntriesByKey[key];
return {
key,
componentImport: {
source: componentsModule,
kind: 'named',
importedName: entry.exportName
}
};
})
};
}
};resolve— produce the generation plan for one heavy route. Implementations can still use private local helpers to gather registry data, metadata, or config before returning the final plan.
A TypeScript helper defineRouteHandlerProcessor(...) is available for
type inference:
import { defineRouteHandlerProcessor } from 'next-slug-splitter/next';
export const routeHandlerProcessor = defineRouteHandlerProcessor({
resolve({ capturedComponentKeys, route }) { ... }
});The standalone CLI generates handler artifacts or runs analysis only. Unlike the Next adapter, it does not derive inputs from a discovered Next config. Pass the route-handlers config module path plus explicit locale semantics.
Required flags:
--route-handlers-config-path— path to the route-handlers config module--locales— comma-separated locale list--default-locale— default locale and member of--locales
Optional flags:
--analyze-only— skip handler emission and report what would be generated--json— emit a machine-readable array of per-target results
pnpm exec tsx ./node_modules/next-slug-splitter/dist/cli.js \
--route-handlers-config-path ./route-handlers-config.ts \
--locales en,de \
--default-locale en \
--analyze-only
node ./node_modules/next-slug-splitter/dist/cli.js \
--route-handlers-config-path ./route-handlers-config.mjs \
--locales en,de \
--default-locale en \
--jsonThe human-readable output prints one summary line per configured target.
--json emits the same per-target results as an array.
- Use
tsxwhen the route-handlers config module is TypeScript, for example.ts. - Use plain
nodewhen the route-handlers config module is JavaScript, for example.mjs.
In development with proxy mode, the CLI step is not needed. The proxy discovers routes on demand.
Used during PHASE_PRODUCTION_BUILD and PHASE_PRODUCTION_SERVER.
- The build analyzes content pages and generates dedicated handler page files
- The adapter injects rewrites into the Next config (
beforeFiles) - Next.js routes matching traffic to the generated handler pages
All routes are resolved upfront at build time. The generated handler pages and rewrites are static artifacts.
Used during PHASE_DEVELOPMENT_SERVER.
- The adapter generates a thin
proxy.tsfile at the app root - It also writes a structural worker bootstrap manifest to
.next/cache/route-handlers-worker-bootstrap.json proxy.tsintercepts page requests matching configured route base paths- A long-lived worker session classifies unknown routes on demand
- Heavy routes are rewritten to their generated handler pages; light routes pass through to the catch-all page
- Stage 1 route-capture facts are cached per target under
.next/cache/route-handlers-lazy-single-routes/
Benefits over rewrite mode in development:
- Instant startup — no upfront generation pass
- On-demand discovery — only routes actually visited are classified
- Cross-restart reuse — emitted handlers and lazy route-capture facts can be reused across dev restarts while development remains the owning phase
When development routing uses 'proxy', you can ask the library to bootstrap
the long-lived worker session during Next startup:
// In route-handlers-config.mjs
export const routeHandlersConfig = {
app: {
routing: {
development: 'proxy',
workerPrewarm: 'instrumentation'
}
},
targets: [...]
};When enabled, next-slug-splitter generates a tiny root instrumentation.ts
file that imports
prewarmRouteHandlerProxyWorker from
next-slug-splitter/next/instrumentation. This is a best-effort startup
prewarm of the current worker session only. It does not classify routes, emit
handlers, or warm specific pages ahead of traffic.
If your app already owns instrumentation.ts or instrumentation.js at the
root or under src/, the library refuses to overwrite it. Leave
workerPrewarm set to 'off' in that case.
The lazy proxy path is self-healing in development:
- The worker determines whether the requested route is light or heavy.
- If the route is heavy, it checks whether the emitted handler file already exists.
- If the file already exists, it is reused and not regenerated.
- If the file is missing, the worker emits that single handler on demand.
- The rewrite is then resolved within the same request cycle.
This means a missing heavy-route handler can be recreated on first request without a separate generation step, while an existing handler is reused as-is.
Handler generation is therefore self-healing, but development can still hit a narrow Next/Turbopack warm-up window where the proxy already knows the correct handler destination while the emitted page is not fully ready yet. During that window, the browser can briefly land on a transient 404 for a catch-all route.
To smooth that development-only case, add a custom pages/404.* page that uses
the dedicated not-found helper:
import type { NextPage } from 'next';
import { useSlugSplitterNotFoundRetry } from 'next-slug-splitter/next/not-found-retry';
const CATCH_ALL_ROUTE_PREFIXES = ['/docs/', '/blog/'];
const NotFound: NextPage = () => {
const isNotFoundConfirmed = useSlugSplitterNotFoundRetry({
catchAllRoutePrefixes: CATCH_ALL_ROUTE_PREFIXES
});
if (!isNotFoundConfirmed) {
return null;
}
return <h1>Page Not Found</h1>;
};
export default NotFound;This hook is a no-op outside development, so production builds still render their normal 404 page immediately.
In dev mode, Next.js caches the page manifest on the client side. When a handler page is lazily emitted after the manifest was cached, the client-side router does not know the page exists and may fail the navigation. To fix this, apply the included patch that adds manifest refresh on rewrite miss:
{
"pnpm": {
"patchedDependencies": {
"next@16.2.0": "patches/next@16.2.0.patch"
}
}
}The patch modifies page-loader.js to accept a refresh parameter and
router.js to re-fetch the dev pages manifest when a proxy rewrite targets
a page not yet in the cached page list. This fix has been proposed upstream
in vercel/next.js#91760 and
the local patch can be removed once it lands. This is only relevant in
development; production builds pre-compile all handler pages.
The development routing mode defaults to 'proxy', and worker prewarm defaults
to 'off'. To override:
// In route-handlers-config.mjs
export const routeHandlersConfig = {
app: {
routing: {
development: 'rewrites',
workerPrewarm: 'off'
}
},
targets: [...]
};Environment variable override (takes precedence over config):
NEXT_SLUG_SPLITTER_DEV_ROUTING=proxy # Force proxy mode
NEXT_SLUG_SPLITTER_DEV_ROUTING=rewrites # Force rewrite modeNEXT_SLUG_SPLITTER_DEV_ROUTING controls only the development routing mode.
workerPrewarm accepts 'off' or 'instrumentation' and only applies when
development routing resolves to 'proxy'.
Wrap one Next config export and register the route handlers config.
| Option | Description |
|---|---|
configPath |
Path to the app-owned route-handlers-config module |
routeHandlersConfig |
Direct config object (alternative to configPath) |
Top-level configuration shape.
| Property | Description |
|---|---|
app.rootDir |
Application root directory |
app.routing.development |
Development routing mode: 'proxy' (default) or 'rewrites' |
app.routing.workerPrewarm |
Dev-only worker startup strategy: 'off' (default) or 'instrumentation' |
app.prepare |
Optional TypeScript prepare step or steps run before route planning |
targets |
Array of target configurations |
When a processor or registry needs a local TypeScript build before runtime
loading, configure app.prepare as one object or an ordered array of objects:
app: {
rootDir,
prepare: [
{
tsconfigPath: relativeModule('tsconfig.processor.json')
}
]
}If you only need one prepare step, a single object is also accepted. If no
pre-build is needed, omit app.prepare.
app.routing.workerPrewarm only affects development proxy mode. When set to
'instrumentation', the library generates a root instrumentation.ts bridge
that prewarms the current proxy worker session. Existing app-owned
instrumentation.ts / instrumentation.js files at the root or under src/
are treated as conflicts and are never overwritten.
Create one catch-all target with normalized route and path values.
| Option | Description |
|---|---|
routeSegment |
Public route segment (e.g. 'docs', 'blog') |
handlerRouteParam |
Dynamic route parameter configuration |
contentDir |
Directory containing content pages |
routeContract |
Pages route contract module, typically the catch-all page such as pages/docs/[...slug].tsx |
generatedRootDir |
Derived generated-output root; presets resolve this from routeSegment |
handlerBinding |
Binding with processor module for route planning |
contentLocaleMode |
Locale detection mode (see below) |
Create one App Router catch-all target with normalized public route values and App-specific route-module inputs.
| Option | Description |
|---|---|
routeSegment |
Public route segment (e.g. 'docs') |
handlerRouteParam |
Dynamic route parameter configuration |
contentDir |
Directory containing content pages |
handlerBinding |
Binding with processor module for route planning |
routeContract |
Shared router-specific route contract. In Pages this usually resolves to the catch-all page module and its getStaticProps, while in App this is typically a dedicated route-contract.ts file that owns getStaticParams and loadPageProps. |
routeContractRuntimeImport |
Optional worker-owned App route module loaded whenever the library executes the route contract outside Next's server graph; defaults to routeContract |
contentLocaleMode |
Locale detection mode (see below) |
Supported kind values:
single— matches a single path segmentcatch-all— matches one or more path segmentsoptional-catch-all— matches zero or more path segments
Several config fields use module-reference helpers instead of raw strings.
| Helper | Use when |
|---|---|
relativeModule('lib/handler-processor') |
The file lives under the app root and should resolve relative to app.rootDir |
packageModule('site-route-handlers/docs/processor') |
The module is exposed through package exports in node_modules, including hoisted workspace packages |
absoluteModule('/abs/path/to/module') |
The file lives outside the app root and outside reachable package exports |
See the Usage section above for a complete packageModule(...) example in both
handlerBinding.processorImport and processor-side component imports.
The handler binding tells the library which processor module to load.
{
processorImport: relativeModule('lib/handler-processor');
}See the Usage section above for a worked processor example.
A processor is a route-local transformer the library calls once per heavy route.
It is exported from the module referenced by processorImport.
resolve— produce the generation plan for one heavy route. Implementations can still use private local helpers to gather registry data, metadata, or config before returning the final plan.
A TypeScript helper defineRouteHandlerProcessor(...) is available for
type inference:
import { defineRouteHandlerProcessor } from 'next-slug-splitter/next';
export const routeHandlerProcessor = defineRouteHandlerProcessor({
resolve({ capturedComponentKeys, route }) { ... }
});Supported modes:
filename— locale is encoded in the content file naming schemedefault-locale— the default locale omits the locale prefix in the public route space
The adapter (adapterPath) is the entry point for Next.js integration. It
runs during the relevant Next.js phases and coordinates:
- Routing strategy selection (rewrite vs. proxy)
- App-owned preparation and config resolution
- Phase-local artifact ownership for dev versus build
- Rewrite injection or generated
proxy.ts/instrumentation.tsbridges
The runtime keeps reuse narrowly scoped to the artifacts it actually owns:
- Phase ownership record —
.next/cache/route-handlers-phase-owner.jsonseparates dev-owned and build-owned generated state so the two phases do not trust each other's handlers or caches - Proxy bootstrap manifest —
.next/cache/route-handlers-worker-bootstrap.jsonpersists only the structural target data the parent proxy runtime and worker need to share - Lazy single-route cache —
.next/cache/route-handlers-lazy-single-routes/stores per-target Stage 1 MDX capture facts viafile-entry-cache, enabling safe cross-restart reuse in development
In proxy mode, the adapter generates a thin proxy.ts bridge file at the app
root. This file:
- Imports the library-owned proxy runtime
- Embeds static matchers for configured route base paths and locales
- Is automatically created when entering proxy mode and cleaned up when leaving
The generated file is marked with an ownership marker so it can be distinguished from user-authored proxy files.
Existing app-owned proxy.ts, proxy.js, middleware.ts, or middleware.js
files at the root or under src/ are treated as hard conflicts. The library
does not overwrite framework-owned routing entrypoints.
When app.routing.workerPrewarm === 'instrumentation' and development uses
proxy mode, the adapter also generates a tiny root instrumentation.ts bridge.
That file is removed again when prewarm is turned off or proxy mode is no
longer active. Existing app-owned instrumentation files are treated as hard
conflicts and are never overwritten or deleted.
In proxy mode, route classification happens in a child worker process. This is necessary because the proxy runtime environment cannot dynamically import app-owned configuration modules. The parent process keeps only lightweight bootstrap state and route-base matchers; the worker reconstructs planner state from the persisted bootstrap manifest, reuses one long-lived session per bootstrap generation, and returns lazy route classifications on demand.
- Two operation modes optimized for their respective environments
- Standalone CLI with explicit route-handlers config and locale semantics
- Lazy on-demand route discovery in development
- Cross-restart dev reuse through persisted bootstrap and lazy single-route
caches under
.next/cache - Optional dev-only
instrumentation.tsworker prewarm - Phase-local artifact ownership that avoids dev/build cache contamination
- Install rewrite integration without mutating the incoming Next config object
- Resolve app-level and target-level route handler config in one shared shape
- Discover content pages and generate handler artifacts per target
- Reuse handler bindings for processor-driven route planning
- Support multi-target setups such as
docsplusblog - Locale-aware routing with configurable detection modes
- Phase-aware behavior — only active during development, build, and production server phases
| Next.js API | Purpose |
|---|---|
adapterPath |
Adapter entry point — hooks into Next.js config resolution |
rewrites() → beforeFiles |
Routes heavy-page traffic to generated handlers in production |
proxy.ts (root file) |
Intercepts and classifies requests on demand in development; existing proxy.* or middleware.* files at the root or under src/ are treated as conflicts |
instrumentation.ts (root file) |
Optional dev-only worker-session prewarm when workerPrewarm: 'instrumentation' is enabled |
| Phase constants | Selects rewrite mode (build/serve) or proxy mode (dev) |