Next.js
Mount a real NextRush application inside a Next.js App Router route handler — one function, seven HTTP methods, zero request rewriting.
Integration · Next.js
Run a real NextRush application inside a Next.js route. No rewrite, no proxy, no wrapper —
one function (handle()) bridges your entire NextRush app into route.ts, returning all seven
HTTP method exports. The request is never touched.
- Next.js route
- handle()
- NextRush engine
- Your app
- Response
Not sure this is what you need?
This page is for mounting NextRush inside an existing or new Next.js app. If you're choosing a JS runtime for a standalone NextRush app (no Next.js involved), see the runtime decision guide instead.
Why this exists
Traditional Next.js API routes put everything in one file — logic, middleware, validation, error handling — per endpoint. Seven endpoints means seven files, each reimplementing the same patterns:
Traditional Next API NextRush inside Next.js
app/api/users/route.ts app/api/[[...route]]/route.ts
GET → logic handle(app) ← one line, seven exports
POST → logic
app/api/posts/route.ts src/server/app.ts
GET → logic createApp() + createRouter()
POST → logic middleware, validation, error handling
app/api/comments/route.ts — grows without touching route.ts
GET → logic
POST → logicWith NextRush, route.ts never grows. Your routers, middleware, and services live in
src/server/ and compose the same way they would under listen().
Before you begin
- ✓ A terminal and a package manager
- ✓ An existing Next.js App Router project (14, 15, or 16) — or a fresh one from
create-next-app - ✓ You already know how to run
next dev - ✓ ~15 minutes
Install NextRush and the Next.js adapter
- Why this matters: like every other runtime target, the
nextrushmeta-package does not bundle the Next.js bridge by default.
$ pnpm add nextrush @nextrush/adapter-nextjs
Behind the scenes:
createAppandcreateRoutercome fromnextrush— identical to every other runtimehandleis re-exported asnextrush/nextjsonce@nextrush/adapter-nextjsis installed- Works standalone too —
@nextrush/adapter-nextjsaccepts any@nextrush/coreApplicationdirectly
Build the app in its own file
- Why this matters: the single most common mistake is writing the whole API inside
route.ts. Build the application as an ordinary NextRush app first — the route file's only job is the bridge.
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 };Behind the scenes: this is a completely ordinary NextRush application — nothing about it knows it's about to run inside Next.js. The same file boots unchanged under listen(), @nextrush/adapter-edge, or any other adapter.
Mount it with handle()
- Why this matters: this is the entire bridge. One import, one function call, seven exports — the route file never grows past this.
import { app } from '@/server/app';
import { handle } from 'nextrush/nextjs';
export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } = handle(app);The [[...route]] catch-all segment lets one route file answer every path under app/api/.
The mount prefix (/api) is decided by app.route(), not by this file or by Next's routing.
Don't use createFetchHandler directly
export const GET = createFetchHandler(app) fails next build's type check — Next's route
context type isn't assignable. Use handle() instead.
Run it and confirm
next devExpected result — curl http://localhost:3000/api/hello returns {"message": "Hello Next.js!"}
What's genuinely different in Next.js
Your server is running. Before moving on, understand what's different — and what isn't.
Same: the Context API (ctx.json, ctx.params, ctx.query, ctx.set), middleware, error
handling — everything behaves exactly as it does under listen(). The request is never rewritten:
ctx.path, ctx.url, and ctx.raw.req inside your handler are the true, original request,
exactly as they'd be under listen().
Different:
- No
listen()— Next.js owns the process.handle()wiresctx.waitUntil()to Next'safter()API automatically when available. Without this adapter,ctx.waitUntil()silently no-ops under a hand-rolled bridge, because Next supplies no execution context of its own the way Cloudflare or Vercel Edge do. - Next.js 14 caching —
GEThandlers are cached statically by default (changed in 15+). Addexport const dynamic = 'force-dynamic'if needed.
Middleware in Next.js context
NextRush middleware runs inside the NextRush engine, after Next.js has already routed to route.ts. This means:
- Next.js middleware (
middleware.ts) runs beforehandle()— it sees the raw request first - NextRush middleware (
app.use(...)) runs afterhandle()— it sees the request through the NextRush Context API - They don't interfere with each other — each operates in its own layer
Request → Next.js middleware → route.ts → handle() → NextRush middleware → Router → ResponseThis is the same layering every runtime adapter uses — handle() doesn't change the order, it just bridges the request into the NextRush engine.
The same middleware works everywhere. NextRush ships @nextrush/logger — it runs identically
under listen() and under Next.js. Nothing changes:
import { createApp, createRouter } from 'nextrush';
import { logger } from '@nextrush/logger';
const app = createApp();
app.use(logger()); // ← runs on every request, same under listen() and Next.js
const api = createRouter();
api.get('/hello', (ctx) => ctx.json({ message: 'Hello Next.js!' }));
app.route('/api', api);
export { app };The logger() middleware doesn't know it's running inside Next.js. It uses ctx.method and ctx.path — both come from the Context API, which reads the original request unmodified. The same file boots under listen(), @nextrush/adapter-edge, or handle().
Common middleware patterns
NextRush ships middleware packages for the most common needs. These work identically under listen() and Next.js — expand each to see the code:
Auth — protect routes with a token check
import type { Context, NextFunction } from 'nextrush';
export async function auth(ctx: Context, next: NextFunction) {
const token = ctx.headers.authorization?.replace('Bearer ', '');
if (!token) {
ctx.throw(401, 'Missing authorization token');
return;
}
const user = await verifyToken(token);
if (!user) {
ctx.throw(401, 'Invalid or expired token');
return;
}
ctx.state.user = user; // available in every downstream handler
await next();
}Apply it to specific routes or globally:
// Global — every route requires auth:
app.use(auth);
// Or per-router — only these routes require auth:
const protectedRouter = createRouter();
protectedRouter.use(auth);
protectedRouter.get('/me', (ctx) => ctx.json(ctx.state.user));The same auth middleware works under listen() — the cookie/header forwarding means Next.js session tokens reach your handler unchanged.
Error handling — catch errors and return consistent JSON
NextRush's error handling works identically inside Next.js. ctx.throw() produces a JSON error response with the correct status code:
import type { Context, NextFunction } from 'nextrush';
export async function errorHandler(ctx: Context, next: NextFunction) {
try {
await next();
} catch (err: any) {
const status = err.status || err.statusCode || 500;
ctx.status = status;
ctx.json({
error: err.message || 'Internal server error',
status,
});
}
}app.use(errorHandler); // register FIRST — wraps everything
// Now any handler can throw:
api.get('/users/:id', async (ctx) => {
const user = await findUser(ctx.params.id);
if (!user) ctx.throw(404, 'User not found'); // → { error: "User not found", status: 404 }
ctx.json(user);
});CORS — allow cross-origin requests from your frontend
NextRush ships @nextrush/cors with presets for common scenarios. Don't write CORS headers by hand — the package handles preflight, Vary headers, and origin validation:
import { cors, devCors, strictCors } from '@nextrush/cors';
// Development — allows localhost origins:
app.use(devCors());
// Production — explicit origins:
app.use(cors({ origin: ['https://my-frontend.com'] }));
// Strict — credentials + specific origins + max-age:
app.use(strictCors({ origins: ['https://my-frontend.com'], credentials: true }));Available presets: simpleCors(), devCors(), strictCors(), internalCors(), staticAssetsCors().
Validation — reject bad input before it hits your handler
NextRush ships @nextrush/validation for schema-based input validation. It integrates with the Context API so validated data is typed:
import { validate } from '@nextrush/validation';
const CreateUser = {
body: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
};
usersRouter.post('/users', validate(CreateUser), async (ctx) => {
// ctx.body is validated and typed
const user = await createUser(ctx.body);
ctx.status(201).json(user);
});Rate limiting — protect endpoints from abuse
NextRush ships @nextrush/rate-limit to protect endpoints from abuse:
import { rateLimit } from '@nextrush/rate-limit';
// Global — 100 requests per minute per IP:
app.use(rateLimit({ windowMs: 60_000, max: 100 }));
// Per-route — stricter for auth endpoints:
authRouter.post('/login', rateLimit({ windowMs: 15 * 60_000, max: 5 }), loginHandler);Works identically under listen() and Next.js — the adapter forwards the real client IP via ctx.ip.
Next.js integration complete — a real NextRush app running inside Next.js. The route file will never need to change.
Checkpoint
Confirm the route answers:
curl -i http://localhost:3000/api/hello
# → HTTP/1.1 200 OK
# → {"message":"Hello Next.js!"}Your project now looks like:
Baseline complete — the bridge is working. The route file will never need to change no matter how large src/server/app.ts grows.
Mental model
The request is never touched. handle() forwards it, unmodified, through the same
request-handling engine every edge/serverless target already uses:
Rule: mount prefixes belong to your application (app.route(prefix, router)) — this package
never infers or strips one. That's why "Build the app"'s app.route('/api', api) and "Mount it"'s folder
(app/api/[[...route]]/) both had to agree.
Growing past one route
A real API is more than one route file. Split by feature, keep business logic out of route
handlers, mount everything once in src/server/app.ts:
// Pure business logic — no ctx, no Request/Response.
export async function findUser(id: string) {
return db.user.findUnique({ where: { id } });
}// 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);
});// 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 };route.ts stays exactly as it was in "Mount it with handle()" — the identical src/server/app.ts also boots
unchanged under listen() or any other adapter.
Advanced: class-based apps (DI, modules, guards)
handle() also accepts a factory instead of a plain app — useful when your app needs an async
boot step like await registerModule(...):
import { Module, Controller, Get, Service } from 'nextrush/class';
@Service()
class UserService {
findAll() { return [{ id: 1, name: 'Alice' }]; }
}
@Controller('/users')
class UserController {
constructor(private users: UserService) {}
@Get() findAll() { return this.users.findAll(); }
}
@Module({ controllers: [UserController], providers: [UserService] })
export class AppModule {}import { createApp } from 'nextrush';
import { registerModule } from 'nextrush/class';
import { handle } from 'nextrush/nextjs';
import { AppModule } from '@/server/app.module';
export const { GET, POST } = handle(async () => {
const app = createApp();
await registerModule(app, AppModule, { prefix: '/api' });
return app;
});Decorators need one tsconfig change — and this path isn't build-verified yet
@nextrush/class compiles with TypeScript's legacy decorators
(experimentalDecorators + emitDecoratorMetadata), the same ones reflect-metadata needs.
Next.js's SWC compiler does support this — it reads both flags from your tsconfig.json and
maps them to its own decorator transform — but you have to turn them on yourself; a fresh
create-next-app project doesn't set them:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Being honest about the rest: SWC's decorator transform is its own reimplementation of
TypeScript's, not TypeScript's actual compiler — and unlike the plain functional path (verified
against three real Next 14/15/16 next builds), this class-based path has not yet been run
through an equivalent real build in this project's own verification. It should work with the
tsconfig.json change above; treat it as reasonably likely rather than proven until you've
confirmed next build succeeds for your own project.
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| 404 for every route | Mount prefix in route.ts folder doesn't match app.route() call | Check server log — it names both halves. app.route('/api', api) must match app/api/[[...route]]/ |
GET returns same response (Next.js 14) | Next 14 statically caches GET handlers by default | Add export const dynamic = 'force-dynamic' to route.ts |
createFetchHandler(app) fails next build | Next's route context type isn't assignable to the edge adapter's parameter | Use handle(app) instead — it solves exactly this mismatch |
Debugging — see what's happening inside the bridge
Enable NextRush's built-in logger to see every request as it passes through the engine:
import { createApp } from 'nextrush';
const app = createApp({ debug: true }); // logs middleware chain, routing, and timingInspect the request in your handler to confirm it's the original, unmodified request:
api.get('/debug', (ctx) => {
ctx.json({
path: ctx.path, // the real URL path, not a rewritten one
method: ctx.method,
headers: Object.fromEntries(Object.entries(ctx.headers)),
runtime: ctx.runtime, // 'node' under Next.js
});
});Check the mount prefix — the #1 source of 404s. The server log prints an actionable hint when app.route() and the folder disagree. In development, look for:
[NextRush] Mount prefix mismatch: app.route('/api', ...) but the route file is at app/api/[[...route]]/route.tsTest src/server/app.ts directly — since it's a plain NextRush application, you can test it without Next.js:
import { app } from '../app';
test('GET /api/hello returns greeting', async () => {
const res = await app.inject({ method: 'GET', path: '/api/hello' });
expect(res.status).toBe(200);
expect(res.json()).toEqual({ message: 'Hello Next.js!' });
});Next.js 14 caching — why GET returns stale data
Next.js 14 statically caches GET route handlers by default (changed to dynamic-by-default in 15.0.0-RC). This means your NextRush handler runs once, and the response is cached for subsequent requests.
Fix: add one export to route.ts:
export const dynamic = 'force-dynamic';This tells Next.js to run the handler on every request instead of caching it. The adapter itself doesn't control this — it's Next's own caching behavior.
FAQ
Does create-nextrush scaffold a Next.js project?
No — use Next's own create-next-app. This package documents how to wire NextRush into it.
Can I use this without the nextrush meta-package?
Yes — install @nextrush/adapter-nextjs directly and pass any @nextrush/core Application to handle().
Does it work on Bun, Deno, or Cloudflare (via OpenNext)?
Yes — the package is fully Web-standard, no node:* or runtime globals.
Does Next.js support decorators?
Its SWC compiler does — enable experimentalDecorators/emitDecoratorMetadata in tsconfig.json. See the class-based section above.
Does it support the Pages Router?
No — App Router only. The Pages Router hands (req, res), not a Request.
How do I test the mounted app?
Test src/server/app.ts the same way you'd test any NextRush app — it's a plain Application
that doesn't know about Next.js. Import it, call app.inject() (or your preferred test helper),
and assert on the response. The bridge in route.ts is one line and doesn't need its own test —
Next's own routing handles that.
How do I share auth between Next.js pages and the NextRush API?
The NextRush app runs inside the same Next.js process, so it shares the same environment and
cookies. Read the session from ctx.headers (the cookie header is forwarded unmodified) or pass
the session token from your Next.js middleware via ctx.state. The adapter never strips or
rewrites headers — what Next.js sends is what your NextRush handler sees.
What you learned
- ✓ One
handle()bridges your entire NextRush app — seven exports, zero configuration - ✓ Mount prefixes belong to your app (
app.route(prefix, router)) — keep them in sync with the folder - ✓ A real API is multiple files — routers, services, and
app.tsgrow independently of the bridge - ✓
ctx.waitUntil()needs this adapter to work under Next.js — without it, background work silently no-ops - ✓ Next.js middleware runs before
handle(); NextRush middleware runs after — they don't interfere - ✓ The same
src/server/app.tsboots unchanged underlisten()or any other adapter
Next steps
🚀 Build a Task API — recommended · ~20 min
Not using Next.js? Go through the same routing/middleware/error-handling ground on a standalone NextRush app. Start the tutorial →
@nextrush/adapter-nextjs reference
The full API surface — handle(), NextHandlerOptions, AppSource, and the types Next's route context requires.
Class-based apps (DI, modules, guards)
Build the mounted app with @Controller, @Service, and registerModule.
Continue learning
Serverless
Install NextRush on AWS Lambda, Google Cloud Functions, or Azure Functions, and see what warm-instance reuse and per-invocation timeouts mean for your app.
Build a Task API
Build a Task API from an empty folder to a running server, and learn the request pipeline every NextRush app shares — routing, middleware order, context, and errors — along the way.