@nextrush/adapter-nextjs
v1.0.0
Published
Mount a NextRush app in a Next.js App Router route handler (Web-standard, App Router only, Next 14/15/16)
Maintainers
Readme
@nextrush/adapter-nextjs
Mount a NextRush app in a Next.js App Router route handler — one function, seven exports, zero request rewriting.
| | |
| --- | --- |
| Purpose | Mount a NextRush Application in a Next.js App Router route handler |
| Package type | Adapter |
| Status | Beta 🚧 |
| Included in nextrush? | ✅ Yes — re-exported as nextrush/nextjs (optional peer, functional installs skip it) |
| Support tier | Public — stable surface once out of beta — see ADR-0014 |
| Maintenance | Active |
| Runtime | Universal — Node · Bun · Deno · Cloudflare (via OpenNext) — anywhere Next.js itself runs |
| Requires | Node >=22 · ESM-only · TypeScript >=5.x · Next.js >=14.0.0 (App Router only) |
| Introduced | v1.0.0-beta.0 |
Highlights
- ✅ Zero request rewriting —
ctx.path/ctx.url/ctx.raw.reqare always the true request; no mount-prefix magic to undo - ✅ Fully Web-standard — no
node:*,process,Buffer, or runtime global; one entry point runs on every host Next.js runs on - ✅
nextis an optional peer — a functional-onlynextrushinstall never resolves it - 📦 Bundle: ~4 KB min+gzip
The problem · When to use · Installation · Quick start · Capabilities · Mental model · Common tasks · Project structure · API overview · Options · Compatibility · Troubleshooting · FAQ · Package relationships · Architecture · Resources
The problem
Next.js's App Router lets you drop an API into app/api/.../route.ts — but the moment your API
grows past a couple of routes, you're stuck choosing between two bad options:
- Write everything by hand in
route.ts— every method (GET/POST/PATCH/…), every param, every validation check, one giant file per route segment, with no shared middleware, no router, no way to split "list users" and "create user" into separate concerns. - Reach for a full API framework that assumes it owns the process — most expect to call
listen()and run their own server. Next.js already owns the process; there's nolisten()to call, and no way to hand aRequestto something that only speaks(req, res).
What you actually want is what Hono popularized: a real app — routers, middleware, a service layer, typed context — built once, the same way regardless of where it runs, then mounted into the route file with one line:
// app/api/[[...route]]/route.ts
import { app } from '@/server/app';
import { handle } from 'nextrush/nextjs';
export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = handle(app);That's this package's entire job: bridge a real NextRush Application — however you've built
it, across however many files — into the seven exports route.ts needs, without rewriting the
request or dropping background work. ctx.waitUntil() is silently a no-op under a hand-rolled
bridge (Next supplies no execution context on its own); handle() wires it to Next's after()
for you.
When to use
Use @nextrush/adapter-nextjs if:
- ✓ You want to mount a NextRush API inside a Next.js App Router project (
app/api/.../route.ts) - ✓ You're on Next.js 14, 15, or 16
Reach for something else if:
- ✗ You're on the Pages Router → migrate the route to
app/api/[[...route]]/route.ts(the App Router has been Next's default for three major versions; this package deliberately does not support Pages — see ARCHITECTURE.md) - ✗ You're deploying to Cloudflare Workers/Vercel Edge/AWS Lambda directly (no Next.js involved)
→ use
@nextrush/adapter-edgeor@nextrush/adapter-serverless
Installation
pnpm add @nextrush/adapter-nextjs
# npm i @nextrush/adapter-nextjs · yarn add @nextrush/adapter-nextjs · bun add @nextrush/adapter-nextjs[!NOTE] Already using
nextrush? Import fromnextrush/nextjsinstead — same package, re-exported as an optional peer. A functional-onlypnpm add nextrushnever resolves it ornext.
Quick start
Build the app in its own file, then mount it. Even a tiny API is two files, not one — the route file's only job is the bridge:
// src/server/app.ts
import { createApp, createRouter } from 'nextrush';
const app = createApp();
const api = createRouter();
api.get('/hello', (ctx) => ctx.json({ message: 'Hello Next.js!' }));
app.route('/api', api);
export { app };// app/api/[[...route]]/route.ts
import { app } from '@/server/app';
import { handle } from 'nextrush/nextjs';
export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = handle(app);No runtime export (Node is already the default; the Edge runtime is deprecated upstream), no
mount-prefix configuration. The route is mounted the same way any NextRush route is mounted
anywhere else: app.route('/api', router). See Project structure
below for how this splits further as the API grows.
Capabilities
Capabilities
- Seven-method export —
handle(app)returns{ GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS }in one call - Class/DI support — pass a factory (
() => Application | Promise<Application>) for apps needingawait registerModule(...)before use - Background work —
ctx.waitUntil()is wired to Next'safter()when available, and never throws even ifafter()itself does - Mount-mismatch diagnostic — in development, a mounted-prefix mistake logs an actionable hint instead of a bare 404
Developer experience
- Fully typed, zero
any - No configuration for the common path —
timeoutandonErrorare the only options, both pass-throughs to the underlying engine
Mental model
The request is never touched. handle() forwards it, unmodified, to the same execution engine
every edge/serverless target already uses:
Next.js route handler ──▶ handle() ──▶ createFetchHandler (unchanged) ──▶ your Application
│
└─ wires ctx.waitUntil() to after(), if availableRule: mount prefixes are the application's own (app.route(prefix, router)) — this package
never infers or strips one.
[!TIP] The full request lifecycle (Mermaid) is in
ARCHITECTURE.md.
Common tasks
Project structure for a real API
app.route(prefix, router) composes the same way regardless of file count — split by feature,
keep business logic out of route handlers, mount everything once in src/server/app.ts:
src/
├── server/
│ ├── app.ts # composes every feature router — the ONLY export route.ts imports
│ ├── routes/
│ │ ├── users.route.ts # HTTP concerns only: params, status, calling into services
│ │ └── posts.route.ts
│ └── services/
│ ├── users.service.ts # business logic, data access — no ctx, no Request/Response
│ └── posts.service.ts
app/
└── api/
└── [[...route]]/
└── route.ts # the bridge — nothing else lives here// src/server/services/users.service.ts — pure business logic, no framework types
export async function findUser(id: string) {
return db.user.findUnique({ where: { id } }); // whatever your data layer is
}// src/server/routes/users.route.ts — HTTP concerns only, delegates to the service
import { createRouter } from 'nextrush';
import { findUser } from '../services/users.service';
export const usersRouter = createRouter();
usersRouter.get('/:id', async (ctx) => {
const user = await findUser(ctx.params.id);
if (!user) return ctx.throw(404, 'User not found');
ctx.json(user);
});// src/server/app.ts — the one place every router gets mounted
import { createApp } from 'nextrush';
import { usersRouter } from './routes/users.route';
import { postsRouter } from './routes/posts.route';
const app = createApp();
app.route('/api/users', usersRouter);
app.route('/api/posts', postsRouter);
export { app };// app/api/[[...route]]/route.ts — unchanged no matter how large the app grows
import { app } from '@/server/app';
import { handle } from 'nextrush/nextjs';
export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = handle(app);This is the same shape Hono popularized: routers are plain, composable
modules; the route file is a one-line bridge; and the app never knows or cares that it's running
inside Next.js — the identical src/server/app.ts also boots under listen(),
@nextrush/adapter-edge, or any other adapter, unchanged.
Mounting under a prefix
const app = createApp();
const api = createRouter();
api.get('/users', (ctx) => ctx.json(listUsers()));
app.route('/api', api); // requests to /api/users matchClass-based apps (DI, modules, guards)
import { registerModule } from 'nextrush/class';
import { AppModule } from '@/server/app.module';
export const { GET, POST } = handle(async () => {
const app = createApp();
await registerModule(app, AppModule, { prefix: '/api' });
return app;
});Building the app once across route-file reloads (dev HMR)
const g = globalThis as unknown as { __nextrushApp?: Application };
const app = (g.__nextrushApp ??= buildApp());API overview
| Export | Signature | Since | Stability | Description |
| ------ | --------- | ----- | --------- | ----------- |
| handle | (app: AppSource, options?: NextHandlerOptions) => NextRouteHandlers | 1.0.0-beta.0 | Beta 🚧 | Mounts an app, returns all seven route-handler exports |
| type AppSource | — | 1.0.0-beta.0 | Beta 🚧 | An Application, or a (possibly async) factory producing one |
| type NextRouteHandlers | — | 1.0.0-beta.0 | Beta 🚧 | The seven exported handler functions |
| type NextHandlerOptions | — | 1.0.0-beta.0 | Beta 🚧 | { timeout?, onError? } |
| type NextRouteContext | — | 1.0.0-beta.0 | Beta 🚧 | The structural shape of Next's second handler argument |
Options
| Option | Type | Required | Default | Security-sensitive | Description |
| ------ | ---- | -------- | ------- | ------------------ | ----------- |
| timeout | number | No | the edge engine's default (25 000 ms) | — | Per-request timeout in ms, raced to a 504 |
| onError | (error, ctx) => Response \| Promise<Response> | No | the engine's default 500 handler | — | Custom error → Response mapping |
Compatibility
Requirements
| Requirement | Version |
| ----------- | ------- |
| NextRush | 4.x |
| Next.js | >=14.0.0 (App Router only) |
| Node.js | >=22 |
| TypeScript | >=5.x |
Runtimes
| Runtime | Supported | Notes |
| ------- | --------- | ----- |
| Node.js >=22 | ✅ | Next's default runtime |
| Bun / Deno / Cloudflare (OpenNext) | ✅ / ✅ / ✅ | The package imports no runtime-specific API — pinned by packages/adapters/conformance's nextjs driver |
Integration
- Peer dependencies:
next >=14.0.0(optional) - Works with: any NextRush middleware/class-runtime package — the mounted app is an ordinary
Application - Incompatible with: the Pages Router (not supported — see ARCHITECTURE.md)
[!IMPORTANT] NextRush is ESM-only, permanently — no CommonJS build. On Node
>=22, CJS consumers canrequire()this ESM package natively. See the Module Format Policy.
Troubleshooting
Cause: the mount prefix in your route file's folder doesn't match your app.route() call.
Fix: in development, check the server log — a mismatch logs an actionable hint naming both
halves and the exact app.route() call to add.
// app/api/[[...route]]/route.ts mounts at /api — the app must declare it too:
app.route('/api', api); // not app.route('/', api)Cause: Next 14 statically caches GET route handlers by default (this changed to dynamic-by-default in 15.0.0-RC).
Fix: add one export.
export const dynamic = 'force-dynamic';Cause: that's exactly the problem this package exists to solve — createFetchHandler's second
parameter (EdgeExecutionContext) isn't assignable from Next's route context.
Fix: use handle(app) instead of createFetchHandler directly.
FAQ
Can I use this without nextrush?
Yes — install @nextrush/adapter-nextjs directly and pass any @nextrush/core Application.
Why ESM-only? See the Module Format Policy.
Does it work on Bun / Deno / Edge?
Yes — the package is fully Web-standard (no node:*, process, or runtime global), so it runs
on every host Next.js itself runs on. Verified by the conformance suite's nextjs driver.
Does it support the Pages Router? No, deliberately — see ARCHITECTURE.md for why.
Package relationships
depends on @nextrush/adapter-edge
@nextrush/adapter-nextjs ──────────────▶
often used with @nextrush/class (for DI/module apps)
usually used next @nextrush/openapi (for generated API docs)- Depends on:
@nextrush/adapter-edge— reuses its fetch engine, unmodified - Often used with:
@nextrush/class— for module/DI-based apps via the factory form - Usually used next:
@nextrush/openapi— if you want generated API docs for the mounted routes - Alternative: none for Next.js specifically — for other platforms, see
@nextrush/adapter-edge(Cloudflare/Vercel/Netlify) or@nextrush/adapter-serverless(Lambda/GCF/Azure)
Architecture
Maintaining or contributing to this package? The internal design — why the request is never
rewritten, the mount-mismatch diagnostic, and the Pages Router non-goal — is in
ARCHITECTURE.md. Design history:
RFC-024,
ADR-0014.
Resources
- 📖 Learn — Documentation · Architecture · RFCs
- 📝 Changelog — CHANGELOG.md
- 🐛 Report an issue — GitHub Issues
- 🤝 Contribute — CONTRIBUTING.md
MIT © Tanzim Hossain
