diff --git a/README.md b/README.md
index badb7919f..80bba2e95 100644
--- a/README.md
+++ b/README.md
@@ -86,7 +86,7 @@ feature. That boundary pays rent surprisingly quickly.
- [Getting started](https://vitnode.com/docs/dev/setup)
- [Build your first plugin](https://vitnode.com/docs/guides/first-plugin)
-- [Plugin routes](https://vitnode.com/docs/dev/routing)
+- [Plugin routes](https://vitnode.com/docs/dev/plugins/routes)
- [Admin Control Panel](https://vitnode.com/docs/dev/plugins/admin)
- [Content delivery and SEO](https://vitnode.com/docs/dev/content-engine/content-delivery-and-seo)
- [Write documentation](https://vitnode.com/docs/dev/documentation)
diff --git a/apps/api/package.json b/apps/api/package.json
index cb36b3018..4599d6a44 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -1,6 +1,6 @@
{
"name": "api",
- "version": "1.2.0-canary.78",
+ "version": "2.0.0-canary.4",
"private": true,
"type": "module",
"scripts": {
diff --git a/apps/api/src/i18n.ts b/apps/api/src/i18n.ts
index e82cbd6e0..906d78af9 100644
--- a/apps/api/src/i18n.ts
+++ b/apps/api/src/i18n.ts
@@ -1,24 +1,5 @@
import type { VitNodeI18nConfig } from "@vitnode/core/lib/i18n/types";
-/**
- * The languages this installation serves.
- *
- * This API and `apps/web` are two halves of one installation - the same
- * Postgres, the same `core_languages` - and this app is the half that owns the
- * schema, so `vitnode db:prepare` seeds the database from *this* list. That is
- * why it is spelled out rather than left empty: the bootstrap used to look for
- * the web app's `src/vitnode.config.ts` by walking the filesystem, never found
- * it from here, and seeded `en` alone into a database serving `en` and `pl`.
- * The API config is now the only thing it reads.
- *
- * It must stay in step with `apps/web/src/i18n.ts`, and
- * `apps/web/src/tests/installation-locales.test.ts` fails if the two drift.
- * A generated split deployment has the same obligation and the same shape: two
- * apps, one declaration each, and no filesystem discovery between them.
- *
- * Packages ship their own translations, so nothing here lists them - a locale
- * with no `messages` entry falls back to `defaultLocale` key by key.
- */
export const i18n = {
defaultLocale: "en",
/**
diff --git a/apps/api/src/vitnode.api.config.ts b/apps/api/src/vitnode.api.config.ts
index 46134dd42..66b7f0b22 100644
--- a/apps/api/src/vitnode.api.config.ts
+++ b/apps/api/src/vitnode.api.config.ts
@@ -46,15 +46,7 @@ export const vitNodeApiConfig = buildApiConfig({
},
],
},
- /**
- * The installation's languages, from the one module that declares them.
- *
- * Not optional here, whatever the type says: this app owns the schema, so
- * `vitnode db:prepare` seeds `core_languages` from this list. Left unset, the
- * API derives its locales from whatever the installed packages ship - which
- * answers "what can be translated", not "what does this site serve" - and the
- * seed falls back to `en` alone.
- */
+
i18n,
dbProvider: drizzle({
connection: POSTGRES_URL,
@@ -100,21 +92,7 @@ export const vitNodeApiConfig = buildApiConfig({
bucket: process.env.SUPABASE_STORAGE_BUCKET,
}),
},
- /**
- * Sign-in with Discord, Google and Facebook.
- *
- * Carried over from `apps/docs` when Stage 17 deleted it: that application's
- * config was the only place in the repo registering these three adapters, and
- * `@vitnode/core/api/adapters/sso/*` would otherwise have had no consumer at
- * all - implementations behind a live route (`/login/sso/:providerId`) with
- * nothing showing how to switch them on.
- *
- * Registering one with its environment variables unset is safe and is the
- * normal state here: each adapter defaults `clientId`/`clientSecret` to `""`
- * and only fails when somebody actually tries that provider, with "Missing
- * Discord client ID or secret" rather than a broken login page. So the list is
- * what this API *supports*; the environment decides what works.
- */
+
authorization: {
ssoAdapters: [
DiscordSSOApiPlugin({
diff --git a/apps/web/content/docs/dev/architecture.mdx b/apps/web/content/docs/dev/architecture.mdx
index 595060d29..006b2db0f 100644
--- a/apps/web/content/docs/dev/architecture.mdx
+++ b/apps/web/content/docs/dev/architecture.mdx
@@ -41,6 +41,26 @@ When a user visits a page (e.g. `/blog`):
---
+## The Request Pipeline
+
+Before route matching, every request passes through the middleware
+`createVitNodeStart` installs - in this order, and an app cannot get in front of
+any of it:
+
+| Order | Middleware | Applies to |
+| :--- | :--- | :--- |
+| 1 | **CSRF** | Server function calls (`handlerType === 'serverFn'`) |
+| 2 | **Locale** | Page requests: canonical `308` redirects and the locale cookie |
+| 3 | **Document cache** | HTML responses: forced `Cache-Control: private, no-store` |
+| 4 | Your own | Whatever `requestMiddleware` lists |
+
+`/api/*` reaches the same middleware and passes through untouched - no redirect,
+no rewrite, no cache directive - so the Hono bridge sees the request exactly as
+the client sent it and keeps its own caching policy. See
+[Configuration](/docs/dev/configuration).
+
+---
+
## Plugin System Architecture
VitNode is built around modular plugins located in `plugins/*`:
@@ -58,7 +78,7 @@ VitNode is built around modular plugins located in `plugins/*`:
/>
+
diff --git a/apps/web/content/docs/dev/cache.mdx b/apps/web/content/docs/dev/cache.mdx
index 300d4ae24..72fc7dfc9 100644
--- a/apps/web/content/docs/dev/cache.mdx
+++ b/apps/web/content/docs/dev/cache.mdx
@@ -29,7 +29,7 @@ instead of one per component.
### Load it from the plugin route
-```ts title="plugins/announcements/src/routes/announcements-page.tsx"
+```ts title="plugins/announcements/src/pages/announcements-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
export const route = definePluginRoute({
diff --git a/apps/web/content/docs/dev/captcha/custom-adapter.mdx b/apps/web/content/docs/dev/captcha/custom-adapter.mdx
index c876fa2c4..d52bee92e 100644
--- a/apps/web/content/docs/dev/captcha/custom-adapter.mdx
+++ b/apps/web/content/docs/dev/captcha/custom-adapter.mdx
@@ -134,7 +134,7 @@ export const createContactRoute = buildRoute({
The site key is public. A plugin page can read it with the shared TanStack Query
definition before rendering its form:
-```tsx title="plugins/contact/src/routes/contact-page.tsx"
+```tsx title="plugins/contact/src/pages/contact-page.tsx"
import { useSuspenseQuery } from '@tanstack/react-query'
import { middlewareConfigQueryOptions } from '@vitnode/core/tanstack/auth'
diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx
new file mode 100644
index 000000000..9f8447bd7
--- /dev/null
+++ b/apps/web/content/docs/dev/configuration.mdx
@@ -0,0 +1,311 @@
+---
+title: Configuration
+description: The two config files a VitNode app owns - the browser-safe shared one, and the server-only companion - plus the request pipeline Core installs for you.
+icon: Settings
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+
+A VitNode app has two configuration files, and the line between them is one
+question: **may a browser hold this?**
+
+| File | Holds | Reached by |
+| :----------------------------- | :---------------------------------------------------- | :----------------------------------------------- |
+| `src/vitnode.config.ts` | locales, metadata, theme, `debug`, enabled plugin ids | the browser, the server, **and your Vite build** |
+| `src/vitnode.server.config.ts` | message loaders | the server only |
+
+---
+
+## `vitnode.config.ts` - the shared config
+
+```ts title="apps/web/src/vitnode.config.ts"
+import { blogPlugin } from '@vitnode/blog/config'
+import { buildConfig } from '@vitnode/core/vitnode.config'
+
+export const vitNodeConfig = buildConfig({
+ debug: false,
+ i18n: {
+ defaultLocale: 'en',
+ locales: [
+ { code: 'en', name: 'English' },
+ { code: 'pl', name: 'Polski' },
+ ],
+ timeZone: 'UTC',
+ },
+ metadata: {
+ shortTitle: 'VitNode',
+ title: 'VitNode',
+ },
+ plugins: [blogPlugin()],
+ theme: {
+ defaultTheme: 'system',
+ },
+})
+```
+
+Three very different readers depend on this file:
+
+1. **The document shell** renders `metadata`, `theme` and `debug` - in the
+ browser as well as on the server.
+2. **The locale runtime** derives routing from `i18n`.
+3. **Your Vite build** loads it with `jiti` while Vite is still resolving its
+ own config, to find out which plugins to generate route, navigation and
+ content-registry imports for.
+
+
+ Because of reader 3, this file is executed at build time; because of reader 1
+ it is bundled for a browser. So it holds plain data and plugin *identity* -
+ never a `() => import(...)` message loader, and never a module that reaches a
+ database.
+
+
+`buildConfig` keeps `locales` a tuple of literal types, so a `Locale` derived
+from it is `'en' | 'pl'` rather than `string`, and `defaultLocale` is checked
+against the list right beside it:
+
+```ts title="apps/web/src/lib/i18n/shared.ts"
+export type Locale = (typeof vitNodeConfig.i18n.locales)[number]['code']
+```
+
+### Enabled plugins
+
+Register a plugin with its own factory:
+
+```ts
+import { blogPlugin } from '@vitnode/blog/config'
+
+plugins: [blogPlugin()]
+```
+
+The factory carries the plugin's content types, its AdminCP navigation and its
+translations - one declaration, and the same one a Next.js host reads directly.
+
+What a TanStack Start app **renders** comes back through generated projections
+rather than out of this object, and that is the part worth knowing:
+
+| Generated file | From | Loaded |
+| :---------------------------- | :--------------------------- | :---------------------------------------------- |
+| `src/plugin-routes.gen.ts` | the plugin's `src/routes.ts` | per route, behind `lazy()` |
+| `src/admin-nav.gen.ts` | the plugin's `admin/nav` | with the AdminCP shell |
+| `src/content-registry.gen.ts` | the plugin's `admin/content` | behind a dynamic `import()` in `src/router.tsx` |
+
+One literal import per configured plugin, written at build time. So a content
+type's editing screen - a Tiptap field, a form layout, a table cell - arrives
+with the route that renders it, not with the config that the document shell
+imports. See [Plugin routes](/docs/dev/plugins/routes) and
+[Plugin frontend modules](/docs/dev/content-engine/plugin-registration).
+
+
+ The `messages` a factory carries is the plugin's own locale barrel, which loads
+ its JSON with `import('./en.json', { with: { type: 'json' } })` - a specifier
+ no bundler follows. VitNode reads translations from
+ `src/locales/packages.ts` instead, so add a line there per language the plugin
+ ships. [Languages & Localization](/docs/dev/i18n) has the detail.
+
+
+
+ Your Vite build executes this file with `jiti` on every regeneration pass to
+ discover the plugin list. Running a plugin's registration graph in Node
+ measures ~340ms per pass against ~6ms for `buildPlugin({pluginId})`, which is
+ the minimum a TanStack host needs - worth knowing if a large plugin set makes
+ the dev watcher feel slow.
+
+
+---
+
+## `vitnode.server.config.ts` - the server-only companion
+
+Message loaders read JSON out of a package's build output, which is exactly what
+the shared config cannot carry. They go here instead:
+
+```ts title="apps/web/src/vitnode.server.config.ts"
+import '@tanstack/react-start/server-only'
+import { buildServerConfig } from '@vitnode/core/vitnode.config'
+
+import { appMessages } from '#/locales/app'
+import { packageMessages } from '#/locales/packages'
+import { vitNodeConfig } from '#/vitnode.config'
+
+export const vitNodeServerConfig = buildServerConfig({
+ config: vitNodeConfig,
+ messages: appMessages,
+ packageMessages,
+})
+```
+
+It **holds** the shared config rather than repeating any of it, so the locale
+list your message loaders are resolved against is the same object the router and
+the document shell read. Hand the whole thing to the loader:
+
+```ts title="apps/web/src/server/messages.server.ts"
+export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig)
+```
+
+`packageMessages` is one line per language a package ships, and `messages` is
+where you reword a string a package translates differently to how you want it.
+[Languages & Localization](/docs/dev/i18n) covers both.
+
+
+ `src/vitnode.api.config.ts` configures the Hono app at `/api/*` - the
+ database, storage, email, Redis. In a single app it reads `vitNodeConfig.i18n`
+ so the site and its emails agree on which languages exist. In a split
+ deployment the API is a separate package and declares its own list.
+
+
+---
+
+## `start.ts` - the request pipeline
+
+TanStack Start expects a `src/start.ts`. In a VitNode app it is one call:
+
+```ts title="apps/web/src/start.ts"
+import { createVitNodeStart } from '@vitnode/core/tanstack/start'
+
+import { vitNodeConfig } from '#/vitnode.config'
+
+export const startInstance = createVitNodeStart({ config: vitNodeConfig })
+```
+
+`createVitNodeStart` installs three things, and none of them is optional:
+
+| Middleware | What it does |
+| :------------ | :---------------------------------------------------------------------------------------- |
+| **CSRF** | Rejects cross-site requests to server functions (`handlerType === 'serverFn'`) |
+| **Locale** | Canonical `308` redirects, the remembered-locale cookie, and the document cache directive |
+| **Documents** | Forces `Cache-Control: private, no-store` onto every HTML response |
+
+
+ Start installs its own CSRF middleware *only* while an app declares no
+ `requestMiddleware` at all. The moment an app writes its own list, that
+ default is replaced by whatever the list holds - so an app that hand-rolls the
+ pipeline and forgets CSRF exposes every server function as an unauthenticated
+ cross-site endpoint, silently. Core owns the list so you cannot.
+
+
+### Why documents are never shared-cacheable
+
+Every page VitNode renders streams a dehydrated TanStack Query cache into its
+HTML, and that cache always holds the visitor's own session - inside `/admin`,
+an administrator's entire permission set. So the directive is **forced**, not
+defaulted: a route that sets `public, max-age=60` is overwritten rather than
+obeyed, because the route is not in a position to know what is in the body it
+would be publishing.
+
+Only `text/html` responses are touched. Everything else keeps what it had.
+
+
+ `/api/*` is served by the Hono bridge through this same middleware and passes
+ through untouched: no locale redirect, no rewrite, and no cache directive on
+ the JSON it answers with. The API decides its own policy with `c.get("cache")`
+ - see [Caching](/docs/dev/cache). The one exception proves the rule rather
+ than bending it: Swagger UI at `/api/swagger` is HTML, so it gets the document
+ directive like any other page an operator reads.
+
+
+### Adding your own middleware
+
+For things your installation genuinely owns - a request id, a tracing span, a
+maintenance-mode gate:
+
+```ts title="apps/web/src/start.ts"
+import { createMiddleware } from '@tanstack/react-start'
+import { createVitNodeStart } from '@vitnode/core/tanstack/start'
+
+import { vitNodeConfig } from '#/vitnode.config'
+
+const requestId = createMiddleware().server(async ({ next }) => {
+ const result = await next()
+ result.response.headers.set('x-request-id', crypto.randomUUID())
+
+ return result
+})
+
+export const startInstance = createVitNodeStart({
+ config: vitNodeConfig,
+ requestMiddleware: [requestId],
+})
+```
+
+**Ordering is guaranteed:** CSRF first, then locale handling, then your list in
+the order you declared it. You cannot get in front of either. That is not
+tidiness - a locale redirect _ends_ the request, so middleware running before it
+would run twice for every visitor who arrives at a non-canonical URL, once for
+the redirect and once for the page.
+
+---
+
+## `vite.config.ts`
+
+One VitNode plugin, which is the environment handling, the dev server's
+dependency pre-bundling, the SSR externals and the plugin route generator in the
+order they have to run:
+
+```ts title="apps/web/vite.config.ts"
+import { vitnode } from '@vitnode/core/framework/vite'
+
+export default defineConfig({
+ plugins: [
+ vitnode({ appRoot: import.meta.dirname }),
+ nitro(),
+ tailwindcss(),
+ tanstackStart(),
+ viteReact(),
+ ],
+})
+```
+
+`appRoot` has to be `import.meta.dirname`: a Vite config is loaded with the
+working directory set to wherever the command ran, which in a monorepo is
+regularly the repository root.
+
+Publish an extra `NEXT_PUBLIC_*` key to the browser with `clientEnv`:
+
+```ts
+vitnode({ appRoot: import.meta.dirname, clientEnv: ['NEXT_PUBLIC_MAP_KEY'] })
+```
+
+Everything named there is compiled into JavaScript anyone can read, so add a key
+only when something in the browser genuinely reads it. The four plugins are
+still exported individually - `vitNodeEnv`, `vitNodeOptimizeDeps`,
+`vitNodeSsrExternals`, `vitNodePluginRoutes` - if you ever need to drop or
+reorder one.
+
+### What gets externalised from the server render
+
+You do not write an `ssr.external` list, because the right answer changes
+between the two commands:
+
+| Command | `@vitnode/core` and your plugins | Why |
+| ------------ | -------------------------------- | --------------------------------------------------------------------------------- |
+| `vite dev` | inlined | Vite owns the modules, so a rebuilt package reaches the server render immediately |
+| `vite build` | external | the built package goes into the output as it is, rather than being bundled again |
+
+Externalising in dev is the tempting mistake, and it fails quietly: an external
+module is imported by Node, whose module cache lives as long as the process. The
+browser hot-reloads your edit, the server render keeps the copy it loaded at
+startup, and React reports a hydration mismatch on markup you already fixed. Only
+a restart clears it - which is exactly the loop this plugin removes.
+
+The build list is `@vitnode/core`, every plugin in your `vitnode.config.ts` and
+`tslib`. It comes from the same configured plugin list the route generator reads,
+so removing a plugin from your config is one edit, not two.
+
+## Learn More
+
+
+
+
+
+
diff --git a/apps/web/content/docs/dev/content-engine/content-delivery-and-seo.mdx b/apps/web/content/docs/dev/content-engine/content-delivery-and-seo.mdx
index c44f03f17..5cc2ef68b 100644
--- a/apps/web/content/docs/dev/content-engine/content-delivery-and-seo.mdx
+++ b/apps/web/content/docs/dev/content-engine/content-delivery-and-seo.mdx
@@ -10,7 +10,7 @@ Content delivery starts in the plugin that owns the content type. Opt into the
public API first, then let the same plugin claim the page URL. Search engines
get a stable story; future you gets fewer scattered files.
-{/* Image prompt: Dark-theme flow diagram showing a Content Engine record with a slug, a plugin route manifest for /articles/:slug, and a rendered page head containing title, description, canonical URL, Open Graph, hreflang, and sitemap symbols. 1600x900. */}
+{/* Image prompt: Dark-theme flow diagram showing a Content Engine record with a slug, a plugin route tree entry for /articles/:slug, and a rendered page head containing title, description, canonical URL, Open Graph, hreflang, and sitemap symbols. 1600x900. */}
@@ -56,15 +56,15 @@ export const articleContentType = defineContentType({
### Claim the public URL in the plugin
-```ts title="plugins/site-notes/src/routes/manifest.ts"
-export const routes = [
- // [!code ++:6]
- {
- entry: 'routes/article-page',
- id: 'article',
- path: '/articles/:slug',
- },
-]
+```ts title="plugins/site-notes/src/routes.ts"
+import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
+
+export const routes = definePluginRoutes([
+ // [!code ++:3]
+ page('/articles/:slug', {
+ component: lazy(() => import('./pages/article-page')),
+ }),
+])
```
@@ -72,7 +72,7 @@ export const routes = [
### Render data and metadata from the plugin route
-```tsx title="plugins/site-notes/src/routes/article-page.tsx"
+```tsx title="plugins/site-notes/src/pages/article-page.tsx"
import type { PluginRoutePageProps } from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
diff --git a/apps/web/content/docs/dev/content-engine/plugin-registration.mdx b/apps/web/content/docs/dev/content-engine/plugin-registration.mdx
index 1995599d9..2af997f3c 100644
--- a/apps/web/content/docs/dev/content-engine/plugin-registration.mdx
+++ b/apps/web/content/docs/dev/content-engine/plugin-registration.mdx
@@ -12,7 +12,7 @@ To keep the AdminCP sidebar light while lazy-loading heavy rich text editors on
| :--- | :--- | :--- |
| `admin/nav` | `src/admin/nav.tsx` | Lightweight sidebar navigation entries and Lucide icons |
| `admin/content` | `src/admin/content.tsx` | Content Engine editing screens, custom fields, and form layouts |
-| `config` | `src/config.tsx` | Master plugin definition mounted in host app `vitnode.config.ts` |
+| `config` | `src/config.tsx` | Master plugin definition - one declaration the two modules above are spread from |
---
@@ -61,12 +61,12 @@ export const adminContent = {
## 3. `config.tsx` (Root Plugin Config)
-Exports the main plugin factory used by host applications:
+The plugin's own single declaration - everything it registers, in one place, so the two browser-safe modules above cannot drift from it:
```tsx title="plugins/blog/src/config.tsx"
import { buildPlugin } from "@vitnode/core/lib/plugin"
import messages from "./locales"
-import { routes } from "./routes/manifest"
+import { routes } from "./routes"
import { adminContent } from "./admin/content"
export const blogPlugin = () =>
@@ -78,6 +78,13 @@ export const blogPlugin = () =>
})
```
+A host registers this factory in `vitnode.config.ts`. A Next.js host then walks
+the registry in its render pass; a TanStack Start host does not - the build
+generates one literal import of `admin/nav` and `admin/content` per configured
+plugin, and the content registry is loaded behind a dynamic `import()`, so an
+editing screen arrives with the route that renders it. Same declarations, two
+doors. See [Configuration](/docs/dev/configuration).
+
---
## Package Exports Configuration
diff --git a/apps/web/content/docs/dev/data-loading.mdx b/apps/web/content/docs/dev/data-loading.mdx
index f98b87640..2b2c7fe44 100644
--- a/apps/web/content/docs/dev/data-loading.mdx
+++ b/apps/web/content/docs/dev/data-loading.mdx
@@ -13,7 +13,7 @@ runs for SSR and client navigation, then hands typed data to the plugin page.
Plugins load data using `definePluginRoute`. The loader runs during SSR and client navigation, handing typed `loaderData` to the page component:
-```tsx title="plugins/blog/src/routes/announcements-page.tsx"
+```tsx title="plugins/blog/src/pages/announcements-page.tsx"
import type { PluginRoutePageProps } from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
diff --git a/apps/web/content/docs/dev/database/pagination.mdx b/apps/web/content/docs/dev/database/pagination.mdx
index 24b4adbec..59593172e 100644
--- a/apps/web/content/docs/dev/database/pagination.mdx
+++ b/apps/web/content/docs/dev/database/pagination.mdx
@@ -180,7 +180,7 @@ Plugin routes normalize their own query string with `parseSearch`, then receive
the typed `search`, `loaderData`, and same-page `navigate` function. That is all
a list needs—no host route file required.
-```tsx title="plugins/blog/src/routes/posts-page.tsx"
+```tsx title="plugins/blog/src/pages/posts-page.tsx"
import type { PluginRoutePageProps } from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
diff --git a/apps/web/content/docs/dev/debugging/index.mdx b/apps/web/content/docs/dev/debugging/index.mdx
index b1482cf59..8086c83d1 100644
--- a/apps/web/content/docs/dev/debugging/index.mdx
+++ b/apps/web/content/docs/dev/debugging/index.mdx
@@ -113,20 +113,24 @@ its entries before React renders, and the screen reads the same ones back.
[React Scan](https://react-scan.com/) draws an outline around every component as
it re-renders, which turns "this page feels heavy" into a number. It is off by
-default; flip `debug` in the shell config:
+default; flip `debug` in the [shared config](/docs/dev/configuration):
-```ts title="src/vitnode.shell.config.ts"
-export const vitNodeShellConfig = {
+```ts title="src/vitnode.config.ts"
+export const vitNodeConfig = buildConfig({
debug: true, // [!code ++]
- i18n,
+ i18n: {
+ defaultLocale: 'en',
+ locales: [{ code: 'en', name: 'English' }],
+ },
metadata: {
shortTitle: 'VitNode',
title: 'VitNode',
},
+ plugins: [],
theme: {
defaultTheme: 'system',
},
-} satisfies Omit
+})
```
The provider loads `react-scan` lazily and only when `debug` is on **and**
diff --git a/apps/web/content/docs/dev/deployments/self-hosted.mdx b/apps/web/content/docs/dev/deployments/self-hosted.mdx
index 2a4e9544d..7b78c37d3 100644
--- a/apps/web/content/docs/dev/deployments/self-hosted.mdx
+++ b/apps/web/content/docs/dev/deployments/self-hosted.mdx
@@ -51,21 +51,41 @@ Configure these in your production `.env` file:
```bash title=".env"
# Database (PostgreSQL 15+)
-DATABASE_URL=postgresql://user:password@localhost:5432/vitnode_db
+POSTGRES_URL=postgresql://user:password@localhost:5432/vitnode_db
-# Security & Sessions
-COOKIE_SECRET=change_me_to_a_long_random_secret_string
+# The origin visitors reach. Email links, SSO callbacks and password-reset
+# links are built from it.
+NEXT_PUBLIC_WEB_URL=https://example.com
-# App Networking
-PORT=3000
-API_URL=http://localhost:8000
-WEB_URL=http://localhost:3000
+# Required if anything schedules cron. A long random string.
+CRON_SECRET=change_me_to_a_long_random_secret_string
# Optional: Shared Cache & Realtime
REDIS_URL=redis://localhost:6379
REDIS_PASSWORD=change_me_redis_password
```
+### Where the API lives
+
+A **Single App** answers `/api/*` itself, so leave `NEXT_PUBLIC_API_URL` unset.
+The server takes the API origin off the request being rendered and the browser
+falls back to the origin the page was served from, so the same build is correct
+on every hostname you point at it.
+
+A **split deployment** runs the web app and `apps/api` as two processes, and the
+web app then has to be told where the API is - its own origin has no `/api/*` to
+answer, so an unset value turns every server-side call into a 404, starting with
+the session read on the first render. Set it in the web app's environment, and
+set it before the build: it is compiled into the browser bundle too.
+
+```bash title="apps/web/.env"
+NEXT_PUBLIC_API_URL=http://localhost:8000
+```
+
+A value set here wins over the request origin, which is why a Single App must
+not carry one - `http://localhost:3000` left over from development would point
+production at the visitor's own machine.
+
---
## Reverse Proxy (Nginx Configuration)
diff --git a/apps/web/content/docs/dev/fetcher.mdx b/apps/web/content/docs/dev/fetcher.mdx
index b274d1c07..5b12b49ee 100644
--- a/apps/web/content/docs/dev/fetcher.mdx
+++ b/apps/web/content/docs/dev/fetcher.mdx
@@ -95,7 +95,7 @@ export const fetchDevices = createIsomorphicFn()
})
```
-```tsx title="plugins/devices/src/routes/devices-page.tsx"
+```tsx title="plugins/devices/src/pages/devices-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
import { fetchDevices } from '../lib/fetch-devices'
diff --git a/apps/web/content/docs/dev/i18n/index.mdx b/apps/web/content/docs/dev/i18n/index.mdx
index e908b1d1f..02d7dec12 100644
--- a/apps/web/content/docs/dev/i18n/index.mdx
+++ b/apps/web/content/docs/dev/i18n/index.mdx
@@ -4,6 +4,7 @@ description: How VitNode merges translations from installed packages, and how to
icon: Globe
---
+import { Callout } from 'fumadocs-ui/components/callout'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
VitNode provides full internationalization out of the box. Every package (`@vitnode/core` and plugins) maintains its own locale files, which VitNode merges per request: core strings first, plugin strings second, and your host app overrides last.
@@ -33,30 +34,72 @@ npx vitnode i18n:check
-`i18n:create` creates translation files across your workspace and registers the language in your configuration. `i18n:check` scans for missing or untranslated keys.
+`i18n:create` adds the language to `src/vitnode.config.ts`, seeds a translation file per installed package, and registers the loaders in `src/locales/app.ts`. `i18n:check` scans for missing or untranslated keys - including a file nobody imports, which is the usual reason a translation "does not apply".
---
## Language Configuration
-Declare supported languages in `apps/web/src/i18n.ts`:
+Locale metadata is plain data, so it lives in the browser-safe [shared config](/docs/dev/configuration) - one declaration, read by the router, the document shell and (in a single app) the API that sends your emails:
+
+```ts title="apps/web/src/vitnode.config.ts"
+export const vitNodeConfig = buildConfig({
+ i18n: {
+ defaultLocale: 'en',
+ locales: [
+ { code: 'en', name: 'English' },
+ // [!code ++:1]
+ { code: 'de', name: 'Deutsch' },
+ ],
+ timeZone: 'UTC',
+ },
+ // ...
+})
+```
-```ts title="apps/web/src/i18n.ts"
-export const i18n = {
- defaultLocale: 'en',
- locales: [
- { code: 'en', name: 'English' },
- // [!code ++:1]
- { code: 'de', name: 'Deutsch' },
- ],
-}
+`timeZone` is explicit on purpose: your app renders on a server, and without one `use-intl` formats dates in whatever zone the server happens to run in - then warns that the client will disagree.
+
+Because `buildConfig` keeps those codes as literal types, `'de'` is now part of your `Locale` union and a typo in `defaultLocale` is a type error.
+
+---
+
+## Where the loaders go
+
+A `() => import('./de.json')` reads a file out of a package's build output, so it is the one part of i18n that must never reach a browser. Two files own it, and both are registered through the **server-only** config:
+
+| File | Holds |
+| :------------------------ | :--------------------------------------------------- |
+| `src/locales/packages.ts` | one loader per language each installed package ships |
+| `src/locales/app.ts` | your own rewordings, merged last |
+
+```ts title="apps/web/src/vitnode.server.config.ts"
+export const vitNodeServerConfig = buildServerConfig({
+ config: vitNodeConfig, // the locale list above
+ messages: appMessages, // src/locales/app.ts
+ packageMessages, // src/locales/packages.ts
+})
+```
+
+
+ Putting a loader in the shared config puts every plugin's AdminCP copy in your
+ browser bundle, and makes your Vite build execute it. `vitnode i18n:create`
+ writes to the right file for you.
+
+
+Adding a language to a package that ships it needs one line in `src/locales/packages.ts`:
+
+```ts title="apps/web/src/locales/packages.ts"
+[CORE.pluginId]: {
+ en: async () => await import('@vitnode/core/locales/en.json'),
+ de: async () => await import('@vitnode/core/locales/de.json'), // [!code ++]
+},
```
---
## Overriding Strings
-To customize existing text from core or a third-party plugin, add an override file in `apps/web/src/locales/{pluginId}/{locale}.json`:
+To customize existing text from core or a third-party plugin, add an override file in `apps/web/src/locales/{pluginId}/{locale}.json` and register it in `src/locales/app.ts`:
```json title="apps/web/src/locales/@vitnode/core/en.json"
{
@@ -68,6 +111,15 @@ To customize existing text from core or a third-party plugin, add an override fi
}
```
+```ts title="apps/web/src/locales/app.ts"
+export const appMessages: AppMessagesMap = {
+ // [!code ++:3]
+ en: {
+ '@vitnode/core': async () => await import('./@vitnode/core/en.json'),
+ },
+}
+```
+
Because your app overrides are merged last, only the keys you specify are overwritten. Everything else continues to fall back to the package defaults.
---
@@ -100,4 +152,9 @@ Missing keys automatically fall back to `defaultLocale` (`en`), preventing raw k
description="Localize transactional emails and error responses"
href="/docs/dev/i18n/server"
/>
+
diff --git a/apps/web/content/docs/dev/i18n/namespaces.mdx b/apps/web/content/docs/dev/i18n/namespaces.mdx
index c97485438..2a02c004f 100644
--- a/apps/web/content/docs/dev/i18n/namespaces.mdx
+++ b/apps/web/content/docs/dev/i18n/namespaces.mdx
@@ -76,31 +76,33 @@ namespace.
## Asking for a namespace
-Declare the exact branches a plugin page renders in the manifest. VitNode loads
-them with the route chunk, so public pages do not download a plugin’s AdminCP
-copy just because it exists.
-
-```ts title="plugins/my-plugin/src/routes/manifest.ts"
-export const routes = [
- {
- entry: 'routes/reports-page',
- id: 'reports',
+Declare the exact branches a plugin page renders as the route's `messages`.
+VitNode loads them with the route chunk, so public pages do not download a
+plugin’s AdminCP copy just because it exists.
+
+```ts title="plugins/my-plugin/src/routes.ts"
+import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
+
+export const routes = definePluginRoutes([
+ page('/reports', {
+ component: lazy(() => import('./pages/reports-page')),
// [!code ++]
- namespaces: ['my-plugin.reports'],
- path: '/reports',
- },
-]
+ messages: ['my-plugin.reports'],
+ }),
+])
```
-The route module can then call `useTranslations('my-plugin.reports')`. If a key
-renders as its own name, confirm the manifest declared the matching namespace.
+A route inherits every namespace its layouts declare, so a shared frame can name
+them once for a whole subtree. The route module then calls
+`useTranslations('my-plugin.reports')`; if a key renders as its own name, confirm
+the route declared the matching namespace.
## Limits
A namespace list reaches the server through a server function, which is a public
`POST` endpoint once the app is built. So the rules are enforced rather than
assumed, and the same rules validate a plugin's build-time declaration - one
-definition, in `@vitnode/core/routing`, so a manifest cannot accept something the
+definition, in `@vitnode/core/routing`, so a route tree cannot accept something the
server refuses.
The symptom of a namespace that was never asked for. Check that the route
- declares it in the plugin's `manifest.ts`.
+ declares it in the plugin's `routes.ts`.
import('./pages/about-page')),
+ messages: ['@vitnode/blog.about'], // [!code ++]
+ }),
+])
```
Read strings directly with `useTranslations`:
-```tsx title="plugins/blog/src/routes/about-page.tsx"
+```tsx title="plugins/blog/src/pages/about-page.tsx"
import { useTranslations } from 'use-intl'
const AboutPage = () => {
@@ -53,7 +53,7 @@ And define the messages in `plugins/blog/src/locales/en.json`:
Host messages are for the site shell. A product page should declare its plugin
- namespace in the manifest and keep its locale JSON beside the route.
+ namespace as the route's `messages` and keep its locale JSON beside the route.
## Placeholders and Pluralization
diff --git a/apps/web/content/docs/dev/index.mdx b/apps/web/content/docs/dev/index.mdx
index e6c18eff6..4fa97d097 100644
--- a/apps/web/content/docs/dev/index.mdx
+++ b/apps/web/content/docs/dev/index.mdx
@@ -86,9 +86,9 @@ route to visit—not just a philosophical plugin.
}
- title="Plugin route manifest"
+ title="Plugin routes"
description="Claim public or AdminCP URLs without adding host route files."
- href="/docs/dev/plugins/route-manifest"
+ href="/docs/dev/plugins/routes"
/>
}
diff --git a/apps/web/content/docs/dev/meta.json b/apps/web/content/docs/dev/meta.json
index 23681d534..f361fb9c7 100644
--- a/apps/web/content/docs/dev/meta.json
+++ b/apps/web/content/docs/dev/meta.json
@@ -9,6 +9,7 @@
"setup",
"deployments",
"architecture",
+ "configuration",
"---Framework---",
"plugins",
"routing",
diff --git a/apps/web/content/docs/dev/performance.mdx b/apps/web/content/docs/dev/performance.mdx
index 6a9bd0439..3aa3df1de 100644
--- a/apps/web/content/docs/dev/performance.mdx
+++ b/apps/web/content/docs/dev/performance.mdx
@@ -38,7 +38,7 @@ TanStack Router automatically extracts route `component`, `errorComponent`, and
Because `head` is evaluated in the main route bundle, importing strings from component files accidentally pulls entire component trees into the initial download:
-```tsx title="plugins/home/src/routes/home-page.tsx"
+```tsx title="plugins/home/src/pages/home-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
// BAD: Pulls HomeRouteContent and all its heavy icons/charts into main entry
@@ -85,7 +85,7 @@ export const ArticleEditor = (props) => (
When a route loader requires a heavy calculation or parsing library, import it dynamically:
-```tsx title="plugins/stats/src/routes/stats-page.tsx"
+```tsx title="plugins/stats/src/pages/stats-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
export const route = definePluginRoute({
diff --git a/apps/web/content/docs/dev/plugins/admin/index.mdx b/apps/web/content/docs/dev/plugins/admin/index.mdx
index 34e460df5..7b47b1e53 100644
--- a/apps/web/content/docs/dev/plugins/admin/index.mdx
+++ b/apps/web/content/docs/dev/plugins/admin/index.mdx
@@ -16,18 +16,16 @@ plugin supplies the useful bit.
### Claim an AdminCP route
-```ts title="plugins/site-notes/src/routes/manifest.ts"
-import type { PluginRouteDefinition } from '@vitnode/core/routing'
+```ts title="plugins/site-notes/src/routes.ts"
+import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
-export const routes: PluginRouteDefinition[] = [
- // [!code ++:6]
- {
+export const routes = definePluginRoutes([
+ // [!code ++:4]
+ page('/admin/site-notes/settings', {
area: 'admin',
- entry: 'routes/admin-settings-page',
- id: 'settings',
- path: '/admin/site-notes/settings',
- },
-]
+ component: lazy(() => import('./pages/admin-settings-page')),
+ }),
+])
```
@@ -35,7 +33,7 @@ export const routes: PluginRouteDefinition[] = [
### Render the plugin page
-```tsx title="plugins/site-notes/src/routes/admin-settings-page.tsx"
+```tsx title="plugins/site-notes/src/pages/admin-settings-page.tsx"
const AdminSettingsPage = () => (
Site notes settings
@@ -135,8 +133,8 @@ the declared permission.
/>
}
- title="Route manifest"
+ title="Plugin routes"
description="Configure route areas, namespaces, guards, and parameters."
- href="/docs/dev/plugins/route-manifest"
+ href="/docs/dev/plugins/routes"
/>
diff --git a/apps/web/content/docs/dev/plugins/breadcrumbs.mdx b/apps/web/content/docs/dev/plugins/breadcrumbs.mdx
index 9654388b9..528aa3f5f 100644
--- a/apps/web/content/docs/dev/plugins/breadcrumbs.mdx
+++ b/apps/web/content/docs/dev/plugins/breadcrumbs.mdx
@@ -1,53 +1,118 @@
---
title: Breadcrumbs
-description: Declare localized breadcrumbs in plugin route modules for public pages and AdminCP screens.
+description: Contribute one localized crumb per plugin route and let VitNode assemble the trail for public pages and AdminCP screens.
icon: Milestone
---
-VitNode provides breadcrumb slots in both the AdminCP header and public site
-layout. The deepest plugin route that declares a breadcrumb wins.
+VitNode renders a breadcrumb trail in both the AdminCP header and the public
+site layout. **Every matched route contributes one crumb**, parent to child:
+
+```text
+Home / Catalog / Products / Laptops / MacBook Pro
+```
+
+So a route says what it is called and nothing else. VitNode owns the separators,
+the `nav` and `aria-current` semantics, and the locale-aware link to each
+route's own URL—a plugin never builds a router link, and never restates the
+crumbs of the layouts above it.
## Quick start
-### Add a plugin breadcrumb
+### A static, translated crumb
-Plugin routes export a breadcrumb component on `definePluginRoute`:
+Declare a component on `definePluginRoute`. It renders inside the message
+namespaces the route declared, so `useTranslations` just works:
-```tsx title="plugins/blog/src/routes/overview-page.tsx"
+```tsx title="plugins/catalog/src/pages/products-layout.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
import { useTranslations } from 'use-intl'
-const OverviewBreadcrumb = () => {
- const t = useTranslations('@vitnode/blog')
- return {t('overview')}
+function ProductsBreadcrumb() {
+ const t = useTranslations('@acme/catalog')
+
+ return t('breadcrumbs.products')
}
// [!code ++:3]
export const route = definePluginRoute({
- breadcrumb: OverviewBreadcrumb,
+ breadcrumb: ProductsBreadcrumb,
})
```
-The crumb inherits the route's declared `namespaces`, so `useTranslations` resolves seamlessly.
+### A crumb read from the loader
+
+The crumb is handed **its own** match's data, so a dynamic route can name itself
+with what it fetched—no second request, and no guessing from the URL:
+
+```tsx title="plugins/catalog/src/pages/category-layout.tsx"
+import type { PluginRouteBreadcrumbProps } from '@vitnode/core/routing'
+import { definePluginRoute } from '@vitnode/core/routing'
+
+interface Category {
+ name: string
+}
+
+function CategoryBreadcrumb({
+ loaderData,
+}: PluginRouteBreadcrumbProps) {
+ return loaderData.name
+}
+
+export const route = definePluginRoute({
+ load: async ({ params }) => await fetchCategory(params.categorySlug),
+ breadcrumb: CategoryBreadcrumb,
+})
+```
-## Best Practices
+`PluginRouteBreadcrumbProps` carries `loaderData`, `params` and
+`search`—the same three names the loader, `head` and the page component receive.
-
- AdminCP labels resolve from the plugin navigation dictionary. Keep the route,
- sidebar entry, and translations in the same package.
+### Leaving a route out
+
+A route that declares no `breadcrumb` contributes nothing, and its parents' crumbs
+stay exactly where they were. `false` says the same thing on purpose, which is
+worth doing when a page's frame already names the screen:
+
+```tsx title="plugins/catalog/src/pages/products-index-page.tsx"
+export const route = definePluginRoute({
+ breadcrumb: false, // [!code ++]
+})
+```
+
+## Rules
+
+| Declaration | What the trail does |
+| ---------------------- | ------------------------------------------------------- |
+| A component | One crumb, given this route's loader data and params |
+| `false` | This route is left out; its parents' crumbs remain |
+| Nothing at all | The same, said by omission |
+| The last crumb | Rendered as the current page, not as a link |
+| Every other crumb | A locale-aware link to that route's own URL |
+
+
+ An AdminCP screen's trail is named by the sidebar this administrator can
+ actually see, so a plugin that adds a nav entry gets its label for free—in
+ every language. Keep the route, the sidebar entry and the translations in the
+ same package.
+
+
+
+ A crumb returns text or an element. Do not render a ``, a
+ separator, or a link: the shell draws the trail *above* the page outlet and
+ needs each crumb as one item so it can put them in one navigation landmark.
## Learn More
+
-
diff --git a/apps/web/content/docs/dev/plugins/create.mdx b/apps/web/content/docs/dev/plugins/create.mdx
index 03a0fa322..61bfdff00 100644
--- a/apps/web/content/docs/dev/plugins/create.mdx
+++ b/apps/web/content/docs/dev/plugins/create.mdx
@@ -50,20 +50,18 @@ feature for the host—that explicit switch is next.
### Keep the route in the plugin
-The generated manifest is the public contract. Add another record here when the
-plugin needs another URL; never copy its page into `apps/web/src/routes`.
-
-```ts title="plugins/site-notes/src/routes/manifest.ts"
-import type { PluginRouteDefinition } from '@vitnode/core/routing'
-
-export const routes: PluginRouteDefinition[] = [
- // [!code ++:5]
- {
- entry: 'routes/home-page',
- id: 'home',
- path: '/site-notes',
- },
-]
+The generated `routes.ts` is the public contract. Add another `page()` here when
+the plugin needs another URL; never copy its page into `apps/web/src/routes`.
+
+```ts title="plugins/site-notes/src/routes.ts"
+import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
+
+export const routes = definePluginRoutes([
+ // [!code ++:3]
+ page('/site-notes', {
+ component: lazy(() => import('./pages/home-page')),
+ }),
+])
```
@@ -74,8 +72,8 @@ export const routes: PluginRouteDefinition[] = [
Import the plugin factory in the host config and add it to `plugins`:
```ts title="apps/web/src/vitnode.config.ts"
-import { buildConfig } from '@vitnode/core/vitnode.config'
import { siteNotesPlugin } from '@acme/site-notes/config' // [!code ++]
+import { buildConfig } from '@vitnode/core/vitnode.config'
export const vitNodeConfig = buildConfig({
plugins: [
@@ -84,8 +82,14 @@ export const vitNodeConfig = buildConfig({
})
```
-The factory already carries the plugin's manifest and message loaders. Keep host
-configuration to composition; the feature stays in its package.
+That is the only composition step - the factory carries the plugin's routes,
+content types and AdminCP navigation, and the feature stays in its package. Your
+build reads this list and generates one literal import per plugin for each of
+those, so a page or an editing screen loads with the route that needs it rather
+than with the config. See [Configuration](/docs/dev/configuration).
+
+Its translations need one more line, in `src/locales/packages.ts` - see
+[Languages & Localization](/docs/dev/i18n).
@@ -111,7 +115,7 @@ npm run dev
Open `http://localhost:3000/site-notes`. The page comes from the plugin, gets
its own chunk, and never moves house. Tiny victory dance optional.
-{/* Image prompt: Split-screen developer tutorial image. Left shows a plugin folder with manifest, locale, and route files. Right shows the resulting /site-notes page in a VitNode app. Dark theme, precise code-like labels, 1600x900. */}
+{/* Image prompt: Split-screen developer tutorial image. Left shows a plugin folder with routes.ts, locale, and pages files. Right shows the resulting /site-notes page in a VitNode app. Dark theme, precise code-like labels, 1600x900. */}
@@ -121,9 +125,9 @@ its own chunk, and never moves house. Tiny victory dance optional.
}
- title="Route manifest"
- description="Add dynamic URLs, loaders, metadata, and route guards."
- href="/docs/dev/plugins/route-manifest"
+ title="Plugin routes"
+ description="Add nested layouts, dynamic URLs, loaders, metadata, and guards."
+ href="/docs/dev/plugins/routes"
/>
}
diff --git a/apps/web/content/docs/dev/plugins/meta.json b/apps/web/content/docs/dev/plugins/meta.json
index 5d0907701..f07d9e1fc 100644
--- a/apps/web/content/docs/dev/plugins/meta.json
+++ b/apps/web/content/docs/dev/plugins/meta.json
@@ -3,5 +3,5 @@
"description": "Build installable VitNode plugins for pages, APIs, data, AdminCP screens, and translations",
"icon": "Plug",
"defaultOpen": true,
- "pages": ["create", "route-manifest", "api", "admin", "breadcrumbs", "..."]
+ "pages": ["create", "routes", "api", "admin", "breadcrumbs", "..."]
}
diff --git a/apps/web/content/docs/dev/plugins/route-manifest.mdx b/apps/web/content/docs/dev/plugins/route-manifest.mdx
deleted file mode 100644
index b3d662979..000000000
--- a/apps/web/content/docs/dev/plugins/route-manifest.mdx
+++ /dev/null
@@ -1,128 +0,0 @@
----
-title: Route Manifest
-description: Declare a plugin-owned TanStack Start route with a stable URL, loader data, metadata, and no host page files.
-icon: Map
----
-
-import { DatabaseIcon, LayoutDashboardIcon, RouteIcon } from 'lucide-react'
-import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
-
-Start by [creating a plugin](/docs/dev/plugins/create). A route manifest is the
-plugin's promise: which URL it owns and which module renders it. The host turns
-that promise into a lazy TanStack Start route—no copied page files, no drama.
-
-{/* Image prompt: Dark-theme developer diagram: a plugin route manifest points to a route module, then into a TanStack Start route inside the app shell. Emphasize “plugin owns feature” and “host composes”. Clean labels, 1600x900. */}
-
-
-
-
-### Declare the URL in the plugin
-
-```ts title="plugins/site-notes/src/routes/manifest.ts"
-import type { PluginRouteDefinition } from '@vitnode/core/routing'
-
-export const routes: PluginRouteDefinition[] = [
- // [!code ++:5]
- {
- entry: 'routes/note-page',
- id: 'note',
- path: '/notes/:slug',
- },
-]
-```
-
-Use `:slug` for dynamic segments. VitNode converts it to TanStack Start's
-internal `$slug` spelling while keeping your plugin manifest portable.
-
-
-
-
-### Keep behavior beside the page
-
-```tsx title="plugins/site-notes/src/routes/note-page.tsx"
-import type { PluginRoutePageProps } from '@vitnode/core/routing'
-import { definePluginRoute } from '@vitnode/core/routing'
-
-interface Note {
- title: string
-}
-
-// [!code ++:8]
-export const route = definePluginRoute({
- load: async ({ params }) => ({ title: `Note: ${params.slug}` }),
- head: ({ loaderData }) => ({
- description: 'A note delivered by the Site notes plugin.',
- title: loaderData?.title,
- }),
-})
-
-const NotePage = ({ loaderData }: PluginRoutePageProps) => (
-
-
{loaderData.title}
-
-)
-
-export default NotePage
-```
-
-
-
-
-### Run the plugin route
-
-
-
-```bash tab="bun"
-bun dev
-```
-
-```bash tab="pnpm"
-pnpm dev
-```
-
-```bash tab="npm"
-npm run dev
-```
-
-
-
-Visit `http://localhost:3000/notes/hello`. The page's code, data, and SEO stay
-with the feature that needs them. A surprisingly polite route.
-
-
-
-
-## Choose the route shape
-
-| Need | Add to the manifest |
-| -------------------- | ------------------------------------------------------ |
-| Public feature page | `path: '/notes'` (the default `area` is `main`) |
-| Staff screen | `area: 'admin'` and a full path such as `/admin/notes` |
-| Signed-in visitor | `requires: 'authenticated'` |
-| Shared plugin layout | `kind: 'layout'` plus child `parentId` values |
-
-
- Use host routes only for shells, docs, or site-wide infrastructure. A product
- page belongs in its plugin, even when it starts life as one brave little URL.
-
-
-
- }
- title="Load data"
- description="Use plugin loaders with cache-aware data and query state."
- href="/docs/dev/data-loading"
- />
- }
- title="AdminCP pages"
- description="Mount a plugin screen in the staff-only Admin Control Panel."
- href="/docs/dev/plugins/admin"
- />
- }
- title="Build the plugin"
- description="Generate the package before adding its next route or capability."
- href="/docs/dev/plugins/create"
- />
-
diff --git a/apps/web/content/docs/dev/plugins/routes.mdx b/apps/web/content/docs/dev/plugins/routes.mdx
new file mode 100644
index 000000000..1340aa445
--- /dev/null
+++ b/apps/web/content/docs/dev/plugins/routes.mdx
@@ -0,0 +1,435 @@
+---
+title: Plugin Routes
+description: Declare plugin-owned URLs as a nested route tree with lazy pages, loaders, metadata, messages, and breadcrumbs.
+icon: Map
+---
+
+import { DatabaseIcon, LayoutDashboardIcon, MilestoneIcon } from 'lucide-react'
+import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
+
+Start by [creating a plugin](/docs/dev/plugins/create). A plugin's `src/routes.ts`
+is its promise to the app: which URLs it owns, and which module renders each one.
+The host turns that promise into lazy TanStack Start routes—no copied page files,
+no drama.
+
+{/* Image prompt: Dark-theme developer diagram: a plugin routes.ts tree (layout → index → dynamic page) on the left, each node pointing at a lazily loaded page chunk on the right, then into a TanStack Start route inside the app shell. Emphasize “plugin owns feature”, “one chunk per page”, “host composes”. Clean labels, 1600x900. */}
+
+
+
+
+### Declare the URL in the plugin
+
+```ts title="plugins/site-notes/src/routes.ts"
+import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
+
+export const routes = definePluginRoutes([
+ // [!code ++:3]
+ page('/notes/:slug', {
+ component: lazy(() => import('./pages/note-page')),
+ }),
+])
+```
+
+Use `:slug` for dynamic segments. VitNode converts it to TanStack Start's
+internal `$slug` spelling while keeping your plugin portable.
+
+
+
+
+### Keep behavior beside the page
+
+```tsx title="plugins/site-notes/src/pages/note-page.tsx"
+import type { PluginRoutePageProps } from '@vitnode/core/routing'
+import { definePluginRoute } from '@vitnode/core/routing'
+
+interface Note {
+ title: string
+}
+
+// [!code ++:8]
+export const route = definePluginRoute({
+ load: async ({ params }) => ({ title: `Note: ${params.slug}` }),
+ head: ({ loaderData }) => ({
+ description: 'A note delivered by the Site notes plugin.',
+ title: loaderData?.title,
+ }),
+})
+
+const NotePage = ({ loaderData }: PluginRoutePageProps) => (
+
+
{loaderData.title}
+
+)
+
+export default NotePage
+```
+
+
+
+
+### Run the plugin route
+
+
+
+```bash tab="bun"
+bun dev
+```
+
+```bash tab="pnpm"
+pnpm dev
+```
+
+```bash tab="npm"
+npm run dev
+```
+
+
+
+Visit `http://localhost:3000/notes/hello`. The page's code, data, and SEO stay
+with the feature that needs them. A surprisingly polite route.
+
+
+
+
+## What `lazy(() => import('./pages/note-page'))` means
+
+It names the module VitNode loads **when the route is needed**—on a navigation,
+or a moment earlier when the visitor hovers a link and the router preloads it.
+
+Nothing about that import runs while your app boots. `lazy` stores the callback;
+Vite reads the literal `import()` inside it at build time and Rollup gives that
+page a chunk of its own. So `routes.ts` stays a few lines of data the app can
+hold cheaply, and a visitor downloads a page only if they open it.
+
+
+ Importing the component at the top of `routes.ts` would put it in the initial
+ bundle of *every* page on the site, and route-level splitting would be gone.
+ VitNode refuses it in the types and again at build time, with the replacement
+ in the message:
+
+```ts
+import NotePage from './pages/note-page'
+
+page('/notes/:slug', {
+ component: NotePage, // [!code --]
+ component: lazy(() => import('./pages/note-page')), // [!code ++]
+})
+```
+
+
+
+Keep the `import()` literal. A specifier built from a variable is not something
+a bundler can follow, so the page never gets a chunk and the build cannot tell
+you the module is missing:
+
+```ts
+page('/notes/:slug', {
+ component: lazy(() => import(`./pages/${slug}-page`)), // [!code --]
+ component: lazy(() => import('./pages/note-page')), // [!code ++]
+})
+```
+
+## Nest routes with `layout()` and `index()`
+
+A `layout()` renders a frame around its `children` and claims no URL of its own.
+`index()` is the route that renders at the layout's own URL. Every path inside a
+layout is **relative** to it, so moving a subtree is one edit:
+
+```ts title="plugins/catalog/src/routes.ts"
+import {
+ definePluginRoutes,
+ index,
+ layout,
+ lazy,
+ page,
+} from '@vitnode/core/routing'
+
+export const routes = definePluginRoutes([
+ layout('/catalog', {
+ component: lazy(() => import('./pages/catalog-layout')),
+ messages: ['@acme/catalog'],
+ children: [
+ page('dashboard', {
+ component: lazy(() => import('./pages/dashboard-page')),
+ }),
+
+ layout('products', {
+ component: lazy(() => import('./pages/products-layout')),
+ children: [
+ index({
+ component: lazy(() => import('./pages/products-index-page')),
+ }),
+
+ layout(':categorySlug', {
+ component: lazy(() => import('./pages/category-layout')),
+ children: [
+ index({
+ component: lazy(() => import('./pages/category-index-page')),
+ }),
+
+ page(':productId', {
+ component: lazy(() => import('./pages/product-page')),
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ }),
+])
+```
+
+That tree serves `/catalog/dashboard`, `/catalog/products`,
+`/catalog/products/laptops` and `/catalog/products/laptops/42`, and a page opens
+inside every frame above it.
+
+| Rule | What VitNode does |
+| ------------------------ | ------------------------------------------------------------- |
+| Top-level path | Absolute: `page('/catalog', …)` |
+| Nested path | Relative: `page('dashboard', …)` joins onto its parent |
+| `index()` | The child at the layout's exact URL—no path of its own |
+| Layout with no `children`| A build error: nothing could ever render it |
+| Route ids | Derived by VitNode while flattening. You never write one |
+
+A layout's frame is a component with `children`:
+
+```tsx title="plugins/catalog/src/pages/catalog-layout.tsx"
+const CatalogLayout = ({ children }: { children: React.ReactNode }) => (
+
+
Catalog
+ {children}
+
+)
+
+export default CatalogLayout
+```
+
+`children`, not an ``: a plugin layout that imported a router's outlet
+could only be installed into one kind of app.
+
+## Choose the route shape
+
+| Need | Add to the tree |
+| -------------------- | -------------------------------------------------------------- |
+| Public feature page | `page('/notes', { component })`—`area` defaults to `main` |
+| Staff screen | `area: 'admin'` and a full path such as `/admin/notes` |
+| Signed-in visitor | `requires: 'authenticated'` |
+| Shared frame | `layout()` with `children` |
+| Translated strings | `messages: ['@acme/catalog']` |
+| URL-as-state | `search: productsSearchSchema` |
+
+## An AdminCP route
+
+`area: 'admin'` picks the shell—the sidebar, the breadcrumb area, the command
+palette, and the admin session guard. It never changes the path, so write the
+`/admin/…` URL in full:
+
+```ts title="plugins/site-notes/src/routes.ts"
+page('/admin/notes', {
+ area: 'admin', // [!code ++]
+ component: lazy(() => import('./pages/admin-notes-page')),
+ messages: ['@acme/site-notes.admin'],
+})
+```
+
+`area` belongs to top-level routes only. Everything inside a layout renders in
+the shell that layout renders in, and `requires` is refused in the admin
+area—the AdminCP has its own session, and a staff permission gates the page's
+*content*. See [AdminCP pages](/docs/dev/plugins/admin).
+
+## Route messages
+
+`messages` lists the translation namespaces the route renders. VitNode warms
+them **alongside** the page's chunk instead of after it, which is the whole
+reason they are declared on the route rather than inside the module:
+
+```ts
+layout('/catalog', {
+ component: lazy(() => import('./pages/catalog-layout')),
+ messages: ['@acme/catalog'], // [!code ++]
+ children: [index({ component: lazy(() => import('./pages/index-page')) })],
+})
+```
+
+A route inherits every namespace its layouts declare, so naming them once on the
+frame is enough for the whole subtree. Inside the module, read them with
+`use-intl`:
+
+```tsx
+import { useTranslations } from 'use-intl'
+
+const CatalogIndexPage = () => {
+ const t = useTranslations('@acme/catalog')
+
+ return
{t('index.intro')}
+}
+```
+
+See [namespaces](/docs/dev/i18n/namespaces) for how a namespace is named and
+where its JSON lives.
+
+## `search` is the one eager field
+
+TanStack Router validates a URL's query string **while it matches the URL**,
+before any chunk is fetched. A schema inside the lazy page module would arrive
+too late, so a route declares it in `routes.ts`:
+
+```ts title="plugins/catalog/src/routes.ts"
+import { productsSearchSchema } from './pages/products-search'
+
+page('/catalog/products', {
+ component: lazy(() => import('./pages/products-page')),
+ search: productsSearchSchema, // [!code ++]
+})
+```
+
+```ts title="plugins/catalog/src/pages/products-search.ts"
+export interface ProductsSearch {
+ page: number
+}
+
+export const productsSearchSchema = (
+ input: Record,
+): ProductsSearch => {
+ const parsed = Number.parseInt(String(input.page ?? ''), 10)
+
+ // Total, never throwing: the router calls this on whatever somebody pasted.
+ return { page: Number.isFinite(parsed) ? Math.max(parsed, 1) : 1 }
+}
+```
+
+The page then gets a typed `search` and a `navigate` that changes it:
+
+```tsx title="plugins/catalog/src/pages/products-page.tsx"
+import type { PluginRoutePageProps } from '@vitnode/core/routing'
+
+import type { ProductsSearch } from './products-search'
+
+const ProductsPage = ({
+ navigate,
+ search,
+}: PluginRoutePageProps) => (
+
+)
+
+export default ProductsPage
+```
+
+TypeScript checks the two halves against each other: the schema has to return
+what the page says it reads, even though the page itself is lazy.
+
+
+ `search` is a function, so it lives in `routes.ts`—which the app imports
+ statically. Everything that file imports is in the initial bundle with it, so
+ keep the schema module small: no React, no component, no import of the page it
+ belongs to.
+
+ Declare it only for a screen whose URL *is* its state—a paginated list whose
+ `?page=999` has to be clamped, a filter whose links must be typed. For a page
+ that merely reads a parameter, use the module's own lazy `parseSearch`
+ instead; it normalises in the loader and adds nothing to the initial bundle.
+
+
+## Dynamic breadcrumbs
+
+Every matched route contributes **one crumb**, parent to child, and VitNode owns
+the separators, the accessibility semantics, and the locale-aware links. A crumb
+returns a label:
+
+```tsx title="plugins/catalog/src/pages/product-page.tsx"
+import type {
+ PluginRouteBreadcrumbProps,
+ PluginRoutePageProps,
+} from '@vitnode/core/routing'
+import { definePluginRoute } from '@vitnode/core/routing'
+
+interface Product {
+ description: string
+ name: string
+}
+
+function ProductBreadcrumb({ loaderData }: PluginRouteBreadcrumbProps) {
+ return loaderData.name
+}
+
+export const route = definePluginRoute({
+ load: async ({ params }) =>
+ await fetchProduct({
+ categorySlug: params.categorySlug,
+ productId: params.productId,
+ }),
+
+ head: ({ loaderData }) => ({
+ description: loaderData?.description,
+ title: loaderData?.name,
+ }),
+
+ breadcrumb: ProductBreadcrumb,
+})
+
+export default function ProductPage({
+ loaderData,
+}: PluginRoutePageProps) {
+ return (
+
+
{loaderData.name}
+
{loaderData.description}
+
+ )
+}
+```
+
+With the catalog tree above, that renders `Catalog / Products / Laptops /
+MacBook Pro`—each crumb from the route that owns it. See
+[breadcrumbs](/docs/dev/plugins/breadcrumbs) for static crumbs, `breadcrumb:
+false`, and how the trail is assembled.
+
+
+ Use host routes only for shells, docs, or site-wide infrastructure. A product
+ page belongs in its plugin, even when it starts life as one brave little URL.
+
+
+## How the app picks this up
+
+The `vitnode:plugin-routes` Vite plugin reads the plugins in
+`src/vitnode.config.ts`, imports each one's `routes` module in Node, validates
+and flattens every tree, refuses two routes that claim one URL—including one of
+the app's own—and writes a single `src/plugin-routes.gen.ts`:
+
+```ts title="apps/web/src/plugin-routes.gen.ts"
+import { routes as pluginRoutes0 } from '@acme/catalog/routes'
+
+export const pluginRouteSources = [
+ { pluginId: '@acme/catalog', routes: pluginRoutes0 },
+] as const satisfies readonly PluginRouteDeclarationSource[]
+```
+
+That is the only generated file, it names no page module, and it is committed
+like any other generated artefact. Your pages stay in your package's own
+`dist`, one chunk each.
+
+
+ }
+ title="Load data"
+ description="Use plugin loaders with cache-aware data and query state."
+ href="/docs/dev/data-loading"
+ />
+ }
+ title="Breadcrumbs"
+ description="Contribute one crumb per route, translated or read from a loader."
+ href="/docs/dev/plugins/breadcrumbs"
+ />
+ }
+ title="AdminCP pages"
+ description="Mount a plugin screen in the staff-only Admin Control Panel."
+ href="/docs/dev/plugins/admin"
+ />
+
diff --git a/apps/web/content/docs/dev/routing/index.mdx b/apps/web/content/docs/dev/routing/index.mdx
index 36727ce05..48280712d 100644
--- a/apps/web/content/docs/dev/routing/index.mdx
+++ b/apps/web/content/docs/dev/routing/index.mdx
@@ -1,15 +1,16 @@
---
title: Routing
-description: Add public and AdminCP URLs through plugin route manifests, with TanStack Start loaders, metadata, and code splitting.
+description: Add public and AdminCP URLs through a plugin's route tree, with TanStack Start loaders, metadata, and code splitting.
icon: Route
---
import { DatabaseIcon, FileTextIcon, RouteIcon } from 'lucide-react'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
-VitNode uses TanStack Start, but feature routes begin in a plugin. The manifest
-is plain data that the host compiles into lazy route imports, so the package owns
-the page without copying files into `apps/web/src/routes`.
+VitNode uses TanStack Start, but feature routes begin in a plugin. A plugin's
+`routes.ts` is a small tree of declarations - a path, and the module that renders
+it - so the package owns the page without copying files into
+`apps/web/src/routes`.
| Put it in | Use it for | Default |
| ---------------- | --------------------------------------------------------------- | -------- |
@@ -22,28 +23,30 @@ the page without copying files into `apps/web/src/routes`.
### Declare the plugin URL
-```ts title="plugins/site-notes/src/routes/manifest.ts"
-import type { PluginRouteDefinition } from '@vitnode/core/routing'
-
-export const routes: PluginRouteDefinition[] = [
- // [!code ++:5]
- {
- entry: 'routes/notes-page',
- id: 'notes',
- path: '/notes',
- },
-]
+```ts title="plugins/site-notes/src/routes.ts"
+import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
+
+export const routes = definePluginRoutes([
+ // [!code ++:3]
+ page('/notes', {
+ component: lazy(() => import('./pages/notes-page')),
+ }),
+])
```
-Use `:slug` for a dynamic segment, such as `path: '/notes/:slug'`. VitNode
+Use `:slug` for a dynamic segment, such as `page('/notes/:slug', …)`. VitNode
maps it to TanStack Start’s internal `$slug` spelling for you.
+`lazy` names the page module without importing it: the literal `import()` is
+what Vite follows to give the page a chunk of its own, and it does not run until
+somebody opens the route.
+
### Add the page module
-```tsx title="plugins/site-notes/src/routes/notes-page.tsx"
+```tsx title="plugins/site-notes/src/pages/notes-page.tsx"
const NotesPage = () => (
Site notes
@@ -87,7 +90,7 @@ Open `http://localhost:3000/notes`.
Export `route` from the same plugin module. Declare `load` before `head` so
TypeScript carries the inferred data into your metadata:
-```tsx title="plugins/site-notes/src/routes/note-page.tsx"
+```tsx title="plugins/site-notes/src/pages/note-page.tsx"
import type { PluginRoutePageProps } from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
@@ -126,9 +129,9 @@ export default NotePage
}
- title="Route manifest reference"
- description="Configure areas, layouts, namespaces, guards, and dynamic paths."
- href="/docs/dev/plugins/route-manifest"
+ title="Plugin routes reference"
+ description="Configure areas, layouts, messages, guards, search, and dynamic paths."
+ href="/docs/dev/plugins/routes"
/>
}
diff --git a/apps/web/content/docs/dev/routing/loading-states.mdx b/apps/web/content/docs/dev/routing/loading-states.mdx
index 042b31618..23a1b7cc7 100644
--- a/apps/web/content/docs/dev/routing/loading-states.mdx
+++ b/apps/web/content/docs/dev/routing/loading-states.mdx
@@ -14,7 +14,7 @@ While a route's loader runs or its code chunk downloads, TanStack Router display
Plugin routes load dynamically. Use React `Suspense` with VitNode's pending skeletons:
-```tsx title="plugins/blog/src/routes/posts-page.tsx"
+```tsx title="plugins/blog/src/pages/posts-page.tsx"
import { FeedPendingSkeleton } from '@vitnode/core/tanstack/pending'
import React, { Suspense } from 'react'
diff --git a/apps/web/content/docs/dev/routing/metadata.mdx b/apps/web/content/docs/dev/routing/metadata.mdx
index d9c3fb0d5..042998e5e 100644
--- a/apps/web/content/docs/dev/routing/metadata.mdx
+++ b/apps/web/content/docs/dev/routing/metadata.mdx
@@ -14,7 +14,7 @@ written by a toaster.
Plugins declare metadata using `definePluginRoute`. Metadata can read dynamically from `loaderData`:
-```tsx title="plugins/blog/src/routes/article-page.tsx"
+```tsx title="plugins/blog/src/pages/article-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
export const route = definePluginRoute({
@@ -51,8 +51,8 @@ inside the plugin that owns those records.
href="/docs/dev/routing"
/>
diff --git a/apps/web/content/docs/dev/routing/navigation.mdx b/apps/web/content/docs/dev/routing/navigation.mdx
index 7a2895518..9267023d8 100644
--- a/apps/web/content/docs/dev/routing/navigation.mdx
+++ b/apps/web/content/docs/dev/routing/navigation.mdx
@@ -68,7 +68,7 @@ Plugin route props expose a narrow `navigate` function for filters, sorting, and
pagination on the page already being viewed. It keeps the plugin independent of
the host router’s entire route tree.
-```tsx title="plugins/catalog/src/routes/catalog-page.tsx"
+```tsx title="plugins/catalog/src/pages/catalog-page.tsx"
import type { PluginRoutePageProps } from '@vitnode/core/routing'
interface CatalogSearch {
diff --git a/apps/web/content/docs/dev/routing/not-found.mdx b/apps/web/content/docs/dev/routing/not-found.mdx
index 89be0491c..721981f0b 100644
--- a/apps/web/content/docs/dev/routing/not-found.mdx
+++ b/apps/web/content/docs/dev/routing/not-found.mdx
@@ -4,13 +4,36 @@ description: Configure root and per-route 404 error boundaries and throw notFoun
icon: SearchX
---
-In TanStack Start, 404 handling is configured as a route option via `notFoundComponent` rather than a static file. VitNode provides localized 404 layouts with history-aware navigation buttons out of the box.
+In TanStack Start, 404 handling is configured as a route option via `notFoundComponent` rather than a static file. VitNode provides localized 404 layouts with history-aware navigation buttons out of the box — and mounts the one your visitors actually hit for you.
-{/* Image prompt: VitNode 404 error page showing large 404 typography, localized "Page Not Found" title, descriptive message, and "Go back" / "Back to home" action buttons. Dark theme, 1440x900. */}
+{/* Image prompt: VitNode 404 error page showing the site header above large 404 typography, localized "Page Not Found" title, descriptive message, and "Go back" / "Back to home" action buttons. Dark theme, 1440x900. */}
+
+## Mistyped URLs get the full site
+
+A URL no route claims is answered by a catch-all that `withCoreMainRoutes` mounts **inside your main shell**. So a visitor who mistypes a link gets the site header, the same `` landmark every page renders in, and a way back — plus a real `404` status for the crawlers, because the route answers `notFound()` from `beforeLoad` rather than quietly rendering a `200`.
+
+You do not wire this up. It arrives with the mount your `router.tsx` already has:
+
+```tsx title="apps/web/src/router.tsx"
+const routeTree = withCoreMainRoutes(fileRouteTree, {
+ localeRouting,
+ mountUnder: mainShellRoute,
+ pageHead,
+})
+```
+
+
+ Why a route and not a `notFoundComponent` on `_main`? Because a pathless shell
+ is only in the match branch when something below it matched. When *nothing*
+ matches, the router hands back the root route on its own — so a boundary on
+ the shell would never run, and the 404 would be the one screen on your site
+ without a header.
+
## Root 404 Handler
-Your `apps/web/src/routes/__root.tsx` defines the fallback boundary for all unmatched URLs:
+Your `apps/web/src/routes/__root.tsx` still defines a boundary, and it is the
+last resort: a `notFound()` thrown where no closer route declared one.
```tsx title="apps/web/src/routes/__root.tsx"
import { createRootRouteWithContext } from '@tanstack/react-router'
@@ -31,7 +54,7 @@ export const Route = createRootRouteWithContext()({
When a requested resource (like an article slug or user ID) is not found in the database, throw `notFound()` inside the loader:
-```tsx title="plugins/blog/src/routes/article-page.tsx"
+```tsx title="plugins/blog/src/pages/article-page.tsx"
import { notFound } from '@tanstack/react-router'
import { definePluginRoute } from '@vitnode/core/routing'
diff --git a/apps/web/content/docs/dev/server-functions.mdx b/apps/web/content/docs/dev/server-functions.mdx
index 083e54786..6c748bae2 100644
--- a/apps/web/content/docs/dev/server-functions.mdx
+++ b/apps/web/content/docs/dev/server-functions.mdx
@@ -57,7 +57,7 @@ export const fetchDevices = createIsomorphicFn()
Consume `fetchDevices` from the plugin route that owns the devices screen:
-```tsx title="plugins/devices/src/routes/devices-page.tsx"
+```tsx title="plugins/devices/src/pages/devices-page.tsx"
import { definePluginRoute } from '@vitnode/core/routing'
export const route = definePluginRoute({
diff --git a/apps/web/content/docs/dev/setup.mdx b/apps/web/content/docs/dev/setup.mdx
index 809497e35..c774c7a2f 100644
--- a/apps/web/content/docs/dev/setup.mdx
+++ b/apps/web/content/docs/dev/setup.mdx
@@ -4,7 +4,12 @@ description: Create a VitNode app, prepare Postgres, start TanStack Start, and s
icon: Rocket
---
-import { BookOpenIcon, PackagePlusIcon, ServerIcon } from 'lucide-react'
+import {
+ BookOpenIcon,
+ PackagePlusIcon,
+ ServerIcon,
+ SettingsIcon,
+} from 'lucide-react'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
You need Node.js 22+, Postgres (or Docker), and your favorite package manager.
@@ -60,29 +65,7 @@ npm run docker:dev
-
-
-### Migrate and create your administrator
-
-
-
-```bash tab="bun"
-bun run db:migrate
-```
-
-```bash tab="pnpm"
-pnpm db:migrate
-```
-
-```bash tab="npm"
-npm run db:migrate
-```
-
-
-
-The migration creates core tables and asks for the first admin account.
-
-
+
### Start VitNode
@@ -105,23 +88,18 @@ npm run dev
Open `http://localhost:3000`.
-
-
-
-### Check AdminCP, then build a plugin
-
-Sign in at `http://localhost:3000/admin`. When the shell works, resist the
-temptation to drop feature files into the host: [create a plugin](/docs/dev/plugins/create)
-and let it own the feature.
-
-{/* Image prompt: A polished VitNode AdminCP sign-in screen and dashboard overview, dark theme, with a clearly visible sidebar and a subtle callout pointing to the plugin-oriented AdminCP area. 1440x900. */}
-
## Keep going
+ }
+ title="Configuration"
+ description="Locales, plugins, request middleware, and what a browser may hold."
+ href="/docs/dev/configuration"
+ />
}
title="Create a plugin"
diff --git a/apps/web/content/docs/guides/blog.mdx b/apps/web/content/docs/guides/blog.mdx
index a8c62c9f4..adeb814f9 100644
--- a/apps/web/content/docs/guides/blog.mdx
+++ b/apps/web/content/docs/guides/blog.mdx
@@ -69,15 +69,11 @@ export const packageMessages = {
```
```ts title="apps/web/src/vitnode.config.ts"
-import { CONFIG_PLUGIN as BLOG } from '@vitnode/blog/const' // [!code ++]
-import { buildPlugin } from '@vitnode/core/lib/plugin' // [!code ++]
+import { blogPlugin } from '@vitnode/blog/config' // [!code ++]
export const vitNodeConfig = buildConfig({
plugins: [
- buildPlugin({
- messages: packageMessages[BLOG.pluginId],
- pluginId: BLOG.pluginId,
- }), // [!code ++]
+ blogPlugin(), // [!code ++]
],
})
```
diff --git a/apps/web/content/docs/guides/first-plugin.mdx b/apps/web/content/docs/guides/first-plugin.mdx
index ee61b9958..bb38feadd 100644
--- a/apps/web/content/docs/guides/first-plugin.mdx
+++ b/apps/web/content/docs/guides/first-plugin.mdx
@@ -35,29 +35,28 @@ npm create vitnode-app@canary -- --plugin
-The result has `routes/manifest.ts`, `routes/home-page.tsx`, `locales/en.json`,
-and `config.tsx`. The CLI adds a workspace dependency but leaves activation to
-you, which makes installed plugins predictable.
+The result has `routes.ts`, `pages/home-page.tsx`, `locales/en.json`, and
+`config.tsx`. The CLI adds a workspace dependency but leaves activation to you,
+which makes installed plugins predictable.
### Claim the page URL
-The manifest belongs to the plugin and is all the host needs to discover a
-route:
+The route tree belongs to the plugin and is all the host needs to discover a
+route. `lazy` names the page module without importing it, so the page gets a
+chunk of its own:
-```ts title="plugins/site-notes/src/routes/manifest.ts"
-import type { PluginRouteDefinition } from '@vitnode/core/routing'
+```ts title="plugins/site-notes/src/routes.ts"
+import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'
-export const routes: PluginRouteDefinition[] = [
- // [!code ++:5]
- {
- entry: 'routes/home-page',
- id: 'home',
- path: '/site-notes',
- },
-]
+export const routes = definePluginRoutes([
+ // [!code ++:3]
+ page('/site-notes', {
+ component: lazy(() => import('./pages/home-page')),
+ }),
+])
```
@@ -68,7 +67,7 @@ export const routes: PluginRouteDefinition[] = [
Edit the generated route module. It stays framework-neutral and is lazily
loaded by the TanStack Start host:
-```tsx title="plugins/site-notes/src/routes/home-page.tsx"
+```tsx title="plugins/site-notes/src/pages/home-page.tsx"
import { useTranslations } from 'use-intl'
const HomePage = () => {
diff --git a/apps/web/content/docs/ui/data-table.mdx b/apps/web/content/docs/ui/data-table.mdx
index 62fafc759..fc050ab7b 100644
--- a/apps/web/content/docs/ui/data-table.mdx
+++ b/apps/web/content/docs/ui/data-table.mdx
@@ -71,7 +71,7 @@ export const MembersTable = ({ data, navigate, search }: MembersTableProps) => {
Give an AdminCP route to the plugin, then pass its typed `search` and `navigate`
props into the table. The table remains reusable and the host stays out of it.
-```tsx title="plugins/members/src/routes/admin-members-page.tsx"
+```tsx title="plugins/members/src/pages/admin-members-page.tsx"
import type { PluginRoutePageProps } from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs
index 3248c6ea6..7a4a1423f 100644
--- a/apps/web/eslint.config.mjs
+++ b/apps/web/eslint.config.mjs
@@ -19,7 +19,6 @@ export default [
"dist/**",
"src/routeTree.gen.ts",
"src/plugin-routes.gen.ts",
- "src/plugin-route-manifest.gen.ts",
"src/admin-nav.gen.ts",
"src/content-registry.gen.ts",
"scripts/**",
diff --git a/apps/web/package.json b/apps/web/package.json
index 29e4accdc..e17ed5dfe 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,5 +1,6 @@
{
"name": "web",
+ "version": "2.0.0-canary.4",
"private": true,
"type": "module",
"imports": {
diff --git a/apps/web/scripts/check-docs.mjs b/apps/web/scripts/check-docs.mjs
deleted file mode 100644
index 6be02df37..000000000
--- a/apps/web/scripts/check-docs.mjs
+++ /dev/null
@@ -1,142 +0,0 @@
-import { readdir, readFile } from 'node:fs/promises'
-import { dirname, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
-
-const docsRoot = join(
- dirname(fileURLToPath(import.meta.url)),
- '../content/docs',
-)
-const errors = []
-
-const walk = async (directory) => {
- const entries = await readdir(directory, { withFileTypes: true })
- const files = await Promise.all(
- entries.map(async (entry) => {
- const path = join(directory, entry.name)
- return entry.isDirectory() ? await walk(path) : [path]
- }),
- )
-
- return files.flat()
-}
-
-const frontmatterValue = (frontmatter, field) => {
- const match = frontmatter.match(new RegExp(`^${field}:\\s*(.+)$`, 'm'))
- return match?.[1]?.trim().replace(/^['"]|['"]$/g, '')
-}
-
-const checkFrontmatter = (file, source) => {
- const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/)
-
- if (!match) {
- errors.push(`${file}: missing frontmatter`)
- return
- }
-
- const title = frontmatterValue(match[1], 'title')
- const description = frontmatterValue(match[1], 'description')
- const icon = frontmatterValue(match[1], 'icon')
-
- for (const [field, value] of [
- ['title', title],
- ['description', description],
- ['icon', icon],
- ]) {
- if (!value) errors.push(`${file}: missing ${field}`)
- }
-
- if (description && (description.length < 50 || description.length > 170)) {
- errors.push(
- `${file}: description should be 50–170 characters for search snippets`,
- )
- }
-}
-
-const checkCategory = (file, source) => {
- let category
-
- try {
- category = JSON.parse(source)
- } catch {
- errors.push(`${file}: invalid JSON`)
- return
- }
-
- for (const field of ['title', 'description', 'icon']) {
- if (!category[field]) errors.push(`${file}: missing ${field}`)
- }
-
- if (
- category.description &&
- (category.description.length < 35 || category.description.length > 170)
- ) {
- errors.push(
- `${file}: description should be 35–170 characters for navigation and search`,
- )
- }
-}
-
-const checkPackageManagerTabs = (file, source) => {
- const tabs = [...source.matchAll(//g)]
- const blocks = source.matchAll(
- /^```(?:bash|sh|shell|zsh)([^\n]*)\n([\s\S]*?)^```/gm,
- )
- const checkedTabs = new Set()
-
- for (const block of blocks) {
- const [, _info, body] = block
- if (!/\b(?:bun|pnpm|npm)\b/.test(body)) continue
-
- const start = block.index ?? -1
- const container = tabs.find((tab) => {
- const tabStart = tab.index ?? -1
- return start >= tabStart && start < tabStart + tab[0].length
- })
-
- if (!container) {
- errors.push(
- `${file}: package-manager command needs Bun, pnpm, and npm tabs`,
- )
- continue
- }
-
- const key = container.index ?? -1
- if (checkedTabs.has(key)) continue
- checkedTabs.add(key)
-
- const labels = new Set(
- [...container[0].matchAll(/```[^\n]*\btab=["']([^"']+)["']/g)].map(
- (tab) => tab[1],
- ),
- )
- const missing = ['bun', 'pnpm', 'npm'].filter((label) => !labels.has(label))
-
- if (missing.length > 0) {
- errors.push(
- `${file}: package-manager tabs are missing ${missing.join(', ')}`,
- )
- }
- }
-}
-
-const files = await walk(docsRoot)
-
-for (const file of files) {
- if (!file.endsWith('.mdx') && !file.endsWith('meta.json')) continue
-
- const source = await readFile(file, 'utf8')
- if (file.endsWith('.mdx')) {
- checkFrontmatter(file, source)
- checkPackageManagerTabs(file, source)
- } else {
- checkCategory(file, source)
- }
-}
-
-if (errors.length > 0) {
- console.error(`Docs check found ${errors.length} issue(s):`)
- for (const error of errors) console.error(`- ${error}`)
- process.exitCode = 1
-} else {
- console.log(`Docs check passed for ${files.length} files.`)
-}
diff --git a/apps/web/source.config.ts b/apps/web/source.config.ts
index 51ea128c2..7aedd62b7 100644
--- a/apps/web/source.config.ts
+++ b/apps/web/source.config.ts
@@ -1,44 +1,6 @@
import { defineConfig, defineDocs } from 'fumadocs-mdx/config'
import jsonSchema from 'fumadocs-mdx/plugins/json-schema'
-/**
- * The documentation collection, and the three capabilities the site is built on.
- *
- * `dir` is this application's own `content/docs`. Nothing here reads from
- * `apps/docs`: Stage 16 copied the source across, and `apps/web` owns it from
- * now on - see `src/tests/docs-source.test.ts`, which fails if a docs file ever
- * points a reader back at the directory Stage 17 deletes.
- *
- * ## `async` is the lazy half of the server collection
- *
- * Without it, `.source/server.ts` globs every document with `eager: true`, so
- * *reaching* the source loader compiles all ~120 documents - MDX, Shiki
- * highlighting and all - before it can answer which one `/docs/dev` is. That
- * cost lands on the first request to any docs URL in the dev server, and on the
- * SSR pass in production, for a lookup that only ever reads frontmatter.
- *
- * `async: true` splits that glob in two. The frontmatter stays eager, but is
- * transformed with `?only=frontmatter` - a one-line module per file, no MDX
- * compiler - and the body becomes `() => import(…)` behind `page.data.load()`.
- * The page tree, the `` and the 404 are all frontmatter, so a docs
- * request now compiles the one document it is going to render, which is also
- * exactly what the browser collection has always done.
- *
- * Nothing in `src/docs` had to change for it, and that is the point of the seam
- * `src/docs/transport.ts` describes: the body was never read on the server. The
- * two consumers that do want content ask for it asynchronously already -
- * `getText("processed")` in `source.server.ts`, and `createFromSource`, which
- * awaits `structuredData()` when a collection is lazy.
- *
- * `includeProcessedMarkdown` keeps the rendered Markdown of every page next to
- * its compiled component, which is what `/llms-full.txt` is made of. Without it
- * `page.data.getText("processed")` has nothing to return and the LLM output is
- * empty rather than missing. It survives `async`: the Markdown rides in the
- * lazily-imported module, so that call now loads the body it reads from.
- *
- * `jsonSchema()` lets a doc render a JSON Schema file as a type table. It is
- * carried over from the Next.js configuration unchanged.
- */
export const docs = defineDocs({
dir: 'content/docs',
docs: {
diff --git a/apps/web/src/admin-nav.gen.ts b/apps/web/src/admin-nav.gen.ts
index 4dd13fac3..7158d7267 100644
--- a/apps/web/src/admin-nav.gen.ts
+++ b/apps/web/src/admin-nav.gen.ts
@@ -14,26 +14,7 @@ import type { AdminNavPluginSource } from '@vitnode/core/lib/plugin'
import { adminNav as adminNav0 } from '@vitnode/blog/admin/nav'
import { adminNav as adminNav1 } from '@vitnode/example/admin/nav'
-/**
- * Every configured plugin's AdminCP navigation, as browser-safe data.
- *
- * A plugin appears here by exporting `admin/nav`, and a plugin that does not
- * contributes no sidebar entries. That is not an error - most plugins have
- * nothing to put in the AdminCP - and it is checked at build time rather than
- * discovered in a browser: the specifiers below are literal, so a bundler
- * resolves them and nothing is reached through a computed string.
- *
- * This is deliberately **not** the plugin's frontend registration.
- * `blogPlugin()` and friends register content types with their editing screens
- * attached - an editor field, a form layout, a table cell - the whole of core's
- * form stack. A sidebar needs ids, hrefs, permissions, icons and content type
- * definitions, all of which are plain data, so that is all this carries. See
- * `AdminNavPluginSource`.
- *
- * Navigation is not derived from the route manifest and never will be: a nav
- * entry may point at a plugin route, at a legacy screen, or at another origin,
- * and a plugin route may deliberately have no entry at all.
- */
+
export const pluginAdminNav = [
adminNav0, // @vitnode/blog
adminNav1, // @vitnode/example
diff --git a/apps/web/src/components/admin-shell.tsx b/apps/web/src/components/admin-shell.tsx
index 66ae942c1..27190d1dc 100644
--- a/apps/web/src/components/admin-shell.tsx
+++ b/apps/web/src/components/admin-shell.tsx
@@ -7,50 +7,6 @@ import { adminNav } from '#/lib/admin-nav'
import { adminUserSearchFn } from '#/lib/admin-search'
import { useAppNavigate } from '#/lib/navigation'
-/**
- * The AdminCP shell, as this app mounts it.
- *
- * Everything the panel *is* - the sidebar, the header, the palette, the user
- * menu, the breadcrumb area and the one `` - is `AdminShellContent`'s.
- * What is bound here is only what a package cannot answer for an application:
- *
- * onNavigate useAppNavigate the palette's Enter key
- * searchUsers adminUserSearchFn this app's own server function
- * languageSwitcher the router's
- * nav adminNav the plugins *this* app configured
- *
- * No `LinkComponent`: every sidebar destination is a route in this application's
- * own tree, so the shell's own default - `RouterLink` - is the right one. The
- * exception is handled a layer down rather than here: a plugin's `admin.nav`
- * entry may point at a docs site or a status page, and `adminLinkFor` renders
- * those as plain anchors, so an absolute URL is never handed to a router that
- * would try to match it. The sidebar and the command palette both go through it,
- * so an entry cannot behave one way when clicked and another when searched.
- *
- * `onNavigate` is still passed, because the palette moves the router *without a
- * link*: Enter on a highlighted entry is a navigation nobody clicked, and it has
- * to be handed the same de-localized destination a `` would build. See
- * `#/lib/navigation`.
- *
- * ## `nav` is a projection, not the plugin registry
- *
- * `src/vitnode.config.ts` is server-side and carries message loaders, which a
- * browser bundle has no business holding. So the sidebar is built from
- * `#/lib/admin-nav`, which reads the generated browser-safe projection: ids,
- * hrefs, permissions, icons and content type definitions, and nothing that
- * renders a screen. The screens have a projection of their own - see
- * `#/lib/content-registry`, which the content route imports so that a plugin's
- * editor fields and form layouts land in that route's chunk rather than in the
- * shell's.
- *
- * It carries the message namespaces with it, because a plugin group's headings
- * live under that plugin's own id and the shell would otherwise render them as
- * dotted identifiers. `_admin`'s loader warms the same list.
- *
- * No navigation is derived from the route manifest, in either direction: the
- * navigation model is complete regardless of which screen a click lands on, and
- * a nav entry is a product decision rather than a consequence of the route tree.
- */
const searchUsers: AdminUserSearch = async (search) =>
await adminUserSearchFn({ data: { search } })
diff --git a/apps/web/src/components/fumadocs/img.tsx b/apps/web/src/components/fumadocs/img.tsx
index 302076d63..a32767a9f 100644
--- a/apps/web/src/components/fumadocs/img.tsx
+++ b/apps/web/src/components/fumadocs/img.tsx
@@ -1,20 +1,6 @@
import { cn } from '@vitnode/core/lib/utils'
import { ImageZoom } from 'fumadocs-ui/components/image-zoom'
-/**
- * A screenshot inside a document, framed and click-to-zoom.
- *
- * Seven MDX files import this **by path** - `@/components/fumadocs/img` - so the
- * module has to keep both its location and its name. That is the whole reason it
- * is here rather than in `src/docs/`: the alias and the path are part of the
- * content, and moving the file would mean editing the copied documentation to
- * suit the implementation.
- *
- * Carried over from the Next.js application unchanged apart from its import
- * style. It takes a Vite asset import as `src`, which is a URL string here
- * rather than Next's `StaticImport` - so `next/image` never comes into it, and
- * the plain `` inside `ImageZoom` is what renders.
- */
export const ImgDocs = ({
className,
imgClassName,
diff --git a/apps/web/src/components/main-header.tsx b/apps/web/src/components/main-header.tsx
index 15559365d..cd5c91a22 100644
--- a/apps/web/src/components/main-header.tsx
+++ b/apps/web/src/components/main-header.tsx
@@ -1,36 +1,6 @@
import { LogoVitNodeBrand } from '@vitnode/core/components/logo-vitnode'
import { MainHeader as MainHeaderContent } from '@vitnode/core/tanstack/layout'
-/**
- * The site header, as the main shell's slot for it.
- *
- * One line of application. The bar, the nav, the language and theme switchers,
- * the user area and both cache entries they read are
- * `@vitnode/core/tanstack/layout`'s `MainHeader`; what this app adds is the one
- * thing only it can answer.
- *
- * **The mark.** `LogoVitNodeBrand` is core's own - the canonical `LogoVitNode`
- * at whichever of its two sizes the viewport has room for - and it is also what
- * the header would default to, so passing it changes no pixel. What it changes
- * is where the answer lives: this file is vitnode.com, and vitnode.com choosing
- * VitNode's mark is a sentence a site should say out loud rather than inherit.
- * An application with its own mark replaces this one prop.
- *
- * No `LinkComponent`. Every header destination is a route in this application's
- * own tree, so the header's own default - `RouterLink`, the router's `Link` in
- * the shape the shared views ask for - is the right one, and the prop stays
- * available for a host that needs to answer differently.
- *
- * ## What the shell owes it
- *
- * Two warm cache entries, both ensured by `_main`'s loader:
- *
- * headerIntlQueryOptions -> a `useSuspenseQuery`, so this is required
- * prefetchSession -> the first paint shows the visitor, not a gap
- *
- * See the loader in `routes/_main.tsx`, which states why one is `ensure` and the
- * other `prefetch`.
- */
export const MainHeader = () => (
} />
)
diff --git a/apps/web/src/content-registry.gen.ts b/apps/web/src/content-registry.gen.ts
index b8fce83f5..3051dd537 100644
--- a/apps/web/src/content-registry.gen.ts
+++ b/apps/web/src/content-registry.gen.ts
@@ -14,31 +14,7 @@ import type { ContentFrontendPluginSource } from '@vitnode/core/lib/plugin'
import { adminContent as adminContent0 } from '@vitnode/blog/admin/content'
import { adminContent as adminContent1 } from '@vitnode/example/admin/content'
-/**
- * Every configured plugin's Content Engine frontend registration.
- *
- * A plugin appears here by exporting `admin/content`, and a plugin that does
- * not contributes no content types to the AdminCP. That is not an error - most
- * plugins register none - and it is decided at build time rather than
- * discovered in a browser: the specifiers below are literal, so a bundler
- * resolves them and nothing is reached through a computed string.
- *
- * This carries more than `admin-nav.gen.ts` and less than
- * `vitnode.config.ts`. Navigation needs ids, hrefs, permissions and icons; a
- * content *screen* needs those plus the components that replace a generated
- * input, a generated table cell and a generated form layout. Neither needs the
- * server config, which carries message loaders and API wiring a browser bundle
- * has no business holding.
- *
- * Nothing is copied and nothing is serialised: React components cannot cross a
- * JSON boundary, so the registrations stay compiled in each plugin's own
- * `dist` and arrive through a specifier a bundler resolved.
- *
- * The registry built from this is validated by `@vitnode/core` -
- * `buildContentFrontendRegistry` runs the same `validateContentTypes` the
- * API side runs, so two plugins claiming one `admin.path` fail loudly here
- * rather than giving one of them a screen and the other nothing.
- */
+
export const pluginContentTypes = [
adminContent0, // @vitnode/blog
adminContent1, // @vitnode/example
diff --git a/apps/web/src/docs/article.tsx b/apps/web/src/docs/article.tsx
index 1624d6400..02fd5cb94 100644
--- a/apps/web/src/docs/article.tsx
+++ b/apps/web/src/docs/article.tsx
@@ -13,28 +13,6 @@ export interface DocsArticleMeta {
url: string
}
-/**
- * What a documentation page looks like: the heading, the "Open in" menu, the
- * table of contents, and the body.
- *
- * Presentation only, and that is what makes the split worth having. The body
- * arrives as `children` and the table of contents as a prop, so this module
- * knows nothing about how either was loaded - `./client-loader` is the half that
- * does, and it renders this.
- *
- * ## Why the table of contents is a prop rather than loader data
- *
- * Because it cannot cross a server function. A `TOCItemType`'s `title` is a
- * `ReactNode`, so a heading containing inline code or a link is a React element,
- * and TanStack Start's serializer rejects those by design - correctly, and at
- * compile time. It does not need to cross: the compiled MDX module exports its
- * own `toc` beside its component, so the table of contents arrives in the same
- * chunk as the body it describes.
- *
- * `tableOfContent` keeps the Next.js application's settings verbatim - the
- * "clerk" style, with `single: false` so a heading's children stay visible while
- * you read it.
- */
export const DocsArticle = ({
children,
description,
diff --git a/apps/web/src/docs/client-loader.tsx b/apps/web/src/docs/client-loader.tsx
index bee876d1d..c88f815a1 100644
--- a/apps/web/src/docs/client-loader.tsx
+++ b/apps/web/src/docs/client-loader.tsx
@@ -5,29 +5,6 @@ import type { DocsArticleMeta } from './article'
import { DocsArticle } from './article'
import { mdxComponents } from './mdx-components'
-/**
- * The browser's half of the MDX split: a page's *path* in, its rendered page
- * out.
- *
- * `collections/browser` is `.source/browser.ts`, generated by
- * `fumadocs-mdx/vite`: a record of `() => import("…/dev/index.mdx")` thunks and
- * nothing else. Importing it costs the thunks, not the documents - every body is
- * its own chunk, fetched when a reader asks for that page.
- *
- * `createClientLoader` is Fumadocs' own API for this, and the reason nothing
- * here serializes a component. `preload(path)` starts the import (the route's
- * loader awaits it, so the first paint already has the body) and
- * `useContent(path, props)` renders whatever it resolved.
- *
- * The loaded module carries `toc` as well as the component, which is what makes
- * the table of contents a prop rather than loader data - see `./article`.
- *
- * `id` is not optional in practice, whatever its default says. TanStack Start's
- * code splitter can duplicate this module into more than one chunk - a route's
- * `component` and its `notFoundComponent` are split separately - and two client
- * loaders with no shared id keep two caches, so a body preloaded by the loader
- * would be fetched a second time by the component. The id makes them one.
- */
export const docsClientLoader = browserCollections.docs.createClientLoader({
id: 'vitnode-docs',
component: ({ default: MDX, toc }, props: DocsArticleMeta) => (
diff --git a/apps/web/src/docs/freshness.ts b/apps/web/src/docs/freshness.ts
index 1ee171dc9..d48aaecbc 100644
--- a/apps/web/src/docs/freshness.ts
+++ b/apps/web/src/docs/freshness.ts
@@ -1,75 +1,5 @@
-/**
- * How long a documentation build stays true, and the two places that answer
- * differently in development.
- *
- * In production the answer is "forever": `content/docs` is build output, frozen
- * when the server started, and the page tree, the search index and
- * `/llms-full.txt` are all derived from it. Computing any of them twice is
- * waste.
- *
- * While somebody is *writing* documentation the answer is the opposite, and the
- * cost of getting it wrong is a dev server that has to be restarted to see a
- * renamed page. Both policies live here rather than being spelled out at each
- * of the three call sites, because "how fresh is the documentation" is one
- * question and it was about to be answered three ways.
- *
- * It is deliberately app-local. `@vitnode/core` is externalised from this app's
- * SSR pass and is built once for every host; `import.meta.env` is this build's,
- * and a package that branched on it would be branching on whoever bundled it.
- */
-
-/**
- * How long the router may reuse a loaded page tree - `_docs`'s `staleTime`.
- *
- * `Infinity` in production, because the tree is a constant: identical for every
- * visitor, unchanged for the life of the process, and refetching it on every
- * navigation inside the documentation would be a round trip for something that
- * cannot have changed.
- *
- * `0` in development, so that adding a page, renaming one or editing a
- * `meta.json` shows up in the sidebar on the next navigation instead of on the
- * next server restart. The loader re-runs, the server function runs again, and
- * Vite has by then re-executed the collection - see {@link memoizePerSource} for
- * why that last step is what actually makes the answer new.
- */
export const DOCS_TREE_STALE_TIME = import.meta.env.PROD ? Infinity : 0
-/**
- * Compute something from the documentation source once per *version* of that
- * source.
- *
- * The search index and `/llms-full.txt` are both expensive, both derived from
- * the whole collection, and both were memoized in a module-level `let` - which
- * is right in production and wrong for the rest of the time: the promise
- * outlives every edit, so a dev server keeps answering with the documentation it
- * read at boot.
- *
- * The fix is not a flag. It is to notice that the thing being cached is a
- * function *of the source module*, and to key the cache on that module:
- *
- * production one module instance for the process -> computed once
- * development Vite re-executes the collection when `content/docs` changes,
- * which produces a new module namespace -> computed again
- *
- * So one mechanism gives both behaviours, and neither of them is a guess about
- * the environment. A repeated search between two edits still reuses the index -
- * the module has not been replaced, so the key has not changed - which is the
- * half a plain "recompute in dev" would have thrown away.
- *
- * A `WeakMap` rather than a `Map` because the key is the previous module
- * namespace: after an edit nothing references it any more, and holding the index
- * built from a document somebody has already changed is how a dev server grows
- * for an afternoon.
- *
- * `loadSource` is a parameter rather than a hard-coded import, for two reasons.
- * It makes this testable without Vite and without the documentation - see
- * `src/tests/docs-freshness.test.ts`. And it keeps this module client-safe:
- * `_docs.tsx` imports {@link DOCS_TREE_STALE_TIME} from here, so an
- * `import("./source.server")` written *in this file* would put the whole content
- * index in the browser build as a lazy chunk. The two server routes pass their
- * own loader, and Start prunes those route files out of the client tree
- * entirely.
- */
export const memoizePerSource = (
loadSource: () => Promise,
compute: (source: TSource) => Promise,
diff --git a/apps/web/src/docs/github.ts b/apps/web/src/docs/github.ts
index ccf137deb..a8949d369 100644
--- a/apps/web/src/docs/github.ts
+++ b/apps/web/src/docs/github.ts
@@ -1,19 +1,3 @@
-/**
- * Where a documentation page's source actually lives, so a reader can edit it.
- *
- * `apps/web/content/docs` and not `apps/docs/content/docs`. Stage 16 moved the
- * ownership of the documentation into this application; the Next.js copy is
- * still on disk only until Stage 17 deletes it, and a "View source" link
- * pointing at a file scheduled for deletion is a link that will 404 for every
- * contributor who follows it after that stage lands.
- *
- * Pure, and separate from the component that renders the link, because it is
- * the one thing in the View Options menu worth asserting - see
- * `src/tests/docs-source.test.ts`.
- *
- * `page.path` is the path inside the collection (`dev/plugins/create.mdx`),
- * which is exactly the tail of the repository path.
- */
export const DOCS_SOURCE_DIRECTORY = 'apps/web/content/docs'
const REPOSITORY_BLOB_URL =
diff --git a/apps/web/src/docs/mdx-components.tsx b/apps/web/src/docs/mdx-components.tsx
index b87a0601b..807e9e714 100644
--- a/apps/web/src/docs/mdx-components.tsx
+++ b/apps/web/src/docs/mdx-components.tsx
@@ -3,36 +3,6 @@ import defaultMdxComponents from 'fumadocs-ui/mdx'
import { Preview } from './preview'
-/**
- * The components every document is rendered with - one map, in one module.
- *
- * Fumadocs' defaults carry the bulk of it: `Callout`, `Card`, `Cards`, the code
- * block and its tabs, the heading anchors, the table wrapper, and - the one that
- * matters most here - `a`, which is the framework link. That is why a Markdown
- * link like `[the manifest](/docs/dev/plugins/route-manifest)` becomes a
- * client-side navigation that keeps the locale prefix: the anchor resolves
- * through `fumadocs-core/framework/tanstack`, which renders TanStack Router's
- * own ``, which builds its href through this app's `rewrite`. There is no
- * link adapter in this application and there must not be one - see
- * `src/routes/_docs.tsx`.
- *
- * Three additions on top, and they are the same three the Next.js application
- * added:
- *
- * - `Steps` / `Step`, used by 48 documents, which Fumadocs ships but does not
- * include in the default map.
- * - `Preview`, which is this site's own: a live rendering of a VitNode component
- * beside the prose describing it.
- *
- * `TypeTable`, `Tabs`/`Tab` and `ImgDocs` are deliberately absent. A document
- * that needs one imports it at the top of its own MDX file, which is how they
- * were written and how they stay: a component used by a dozen pages does not
- * need to be in the map every page pays for.
- *
- * Declared once and exported, rather than built inline in the route: the Next.js
- * page assembled this object in its render, and a second copy appearing in a
- * second route is exactly the drift this stage is meant to prevent.
- */
export const mdxComponents = {
...defaultMdxComponents,
Preview,
diff --git a/apps/web/src/docs/moved-pages.ts b/apps/web/src/docs/moved-pages.ts
index 0d3ba7d7e..b2a20228b 100644
--- a/apps/web/src/docs/moved-pages.ts
+++ b/apps/web/src/docs/moved-pages.ts
@@ -1,25 +1,8 @@
-/**
- * Where a document used to live, and where it lives now.
- *
- * The documentation was reorganised: `docs/tanstack/*` folded into `docs/dev/*`,
- * `docs/dev/tanstack/*` was dissolved into the categories its pages actually
- * belong to - routing with routing, metadata with metadata, the plugin tutorial
- * with the plugin section - and the two AdminCP pages moved under
- * `docs/dev/plugins/admin`. Those old URLs shipped, which means they are in
- * somebody's bookmarks and in a search index, so `/docs/$` answers them with a
- * `301` to the new slug rather than a 404.
- *
- * A `301` and not a `302`: the move is permanent, and a permanent redirect is
- * what transfers the old URL's ranking to the new one.
- *
- * Slugs here are what `/docs/$` receives as its splat - the path after `/docs/`,
- * with no leading or trailing slash and no locale prefix, because the router has
- * already stripped that.
- */
const EXACT_MOVES: Record = {
'dev/not-found': 'dev/routing/not-found',
'dev/plugins/admin-page': 'dev/plugins/admin',
'dev/plugins/dashboard-widgets': 'dev/plugins/admin/dashboard-widgets',
+ 'dev/plugins/route-manifest': 'dev/plugins/routes',
'dev/tanstack': 'dev/architecture',
'dev/tanstack/admin': 'dev/plugins/admin-page',
'dev/tanstack/data-loading': 'dev/data-loading',
@@ -33,11 +16,6 @@ const EXACT_MOVES: Record = {
tanstack: 'dev',
}
-/**
- * The `docs/tanstack/*` tree kept its shape when it moved under `docs/dev`, so
- * one prefix rule covers all fourteen of its pages instead of fourteen entries.
- * `docs/tanstack/database/pagination` becomes `docs/dev/database/pagination`.
- */
const PREFIX_MOVES: [from: string, to: string][] = [['tanstack/', 'dev/']]
export const movedDocsSlug = (slug: string): string | undefined => {
diff --git a/apps/web/src/docs/not-found-content.tsx b/apps/web/src/docs/not-found-content.tsx
deleted file mode 100644
index 8b10412db..000000000
--- a/apps/web/src/docs/not-found-content.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Link } from '@tanstack/react-router'
-import { buttonVariants } from 'fumadocs-ui/components/ui/button'
-import { DocsPage } from 'fumadocs-ui/page'
-
-/**
- * `/docs/does-not-exist`, and every other document that is not there.
- *
- * Rendered *inside* the docs shell - the sidebar, the tabs and the search are
- * still on screen - because a reader who mistyped a URL or followed a stale
- * bookmark is one click from the page they meant, and a bare error screen throws
- * that away.
- *
- * ## It is a 404, and not a redirect
- *
- * The Next.js application sent `/docs` itself to `/docs/dev` with an HTTP
- * redirect, and that one is preserved (`src/routes/_docs/docs.index.tsx`)
- * because `/docs` is a real URL with no landing document behind it. A *missing*
- * document is a different thing: bouncing it to the first section would answer a
- * question nobody asked and would tell a crawler the page exists. So this route
- * answers not-found, which is what `throw notFound()` in `getDocsPage` asks for.
- *
- * The router's `` directly rather than an injected component: this file is
- * a route's own screen rather than a shared view, both destinations are routes in
- * this tree, and the rewrite writes the locale prefix into each - so from
- * `/pl/docs/nope` the buttons lead to `/pl/docs/dev` and `/pl`.
- */
-export const DocsNotFoundContent = () => (
-
-
-
404
-
-
- This page does not exist
-
-
-
- The document you are looking for may have been renamed or moved. The
- sidebar has everything that is here.
-
-
-
-
- Browse the documentation
-
-
-
- Back to VitNode
-
-
-
-
-)
diff --git a/apps/web/src/docs/page-content.tsx b/apps/web/src/docs/page-content.tsx
index 4b9a44eb8..cb118523a 100644
--- a/apps/web/src/docs/page-content.tsx
+++ b/apps/web/src/docs/page-content.tsx
@@ -2,26 +2,6 @@ import type { DocsPageData } from './transport'
import { docsClientLoader } from './client-loader'
-/**
- * One document, rendered from what its route's loader resolved.
- *
- * Small, and its own module for a reason that is not size: it is the first thing
- * in the documentation graph that reaches Fumadocs' UI, and the route may only
- * name it through `component`. A route's `loader` and `head` are evaluated in
- * the client entry on *every* page of the application, so an import the route
- * file made statically would put the documentation runtime in front of every
- * visitor to the front page.
- *
- * `path` selects the compiled MDX module; the rest is what the page looks like
- * around it. Both come from the one server-side lookup the loader made, so the
- * `
`, the `` and the body cannot disagree - see `./transport`.
- *
- * `metaTitle` is deliberately not passed on: it is the tab's title, with the
- * page's ancestors appended, and the heading shows `title`.
- *
- * `useContent` reads the module through `use()`, so a body that somehow was not
- * preloaded suspends rather than flashing empty.
- */
export const DocsPageContent = (page: DocsPageData): React.ReactNode =>
docsClientLoader.useContent(page.path, {
description: page.description,
diff --git a/apps/web/src/docs/pending.tsx b/apps/web/src/docs/pending.tsx
new file mode 100644
index 000000000..3ae855f90
--- /dev/null
+++ b/apps/web/src/docs/pending.tsx
@@ -0,0 +1,118 @@
+import { Skeleton } from '@vitnode/core/components/ui/skeleton'
+import { cn } from '@vitnode/core/lib/utils'
+import { DocsPage } from 'fumadocs-ui/page'
+
+const DocsHeadingPendingSkeleton = () => (
+
+)
diff --git a/apps/web/src/docs/preview-data-table.tsx b/apps/web/src/docs/preview-data-table.tsx
index 4d46dbc5a..18f1ab3d8 100644
--- a/apps/web/src/docs/preview-data-table.tsx
+++ b/apps/web/src/docs/preview-data-table.tsx
@@ -10,26 +10,6 @@ import React from 'react'
export type { ColumnDef } from '@vitnode/core/components/table/data-table-content'
-/**
- * The data table, wired for a documentation preview.
- *
- * The two table examples used `@vitnode/core/components/table/data-table`, which
- * is the table wired to **Next.js**: it mounts `NextDataTableNavigation`, which
- * reads `next/navigation` and `next-intl`'s locale-aware router. That module
- * cannot exist in this application, and importing it would have been the one
- * thing that made a documentation page drag Next.js into a Next-free build.
- *
- * So the preview supplies the same seam the AdminCP screens supply: a
- * `DataTableNavigation` - "here is the query string, and here is how to change
- * it" - around the shared `ContentDataTable`. The table itself is identical;
- * only the two lines that decide what "sort by this column" means are different,
- * which is exactly the split `components/table/navigation.tsx` exists for.
- *
- * The state is local rather than the URL, and for a demo that is a feature: a
- * reader sorting a table of four fictional users should not have the page they
- * are reading navigate underneath them, and four readers sorting four previews
- * on one page would otherwise fight over one query string.
- */
export function DataTable(props: DataTableProps) {
const [search, setSearch] = React.useState('')
diff --git a/apps/web/src/docs/preview.tsx b/apps/web/src/docs/preview.tsx
index 04af368f9..39c345098 100644
--- a/apps/web/src/docs/preview.tsx
+++ b/apps/web/src/docs/preview.tsx
@@ -2,30 +2,6 @@ import { Loader } from '@vitnode/core/components/ui/loader'
import { cn } from '@vitnode/core/lib/utils'
import React from 'react'
-/**
- * A live VitNode component, rendered inside the document that describes it.
- *
- * `` in an MDX file renders
- * `src/docs/examples/button.tsx` - 37 documents use it, and it is the reason the
- * UI section of the documentation is worth reading rather than a list of prop
- * tables.
- *
- * ## Why the examples are a glob rather than a template literal
- *
- * The Next.js version called ``dynamic(() => import(`../../examples/${name}.tsx`))``
- * *inside the render*, which meant a new component identity on every render and
- * an `eslint-disable` on both lines to say so. `import.meta.glob` is Vite's own
- * answer: it resolves the whole directory at build time into a record of lazy
- * importers, and every `React.lazy` below is created **once, at module scope**.
- * So switching tabs on a page no longer remounts the demo, and the rule that
- * catches components created during render has nothing to complain about.
- *
- * `React.lazy` does not call its importer until something renders it, so the map
- * costs 37 closures and no chunks. `eager: false` is the default and is stated
- * anyway, because it is the whole point: eagerly globbing this directory would
- * put every example - the editor, the data table, the whole form stack - into
- * the documentation's chunk.
- */
const exampleModules = import.meta.glob<{
default: React.ComponentType
}>('./examples/*.tsx', { eager: false })
diff --git a/apps/web/src/docs/search-dialog.tsx b/apps/web/src/docs/search-dialog.tsx
index 03f079273..cd1bbc072 100644
--- a/apps/web/src/docs/search-dialog.tsx
+++ b/apps/web/src/docs/search-dialog.tsx
@@ -13,22 +13,6 @@ import {
import { DOCS_SEARCH_PATH } from './search-path'
-/**
- * The documentation's own search, opened with `⌘K` from anywhere under `/docs`.
- *
- * Fumadocs' own dialog and Fumadocs' own client - `type: "fetch"` queries the
- * index over HTTP and gets back titles, headings and the text under them, which
- * is what makes searching for a sentence in the middle of a page work.
- *
- * The only thing this application changes is `api`. It defaults to
- * `/api/search`, and `/api/*` is Hono's; see `./search-path`.
- *
- * It is deliberately *not* the site-wide search at `/search`. That one indexes
- * VitNode content through the API - posts, articles, whatever a plugin
- * registered - and knows nothing about MDX; this one indexes documentation and
- * knows nothing about content. Two indexes, two dialogs, and the docs shell is
- * the only place this one is mounted.
- */
const DocsSearchDialog = (props: SharedProps) => {
const { query, search, setSearch } = useDocsSearch({
api: DOCS_SEARCH_PATH,
diff --git a/apps/web/src/docs/search-path.ts b/apps/web/src/docs/search-path.ts
index e146425bf..0c3a56fab 100644
--- a/apps/web/src/docs/search-path.ts
+++ b/apps/web/src/docs/search-path.ts
@@ -1,34 +1 @@
-/**
- * Where the documentation's search index answers, and why it is not
- * `/api/search`.
- *
- * `/api/*` belongs to Hono. It is the whole of this application's API and its
- * security boundary, mounted by `src/routes/api/$.ts` and shared verbatim with
- * `apps/api`, and Fumadocs claiming a path inside it would be a second,
- * unrelated server answering from the same namespace - the exact ambiguity the
- * migration has spent fifteen stages avoiding. Routing the search *through* Hono
- * instead would be worse in a different way: the index is built from the MDX
- * collection, so the API process would have to import the whole documentation
- * build graph to answer a query about it.
- *
- * So the search index is a documentation resource and lives with the
- * documentation: `/docs/search`, served by `src/routes/docs.search.ts`, which is
- * a server route and renders no document.
- *
- * ## It is a root-relative path on purpose
- *
- * The reader may be at `/pl/docs/dev`, and this is passed to `fetch` - which
- * resolves a leading slash against the origin, not the current path. So the
- * request is `/docs/search` from every page in every language, which is the one
- * spelling the server route is mounted at. The results carry internal URLs
- * (`/docs/dev/...`); the locale prefix is put back by the router when the reader
- * navigates to one, exactly as it is for every other link in this application.
- *
- * ## The one thing this shadows
- *
- * A static segment outranks a splat, so this path is not reachable as a
- * document: a `content/docs/search.mdx` would be indexed and never render.
- * There is no such file, and `src/tests/docs-route.test.ts` fails if one is ever
- * added - which turns a silently unreachable page into a failing build.
- */
export const DOCS_SEARCH_PATH = '/docs/search'
diff --git a/apps/web/src/docs/section.ts b/apps/web/src/docs/section.ts
index 1056a8738..92addeee2 100644
--- a/apps/web/src/docs/section.ts
+++ b/apps/web/src/docs/section.ts
@@ -1,30 +1,3 @@
-/**
- * Which top-level section of the documentation a URL is in - `dev`, `ui`,
- * `guides` - or `undefined` for `/docs` itself.
- *
- * It exists for one thing: the accent colour. Each section paints
- * `--color-fd-primary` from its own token (`src/styles.css`), so the sidebar,
- * the active tab and every link inside a document are orange under `dev`, teal
- * under `ui` and blue under `guides`.
- *
- * ## Why this is a pure function of the pathname
- *
- * The Next.js application could not do it this way. Reading the slug there meant
- * reading `params` inside the layout, which would have made the whole docs shell
- * dynamic - so it painted the class onto `` from an inline script derived
- * from `location.pathname`, plus a `useLayoutEffect` to keep it in step. Two
- * mechanisms, a `useServerInsertedHTML` call and a comment explaining why a
- * `