Plugins

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:

plugins/my-plugin/src/routes/manifest.ts
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:

plugins/my-plugin/src/routes/post-page.tsx
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 PostPage

Directory Structure

All route files live inside your plugin's src/routes/ directory:

config.tsx
manifest.ts
post-page.tsx
guide-layout.tsx

Add a route step by step

1. Declare the route in manifest.ts

Export an array of routes from src/routes/manifest.ts:

plugins/my-plugin/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:

plugins/my-plugin/src/routes/post-page.tsx
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 PostPage

3. Register routes in your plugin config

Pass the routes array into buildPlugin:

plugins/my-plugin/src/config.tsx
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, 
  })

4. Run the app

bun dev
pnpm dev
npm run dev

Visit /blog/hello to see your route rendered live.

Field Reference

Nine fields configure a PluginRouteDefinition. Only id, path, and entry are required:

Prop

Type

Path Syntax Rules

ShapeWrite thisNot 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 :slug in manifests: VitNode compiles :slug into TanStack Router's $slug syntax automatically.
  • Lowercase static segments: Paths match case-insensitively. Always write /blog/post, not /Blog/Post.
  • Never include locale prefixes: /blog automatically serves /pl/blog or any configured locale.

Layouts and Nesting

Group related routes inside a shared layout using kind: 'layout' and parentId:

plugins/my-plugin/src/routes/manifest.ts
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:

plugins/my-plugin/src/routes/docs-layout.tsx
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 DocsLayout

AdminCP Pages

Set area: "admin" to mount your route inside the AdminCP shell (with sidebar, breadcrumbs, and staff auth):

plugins/my-plugin/src/routes/manifest.ts
{
  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:

plugins/my-plugin/src/routes/topic-page.tsx
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 TopicPage

Route Lifecycle Hooks

HookDescription
loadRuns on server and client before render to load data. Receives { context, params, search }.
headEmits page <title>, <meta>, and Open Graph tags. Receives { loaderData, params }.
breadcrumbComponent rendering breadcrumb item in shell header.
parseSearchNormalizes 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.

Learn More