Module Structure
Every next-modular module follows this structure:
my-module/
├── src/
│ ├── index.ts # Module definition (defineModule)
│ ├── routes/
│ │ ├── home.tsx # Page components
│ │ └── detail.tsx
│ └── server/
│ ├── api/
│ │ └── hello.ts # API route handlers
│ └── middleware.ts # Module middleware
├── package.json
└── README.mdModule definition
The entry point uses defineModule() to declare the module’s name, base path, and register its routes, API handlers, and middleware:
import { defineModule, route } from 'next-modular';
import * as home from './routes/home';
import * as detail from './routes/detail';
import AboutPage from './routes/about';
import { helloHandler } from './server/api/hello';
import { myMiddleware } from './server/middleware';
export interface MyModuleConfig {
customOption?: string;
}
export const myModule = defineModule<MyModuleConfig>({
name: 'my-module',
basePath: '/my-module',
routes: [
// route() reads the component and metadata from the route file.
route('/', home),
// You can also pass a component directly.
{ path: '/about', component: AboutPage },
route('/[id]', detail),
],
apiRoutes: [
{ path: '/hello', handler: helloHandler },
],
middleware: {
handler: myMiddleware,
},
});There are two ways to register a route, and you can mix them:
route(path, mod)takes a route file imported as a namespace (import * as home) and pulls out its default component plus anymetadata/generateMetadataexports. Use this when the route has metadata.- The plain object form takes a component you import directly
(
import AboutPage from './routes/about'). Metadata is optional here — attachmetadata/generateMetadatato the object yourself if you need it.
The returned module is callable. You can pass configuration when registering it:
myModule({ enabled: true, customOption: 'value' })Route components
Route components receive a params object with any dynamic segments matched from the URL:
export default function DetailPage({ params }: { params: Record<string, string> }) {
return <div>Item: {params.id}</div>;
}Route metadata
A route file can export Next.js metadata just like a normal page — either a
static metadata object or a dynamic generateMetadata function:
import type { Metadata } from 'next';
// Static:
export const metadata: Metadata = { title: 'Home' };
// Or dynamic, from the matched route params:
export function generateMetadata({ params }: { params: Record<string, string> }): Metadata {
return { title: `Item ${params.id}` };
}
export default function HomePage() {
return <div>Home</div>;
}Register the route with route() so these exports are picked up. Because module
routes render through the catch-all page, resolve the metadata in
app/[...module]/page.tsx with handleMetadata:
import { handleMetadata } from 'next-modular';
import type { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: Promise<{ module: string[] }>;
}): Promise<Metadata> {
const { module } = await params;
return (await handleMetadata('/' + module.join('/'))) ?? {};
}generateMetadata takes precedence over the static metadata object when both
are present. App-wide defaults still belong in your root layout.tsx.
params is passed as a plain resolved object (not a Promise), since module
routes are resolved by next-modular rather than by Next.js directly.
API handlers
API handlers receive the raw Request and a context object with params:
export async function helloHandler(req: Request, context: any): Promise<Response> {
return new Response(JSON.stringify({ message: 'Hello' }), {
headers: { 'Content-Type': 'application/json' },
});
}Middleware
Module middleware runs for any request matching the module’s basePath (or /api/basePath):
import { NextRequest, NextResponse } from 'next/server';
export async function myMiddleware(req: NextRequest) {
// Return NextResponse to short-circuit, or void/undefined to continue
return NextResponse.next();
}Global middleware
Set global: true on the middleware definition to run it on every request
instead of only paths under the module’s basePath. Any path filtering is then
up to the handler:
export const myModule = defineModule({
name: 'my-module',
basePath: '/my-module',
middleware: {
handler: myMiddleware,
global: true,
},
});Middleware runs in registration order; the first handler to return a value
short-circuits the chain. The enabled and features.middleware config toggles
still apply.
Path matching
Routes support:
- Static paths:
/hello - Dynamic segments:
/[id] - Catch-all routes:
/[...slug]
The route matcher resolves the URL relative to the module’s basePath. For example, a module with basePath: '/blog' and route path: '/[slug]' matches /blog/my-post.
Routes are matched in declaration order, and static segments are not automatically prioritized over dynamic ones. Declare specific static routes (like /about) before dynamic ones (like /[id]), otherwise the dynamic route will capture the static path first.