@fluojs/platform-nextjs
v2.0.0
Published
Fluo HTTP platform and decorator compiler integration for Next.js App Router and Pages Router.
Maintainers
Readme
@fluojs/platform-nextjs
Connect an existing Fluo backend to Next.js App Router Route Handlers or Pages Router API Routes without replacing Fluo's decorators, modules, factory, or dependency injection.
Table of Contents
- Installation
- Shared Setup
- App Router
- HEAD Routing
- Pages Router
- FluoFactory Integration
- Pipeline Compatibility
- Decorator Compiler Wiring
- Lifecycle
- Process-local application accessor
- Options
- Runtime Contract
- Public API
- API Migration
Installation
Supported hosts are Next.js 16.x (peer >=16.0.0 <17) on Node.js
>=24.11.0 <27 (the Babel 8 compiler toolchain floor), with @fluojs/runtime
>=3.0.0 <4. Next.js Edge Runtime is
not supported. See the Node.js support contract.
If the application already uses Fluo, add only the adapter:
pnpm add @fluojs/platform-nextjsFor a new Fluo backend inside an existing Next.js application, add the Fluo packages used by application source as direct dependencies too:
pnpm add \
@fluojs/core \
@fluojs/http \
@fluojs/runtime \
@fluojs/platform-nextjsNo separate Babel package, loader package, or handwritten decorator options are required. The adapter ships the Next-specific decorator loader.
Shared Setup
Enable the packaged decorator transform while preserving the rest of the existing Next configuration:
// next.config.ts
import { withFluoNextBackend } from '@fluojs/platform-nextjs/next-config';
export default withFluoNextBackend({});Keep the existing decorated Fluo module unchanged:
// src/app.module.ts
import { Module } from '@fluojs/core';
import {
Controller,
Get,
HttpCode,
Post,
type RequestContext,
} from '@fluojs/http';
@Controller('/api')
class ApiController {
@Get('/health')
health() {
return { status: 'ok' };
}
@Post('/echo')
@HttpCode(201)
echo(_input: undefined, context: RequestContext) {
return { body: context.request.body };
}
}
@Module({ controllers: [ApiController] })
export class AppModule {}Create the adapter, pass it to FluoFactory, and start the application exactly
like every other Fluo HTTP platform:
// src/backend.ts
import { NextHttpApplicationAdapter } from '@fluojs/platform-nextjs';
import { FluoFactory } from '@fluojs/runtime';
import { AppModule } from './app.module';
export const nextAdapter = NextHttpApplicationAdapter.create();
export const app = await FluoFactory.create(AppModule, {
adapter: nextAdapter,
});
await app.listen();App Router
Add one optional catch-all Route Handler:
// app/api/[[...path]]/route.ts
import { createNextAppRouterHandler } from '@fluojs/platform-nextjs/app-router';
export const {
GET,
POST,
PUT,
PATCH,
DELETE,
HEAD,
OPTIONS,
} = createNextAppRouterHandler(() =>
import('../../../src/backend').then(({ nextAdapter }) => nextAdapter),
);Next.js still discovers one filesystem route. Fluo owns decorator metadata,
route matching, module bootstrap, dependency injection, request scope, body
parsing, errors, and controller dispatch behind that facade. Route Handlers
default to the Node.js runtime that Fluo backends require, so no runtime
override is needed.
HEAD Routing
Opt in at adapter construction when a GET-only Fluo route should answer Next's
automatic HEAD as well as an explicit HEAD export:
import { NextHttpApplicationAdapter } from '@fluojs/platform-nextjs';
export const nextAdapter = NextHttpApplicationAdapter.create({
headRouting: 'explicit-or-get',
});Omission preserves existing method matching: a GET-only Fluo route does not
implicitly match HEAD. With this option, the shared HTTP matcher selects one
eligible explicit @Head route, otherwise @All, otherwise @Get. Method
priority precedes path specificity across those groups; normal static/parameter
and version rules apply within each group. ALL is a method wildcard, not a
path wildcard: Fluo catch-all path grammar remains unsupported.
Selection happens once before module middleware, guards, and the controller. A route miss returns 404. A selected handler's 404 is final and never triggers another dispatch. Application middleware, module middleware, guards, and controller each run at most once, subject to normal short-circuit behavior.
The normalized request and native Request retain HEAD, including inside
middleware, guards, version extraction, conditional resolvers, and handlers.
The selected descriptor still identifies its declared method, such as GET.
Existing conditional-request and byte-range status/header rules therefore
remain active. The adapter returns a null body for every opted-in HEAD response,
including direct sends, route misses, errors, and unavailable/closed 503s,
preserving status, status text, representation headers, and independent cookies.
It does not invent a content length when the selected response did not supply one.
Active response streams are cancelled, then the dispatch lifecycle, iterator
cleanup, and request-scope disposal are awaited. Stream sources must cooperate
with cancellation and settle their iterator return(); cleanup failures retain
the dispatcher's observer/logging policy without a GET retry or replacement of
already committed metadata. Application-owned resources that never enter a
response stream remain application-owned. Prefer a createByteRangeResponse
stream factory when HEAD must avoid opening a byte source altogether.
Migration: remove the application wrapper that reconstructs a HEAD request
as GET and discards its body. Keep the standard
createNextAppRouterHandler(loadAdapter) facade. Export only GET to let Next
auto-HEAD call it, or export both GET and HEAD; both use the same policy.
Pages Router uses the same adapter option. Update method-based application
branches that previously observed GET to recognize the original HEAD. No
configuration or behavior change is required for consumers that leave this
option unset.
Evidence: head-routing.test.ts,
head-routing-public-types.test.ts,
and the packaged Next 16 production E2E, which compares
auto-HEAD, direct App Router HEAD, and Pages Router HEAD over real HTTP.
Pages Router
Create one optional catch-all API Route. The exported config disables Next's built-in body parser so Fluo receives the original request stream.
// pages/api/[[...path]].ts
import {
createNextPagesRouterHandler,
type NextPagesRouterConfig,
} from '@fluojs/platform-nextjs/pages-router';
export default createNextPagesRouterHandler(() =>
import('../../src/backend').then(({ nextAdapter }) => nextAdapter),
);
export const config = {
api: {
bodyParser: false,
},
} satisfies NextPagesRouterConfig;The Pages bridge converts the raw IncomingMessage into a Web request, keeps
Fluo body parsing and size limits authoritative, and streams the Web response
back through ServerResponse.
Input is read on demand rather than queued ahead of the Fluo parser. An oversized upload can receive HTTP 413 before it finishes; only after the response is sent does the bridge discard the remaining input, without taking ownership of the socket. Raw-body capture preserves the original bytes.
Client disconnects propagate to context.request.signal and cancel response
stream reads. A disconnect during lazy bootstrap ends that request without
dispatching it or cancelling the shared backend startup.
FluoFactory Integration
The Next adapter follows the same platform contract as Fastify and the other
Fluo HTTP adapters. Application code creates the adapter, supplies it to
FluoFactory.create(), and calls app.listen(). The runtime constructs the
application and dispatcher, then attaches the dispatcher through
NextHttpApplicationAdapter.listen().
The Next adapter does not create the application and does not open a socket.
It dispatches through instance fetch(request) after the dispatcher is attached,
while Next.js continues to own the HTTP server. Use the lazy facade above for
ordinary App route integration.
Route facades dynamically import src/backend.ts on the first request.
Import completion includes its top-level FluoFactory.create() and
app.listen(), so handlers cannot observe an unbound adapter. next build
compiles the backend chunk without executing that bootstrap.
Pipeline Compatibility
Both router bridges normalize requests into the same Fluo dispatcher used by the other HTTP platforms. The full application pipeline remains active:
- request
Cookieheaders and DTO@FromCookiebinding - response headers and multiple independent
Set-Cookievalues - application and module middleware
- guards, interceptors, exception filters, and request observers
RequestDtobinding, global converters, and field-level@Convert- request scope, body parsing, multipart handling, and raw body preservation
Fluo does not expose Nest's Pipe abstraction under that name. The equivalent
input transformation contract is a global Converter supplied to
FluoFactory.create() or a field-level @Convert on a RequestDto.
Next middleware may still rewrite request or response headers before or after
the Route Handler. Fluo controllers read the cookie header that reaches their
handler through @FromCookie or RequestContext, rather than Next's
component-oriented cookies() helper.
App Router and Pages Router are both supported in one hybrid Next application.
Because Next emits them as separate server route bundles, enabling both
catch-alls with the default per-closure lazy recipe above creates a separate
Fluo application per bundle. Sharing one application Promise requires every
shared consumer to explicitly use defineNextApplication with the same key
and execute within the same JS global (globalThis). See the
opt-in sharing contract.
Different processes, workers, serverless instances, and JS globals remain isolated.
Use one catch-all during migration to avoid per-bundle bootstrap without explicit
sharing, or host Fluo separately when deterministic single-instance ownership matters.
Decorator Compiler Wiring
withFluoNextBackend() adds the adapter's packaged loader to the server-side
Turbopack *.ts rule for application files, excluding browser and dependency
files. The loader applies the same Babel TC39 decorators
2023-11 transform used by @fluojs/vite and returns JavaScript to Turbopack.
The removed allowDeclareFields preset option is never passed; declaration-only
and decorated fields keep their handling with Babel 8.
The packaged Next adapter declares Babel 8 dependencies
(@babel/core ^8.0.6, @babel/plugin-proposal-decorators ^8.0.2, and
@babel/preset-typescript ^8.0.1).
The packaged compiler integration supports Turbopack only, not webpack.
Keep decorated backend declarations in .ts files; the helper does not add
a .tsx rule. See the compiler tooling table.
The Vite plugin itself cannot be inserted into Next.js because Vite and Turbopack have different plugin contracts. The shared compiler recipe is the reusable part; the adapter provides the Next-specific loader and config object.
Existing turbopack options, aliases, and rules are preserved. When an
application already has a *.ts rule, the Fluo decorator rule is appended.
The helper returns a new configuration object and does not mutate its input.
Applications with both a backend and client stores can declare compiler scope
and output path preservation in the second argument. This example assumes
decorated declarations and DTOs live under src/backend/.
import {
type FluoNextBackendOptions,
withFluoNextBackend,
} from '@fluojs/platform-nextjs/next-config';
const backend = {
include: 'src/backend/**',
exclude: '**/*.test.ts',
preserveModulePaths: true,
} satisfies FluoNextBackendOptions;
export default withFluoNextBackend({}, backend);| Option | Default | Contract |
| --- | --- | --- |
| include | Omitted | Restrict eligible paths with a Turbopack path glob or RegExp. |
| exclude | Omitted | Exclude paths with a Turbopack path glob or RegExp. Both it and include apply. |
| preserveModulePaths | false | When true, omit as: '*.js' to retain the original .ts module path. |
Turbopack applies path conditions to project-relative paths under
turbopack.root (the Next project by default). The helper does not evaluate
patterns itself. include/exclude augment the existing
server/application/content conditions; they cannot re-include browser or foreign
dependency files. The existing content condition matches @ followed by a word
character, so imports and comments can match without decorators. Client-module
SSR is not a browser compilation; declare its boundary with include or
exclude. The helper does not change the scope of existing user rules.
Omitting the options preserves the existing broad *.ts selection and
as: '*.js' output rule. With path preservation, the same Babel JavaScript
output is parsed under its original TypeScript filename; no version-specific
type: 'ecmascript' option is forced. Include every backend declaration and DTO:
decorated files outside the scope may not work without their own compiler
configuration. Compiler errors propagate through Next dev/build.
In the real Next 16.3.4 fixture, the legacy helper resolves the client store SSR
import as ./store.ts.js, failing dev GET / and the production build.
Scope restriction and path preservation are independent opt-ins. See the
real Next fixture for reproduction and version-specific
verification commands within the supported range.
Lifecycle
The backend module performs ordinary Fluo bootstrap once when the first route request imports it:
const nextAdapter = NextHttpApplicationAdapter.create();
const app = await FluoFactory.create(AppModule, {
adapter: nextAdapter,
});
await app.listen();The route handler caches one loader promise, so concurrent first requests share
the same backend import and startup. Application code owns the normal Fluo
Application. Calling
Application.close() runs the standard shutdown lifecycle and closes the
adapter. Requests sent afterward receive HTTP 503 problem JSON.
await app.close('manual shutdown');Next.js owns process startup and shutdown. The package does not register process signal handlers or automatically create a second application after explicit close.
Process-local application accessor
Existing lazy handlers retain one Promise per closure. Choose the root export
defineNextApplication({ key, load }) only when RSC, auth callbacks, and Route
Handlers must use one application in the same JS process and globalThis.
The Node/Next/runtime and decorator compiler prerequisites in Shared Setup remain.
| Field | Contract |
| --- | --- |
| Input | Fixed application-owned string key, argument-free async load |
| Default | Opt-in; existing lazy helpers are unchanged. Definition does not load |
| Output/order | The first invocation claims the key and stores a Promise before running load. Calls with that key return the exact same Promise |
| Failures | Sync throws and async rejections remain cached. Redefining with another loader retains the first result; there is no automatic retry |
| Ownership | The caller owns bootstrap/listen failure cleanup, consumer drain, app.close() or context/container disposal. The accessor registers no signals |
| Reload | HMR evaluation does not replace a live graph. After close, the Promise still yields the closed graph. No reset/evict API; restart the host to apply a new bootstrap |
| Isolation | Different keys, processes, workers, serverless instances, and JS globals are isolated. This is not a distributed singleton or a guarantee of one connection |
| Data | Do not capture request/actor/session data in key or load. Only application resource Promises belong in the global cache; pass request data as method arguments or through request scopes |
| Type/identity | All loaders using one key must agree on application type/contract. There is no runtime shape validation or class-name identity inference |
This file reuses src/backend.ts above without each bundle implementing its own
global Promise. Route every shared consumer through the accessor; mixing it with
direct backend imports can bootstrap outside this cache.
// src/application.ts
import { defineNextApplication } from '@fluojs/platform-nextjs';
export const getApplication = defineNextApplication({
key: 'my-blog/application/v1',
load: () => import('./backend'),
});The route facade obtains its adapter from the same graph.
// app/api/[[...path]]/route.ts
import { createNextAppRouterHandler } from '@fluojs/platform-nextjs/app-router';
import { getApplication } from '../../../src/application';
export const { GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS } =
createNextAppRouterHandler(() => getApplication().then(({ nextAdapter }) => nextAdapter));Keep service contracts separate from runtime class imports. Import the following
module from the existing AppModule. The owning module registers the provider and
alias once each; tokens injected from other modules must be exported.
// src/posts-contract.ts
import { publicToken } from '@fluojs/core';
export interface PostsReader { title(): string }
export const POSTS = publicToken<PostsReader>('my-blog/posts/v1');// src/posts.module.ts
import { Module } from '@fluojs/core';
import { POSTS, type PostsReader } from './posts-contract';
class PostsService implements PostsReader {
title() { return 'FluoBlog'; }
}
@Module({
providers: [PostsService, { provide: POSTS, useExisting: PostsService }],
exports: [POSTS],
})
export class PostsModule {}Using this application function from RSC or auth callbacks also removes repeated
container.resolve calls and runtime class imports.
// src/posts.ts
import { getApplication } from './application';
import { POSTS } from './posts-contract';
export async function getPosts() {
return (await getApplication()).app.container.resolve(POSTS); // Promise<PostsReader>
}Add aliases/exports only for tokens needing a public boundary. Existing class
token providers are not replaced, and distinct constructors with identical names
are not merged. Symbol.for(...) + useExisting + resolve<PostsReader>(...)
remains the valid manual recipe; publicToken only adds inference for that symbol.
Every declaration of one namespace must agree on the service contract.
Cyclic initialization in which load awaits its own accessor is unsupported.
src/application-accessor.test.ts covers concurrency, failure, module evaluation,
and pending/failed/successful close. src/application-public-types.test.ts compiles
emitted public declarations. e2e/next.test.mjs exercises separate RSC/auth/route
evaluations, overlapping initialization, key/process isolation, request-data
separation, and retained close/failure in an actual Next production build.
Options
const nextAdapter = NextHttpApplicationAdapter.create({
maxBodySize: 1_048_576,
rawBody: true,
});
const app = await FluoFactory.create(AppModule, {
adapter: nextAdapter,
// Ordinary Fluo CreateApplicationOptions stay here.
});
await app.listen();maxBodySize: non-negative maximum request body size in bytesrawBody: preserve parsed request bytes oncontext.request.rawBodyheadRouting: optional'explicit-or-get'selection and bodyless HEAD lifecycle; see HEAD Routing- Fluo runtime options remain ordinary
FluoFactory.create()options
Runtime Contract
- App Router Route Handlers and Pages Router API Routes
- Next.js 16.x (peer
>=16.0.0 <17) - Node.js
>=24.11.0 <27hosting only; no Edge Runtime @fluojs/runtimepeer>=3.0.0 <4withFluoNextBackend()innext.config.tsfor Turbopack only- request-lazy dynamic backend module import
- App Router exports:
GET,POST,PUT,PATCH,DELETE,HEAD, andOPTIONS - Pages Router default
NextApiHandlerexport withbodyParser: false - HTTP method availability remains bounded by Next.js routing; the adapter
does not add
QUERYor other custom method support beyond that host boundary - Web-standard
RequestandResponse - No raw WebSocket upgrade seam
- No custom server or process signal ownership
- Separate lazy applications per catch-all bundle with the default per-closure
recipe. Consumers explicitly using
defineNextApplicationwith the same key share only within the same JS global (globalThis), under the opt-in contract. Different processes, workers, serverless instances, and JS globals remain isolated
Use a Fluo Node or Fastify platform adapter when the application requires raw Node.js transport ownership, WebSocket upgrades, or an independently hosted backend.
Public API
| Public import owner | Exports and responsibility |
| --- | --- |
| @fluojs/platform-nextjs | NextHttpApplicationAdapter.create(options) creates an independent, unbound adapter. NextAdapterOptions, NextAdapterLoader, and InvalidNextAdapterOptionError also belong to the root. |
| @fluojs/platform-nextjs | defineNextApplication(options) and NextApplicationOptions<T> own the process-local application Promise for an explicit key. |
| @fluojs/platform-nextjs/app-router | createNextAppRouterHandler(loadAdapter), NextAppRouteHandler, NextAppRouterMethodHandlers: lazy Web Request callbacks and the seven method exports required by Next. |
| @fluojs/platform-nextjs/pages-router | createNextPagesRouterHandler(loadAdapter), NextPagesRouterConfig: the Node stream bridge and required static bodyParser: false type. |
| @fluojs/platform-nextjs/next-config | withFluoNextBackend(config, options?), FluoNextBackendOptions: Turbopack compiler configuration and opt-in scope/path preservation. |
| @fluojs/platform-nextjs/decorators-loader | The packaged compiler loader used by the config helper. |
The adapter retains its HttpApplicationAdapter class identity, public
constructor and inheritance, and instance fetch(request), listen(dispatcher),
close(), and getRealtimeCapability(). Creation does not bind a dispatcher or
open a socket; instance execution state is not shared through static fields.
Host callbacks and the compiler helper remain distinct capabilities rather than
being wrapped in creation classes.
API Migration
This is a major release removing public APIs. There are no permanent compatibility aliases.
| Removed surface | Replacement |
| --- | --- |
| Root/App/Pages createNextAdapter(options) | Import NextHttpApplicationAdapter from the root and call .create(options). The same option validation and class instance are preserved. |
| App/Pages NextHttpApplicationAdapter, NextAdapterOptions, NextAdapterLoader | Move imports to the root. Import App's former InvalidNextAdapterOptionError from the root too. |
| Root createNextAppRouterHandler, NextAppRouteHandler, NextAppRouterMethodHandlers | Import from /app-router. |
| Root createNextPagesRouterHandler, NextPagesRouterConfig | Import from /pages-router. |
| Adapter GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS fields | Destructure the result of createNextAppRouterHandler(loadAdapter) into route module exports. Lazily import the backend as in the App Router recipe above. |
Do not delete Next route module exports such as GET/POST/HEAD.
The adapter's low-level instance dispatch remains fetch(request), but do not
replace lazy bootstrap with an eager backend import or direct bound handler
export in a route. Do not convert Pages into a Web Request callback. Preserve
bodyParser: false, demand-driven input, drain waits for
ServerResponse.write() backpressure, disconnect abort, and remaining-upload
drain after the response completes. Next retains socket ownership.
Each facade's closure cache retains only that facade's loader Promise.
The defineNextApplication keyed cache retains the first Promise and failure
across consumers in the same JS global. Do not attribute one cache's guarantees
to the other. Neither automatically retries failure or reopens a closed application.
The consumer audit includes package tests/public declarations, the real Next
fixture, Docs runtime-adapters/http-platform pages, and Book volume 3 chapter 15
creation/import paths. Current CLI generators and examples/ have no consumers
of this Next API. The executable example is the package fixture.
Historical changelogs retain the API that existed at the time. New source and
distributed JavaScript/.d.ts do not retain the removed exports.
src/public-api.test.ts, src/app-router.test.ts,
src/head-routing-public-types.test.ts, and real Next E2E verify creation, host
methods, cache distinctions, and import ownership. Internal
createLazyNextAdapterResolver is the actual closure-cache implementation shared
by the App/Pages bridges, not a public package export. Documentation code-fence
import ownership is checked by pnpm verify:platform-consistency-governance.
Development Verification
From the repository root:
pnpm --filter '@fluojs/platform-nextjs...' build
pnpm --filter @fluojs/platform-nextjs typecheck
pnpm --filter @fluojs/platform-nextjs test
pnpm --filter @fluojs/platform-nextjs test:e2eThe unit suite includes shared Web portability checks and native Pages transport regressions. The E2E suite stages package distribution files, builds a real Next.js application through dev/build/start, and verifies both routers and client store SSR over HTTP without source aliases. See the fixture guide.
Bounded Body Parsing
Set NextAdapterOptions.bodyParser at adapter construction. It accepts the
HTTP-owned BodyParser: 'default' (also the omitted default), 'text', or a
synchronous/asynchronous callback. Application-wide text mode is
NextHttpApplicationAdapter.create({ bodyParser: 'text' }). For path-specific policy, delegate
unselected paths to the unchanged parser instead of reconstructing Requests:
import { NextHttpApplicationAdapter } from '@fluojs/platform-nextjs';
export const nextAdapter = NextHttpApplicationAdapter.create({
headRouting: 'explicit-or-get',
maxBodySize: 1_048_576,
rawBody: true,
bodyParser(text, context) {
if (context.path === '/api/posts/draft') return text;
return context.parseDefault();
},
});Pass this adapter to the existing FluoFactory.create() and keep the ordinary
App/Pages facade. Paths are original URL pathnames before Fluo route matching;
there are no matched params or route metadata at this stage. Multipart keeps its
existing parser/limits and does not invoke the callback. The
HTTP contract and capability matrix
own defaults, empty/absent bodies, raw bytes, errors, and abort cooperation.
Authentication boundary: bounded reading and custom callbacks precede Fluo
middleware, guards, and filters. To return 401 before malformed-JSON 400, receive
text, authenticate explicitly in middleware/a guard, then parse JSON in the
handler (or a later application stage), translating syntax errors to
BadRequestException. Oversized input still returns 413 before authentication.
Text mode does not authenticate, reorder the pipeline, or make invalid JSON null.
Migration: remove the wrapper that changes Content-Type to text/plain and
constructs a replacement Request. Keep original headers and pass the original
Request to the standard facade. The adapter already consumes the original body
without cloning; a proxied Request.clone failure in that removed wrapper is not
a Next adapter defect. rawBody: true alone is not a JSON parsing bypass.
Pages Router still exports Next api: { bodyParser: false } so Fluo owns the raw
stream; an upstream parsed host body cannot recover original bytes.
Evidence: src/body-parser.test.ts, the cold public declaration tests in
src/head-routing-public-types.test.ts, and e2e/fixture/backend.ts plus
e2e/next.test.mjs. The production fixture uses this public callback and no
request/header rewriting for App and Pages routes, including default delegation,
UTF-8 limits, raw bytes, empty/malformed/non-JSON inputs, and auth precedence.
