Route Manifest
Declare plugin routes cleanly as serializable data with routes/manifest.ts - dynamic paths, layouts, guards, and module exports.
A plugin owns its pages. It declares what pages it has and where they live in src/routes/manifest.ts. VitNode reads this manifest at build time and mounts each page into TanStack Router with code splitting and SSR out of the box.
Quick start
Three fields define a complete route:
import type { PluginRouteDefinition } from "@vitnode/core/routing"
export const routes: PluginRouteDefinition[] = [
{
entry: "routes/post-page",
id: "post",
path: "/blog/:slug",
},
]And the page module it points to:
const PostPage = () => (
<div className="container mx-auto max-w-2xl p-4">
<h1 className="text-2xl font-bold">Hello from plugin!</h1>
</div>
)
export default PostPageDirectory Structure
All route files live inside your plugin's src/routes/ directory:
Add a route step by step
1. Declare the route in manifest.ts
Export an array of routes from src/routes/manifest.ts:
import type { PluginRouteDefinition } from "@vitnode/core/routing"
export const routes: PluginRouteDefinition[] = [
{
entry: "routes/post-page",
id: "post",
path: "/blog/:slug",
},
]2. Create the route component module
Export a React component as default. Optionally export route = definePluginRoute({ ... }) for data loading and metadata:
import type { PluginRoutePageProps } from "@vitnode/core/routing"
import { definePluginRoute } from "@vitnode/core/routing"
interface Post {
title: string
content: string
}
export const route = definePluginRoute({
load: async ({ params }) => ({
title: `Post ${params.slug}`,
content: "Welcome to this post!",
}),
})
const PostPage = ({ loaderData }: PluginRoutePageProps<Post>) => (
<article className="container mx-auto max-w-2xl p-4 flex flex-col gap-2">
<h1 className="text-3xl font-bold">{loaderData.title}</h1>
<p>{loaderData.content}</p>
</article>
)
export default PostPage3. Register routes in your plugin config
Pass the routes array into buildPlugin:
import { buildPlugin } from "@vitnode/core/lib/plugin"
import messages from "./locales"
import { routes } from "./routes/manifest"
export const myPlugin = () =>
buildPlugin({
pluginId: "my-plugin",
messages,
routes,
})Field Reference
Nine fields configure a PluginRouteDefinition. Only id, path, and entry are required:
Prop
Type
Path Syntax Rules
| Shape | Write this | Not this |
|---|---|---|
| Static | /blog | - |
| Dynamic segment | /blog/:slug | /blog/[slug], /blog/$slug |
| Nested | /blog/:slug/comments | /blog/$slug/comments |
| Root | / | "", blog (a path must start with /) |
- Use
:slugin manifests: VitNode compiles:sluginto TanStack Router's$slugsyntax automatically. - Lowercase static segments: Paths match case-insensitively. Always write
/blog/post, not/Blog/Post. - Never include locale prefixes:
/blogautomatically serves/pl/blogor any configured locale.
Layouts and Nesting
Group related routes inside a shared layout using kind: 'layout' and parentId:
export const routes: PluginRouteDefinition[] = [
// Parent layout
{
id: "docs",
entry: "routes/docs-layout",
path: "/docs",
kind: "layout",
},
// Child pages
{
id: "docs-index",
entry: "routes/docs-index-page",
path: "/docs",
parentId: "docs",
},
{
id: "docs-topic",
entry: "routes/docs-topic-page",
path: "/docs/:topic",
parentId: "docs",
},
]In the layout component, render <Outlet /> where child routes appear:
import { Outlet } from "@tanstack/react-router"
const DocsLayout = () => (
<div className="flex gap-6">
<aside className="w-64 border-r p-4">Sidebar</aside>
<main className="flex-1 p-4">
<Outlet />
</main>
</div>
)
export default DocsLayoutAdminCP Pages
Set area: "admin" to mount your route inside the AdminCP shell (with sidebar, breadcrumbs, and staff auth):
{
id: "settings",
entry: "routes/admin-settings-page",
path: "/admin/my-plugin/settings",
area: "admin",
}To add an item in the AdminCP sidebar, register it in src/admin/nav.tsx as well. See AdminCP Pages for details.
What the Route Module Exports
A route module exports a default component and an optional definePluginRoute configuration:
import type { PluginRoutePageProps } from "@vitnode/core/routing"
import { definePluginRoute } from "@vitnode/core/routing"
interface Topic {
title: string
description: string
}
export const route = definePluginRoute({
load: async ({ context, params }) => {
return await fetchTopic(params.topic, context.locale)
},
head: ({ loaderData }) => ({
title: loaderData?.title,
description: loaderData?.description,
}),
})
const TopicPage = ({ loaderData }: PluginRoutePageProps<Topic>) => (
<article>
<h1>{loaderData.title}</h1>
<p>{loaderData.description}</p>
</article>
)
export default TopicPageRoute Lifecycle Hooks
| Hook | Description |
|---|---|
load | Runs on server and client before render to load data. Receives { context, params, search }. |
head | Emits page <title>, <meta>, and Open Graph tags. Receives { loaderData, params }. |
breadcrumb | Component rendering breadcrumb item in shell header. |
parseSearch | Normalizes URL query string parameters for typed search access. |
Declare load above head
TypeScript infers loaderData type in head and the page component from what load returns. Always declare load above head in definePluginRoute.
Best Practices & Gotchas
No file extensions in entry
Write entry: 'routes/post-page', never 'routes/post-page.tsx'. Export subpaths resolve automatically via your plugin's package.json export map.
Rebuilding after adding new routes
When you add a brand new route to manifest.ts, restart your dev server so the Vite plugin recognizes the new file and updates generated registries.