Configuration
Reference for vitnode.config.ts, vitnode.server.config.ts, start.ts and vite.config.ts, the files that configure a VitNode web app, with every option, default and generated file.
This page is the reference for the files that configure a VitNode web app: what goes in each one, which options it takes and what Core does with them. Use it when you change the site's languages, logo, menus or plugins, add request middleware, or tune the Vite build. A new app already has all four files, filled in with working defaults.
| File | Holds | Runs in | Section |
|---|---|---|---|
src/vitnode.config.ts | Locales, metadata, logo, menus, theme, enabled plugins | Browser, server and your Vite build | Shared config |
src/vitnode.server.config.ts | Message loaders | Server only | Server config |
src/start.ts | The request middleware | Server | Request pipeline |
vite.config.ts | The vitnode() Vite plugin and its options | Vite (dev and build) | Vite config |
The line between the first two is one question: may a browser hold this?
The API has its own src/vitnode.api.config.ts, covered in the callout under
Server config.
Shared config
src/vitnode.config.ts exports the result of buildConfig:
import { blogPlugin } from '@vitnode/blog/config'
import { LogoVitNodeBrand } from '@vitnode/core/components/logo-vitnode'
import { buildConfig } from '@vitnode/core/vitnode.config'
export const vitNodeConfig = buildConfig({
i18n: {
defaultLocale: 'en',
locales: [
{ code: 'en', name: 'English' },
{ code: 'pl', name: 'Polski' },
],
timeZone: 'UTC',
},
logo: LogoVitNodeBrand,
metadata: {
shortTitle: 'VitNode',
title: 'VitNode',
},
plugins: [blogPlugin()],
theme: {
defaultTheme: 'system',
},
})Options
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
i18n | object | Yes | - | Languages and locale routing. See Languages & Localization |
metadata | object | Yes | - | Site title, used in page titles as {page} - {shortTitle ?? title} |
plugins | buildPlugin() results | Yes | - | The installed plugins' frontend factories. See Enabled plugins |
editor | object | No | - | Settings shared by every Editor in the app |
logo | component | No | VitNode logo | The header logo. See Logo |
navigation | object | No | - | The header menu and the phone bottom bar. See Navigation |
pageCache | object | No | - | Server-rendered HTML cache storage and per-route overrides. See Page cache reference |
theme | object | No | - | Theme provider options, such as defaultTheme: 'system' |
viewTransitions | boolean | No | true | false turns off the page fade and logo morph. See View transitions |
Who reads it
Three very different readers depend on this file:
- The document shell renders
metadata,logoandtheme, in the browser as well as on the server. - The locale runtime derives routing from
i18n. - Your Vite build loads it with
jitiwhile 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. The one exception is logo: a small, self-contained component.
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:
export type Locale = (typeof vitNodeConfig.i18n.locales)[number]['code']Logo
logo is the component the site header shows in its home link. Leave it out
and you get the VitNode logo - which is lovely, but it is probably not your
brand.
export const Logo = () => (
<img
alt="Acme"
className="h-8 w-auto"
height={32}
src="/logo.svg"
width={96}
/>
)import { Logo } from '@/components/logo'
export const vitNodeConfig = buildConfig({
// ...
logo: Logo,
})Pass the component itself, not <Logo />. Keep it lean: your Vite build
executes this file too, so a logo that imports half your UI makes every
regeneration pass slower.
Navigation
navigation holds the header menu and the phone bottom bar. Each link is an
href, a title message key and an optional icon component:
import { CompassIcon } from 'lucide-react'
export const vitNodeConfig = buildConfig({
// ...
navigation: {
header: [{ href: '/discover', icon: CompassIcon, title: 'app.menu.discover' }],
mobileBottomBar: [
{ href: '/discover', icon: CompassIcon, title: 'app.menu.discover' },
],
},
})See Main Navigation for dropdowns, translations and the checks that run at startup.
View transitions
Navigations crossfade and the logo morphs between layouts by default. Set
viewTransitions to false to swap pages instantly instead:
export const vitNodeConfig = buildConfig({
// ...
viewTransitions: false,
})See View transitions for what this changes.
Enabled plugins
Register a plugin with its own factory:
import { siteNotesPlugin } from 'site-notes/config'
export const vitNodeConfig = buildConfig({
// ...
plugins: [siteNotesPlugin()],
})create-vitnode-app --plugin adds this line for
you when it generates a plugin.
The factory carries the plugin's identity, its locale barrel and its route
declarations - plain data, and nothing that renders. Its AdminCP navigation and
its Content Engine screens live in the plugin's admin/nav and admin/content
modules instead, and they never pass through this object.
What a TanStack Start app renders comes back through generated projections of those modules, 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 |
src/content-modules.gen.ts | the plugin's content module | by the API, through buildApiConfig({ contentModules }) |
src/blocks.gen.ts | the plugin's widgets module | only by routes that render blocks |
src/package-messages.gen.ts | the factory's localeFiles | per request, one locale at a time |
src/page-cache.gen.ts | route cache options | by src/server.ts, to pick each page's HTML cache policy |
src/api-registry.gen.ts | the plugin's config.api | never. Type-only, it teaches the fetcher which plugins exist |
src/plugin-inventory.gen.ts | the factory's metadata | by the API only, through buildApiConfig({ frontendPlugins }) |
The Vite plugin rewrites these files on every vite dev and vite build, so
never edit them by hand. A generated app lists src/*.gen.ts in its
.gitignore.
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. Spreading admin/content into the factory undoes exactly that: the
whole editing stack rides along with every public page. See
Plugin routes and
Plugin frontend modules. The inventory is
what the AdminCP plugin catalog lists.
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. The factory's localeFiles is the same list spelled as
package subpaths, and that is what your app's translations are loaded from:
your build writes the loaders into src/package-messages.gen.ts for you.
Languages & Localization has the detail.
Your Vite build executes this file with jiti on every regeneration pass to
discover the plugin list, so everything a plugin factory imports runs in Node
each time. Keep factories to plain data (buildPlugin({ manifest, routes, messages, localeFiles })). A factory that pulls in a large registration
graph makes the dev watcher slower with every plugin you add.
Server config
src/vitnode.server.config.ts is the server-only companion of the shared config.
Message loaders read JSON out of a package's build output, which is exactly what
the shared config cannot carry. They go here instead:
import '@tanstack/react-start/server-only'
import { buildServerConfig } from '@vitnode/core/vitnode.config'
import { appMessages } from '@/locales/app'
import { packageMessages } from '@/package-messages.gen'
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:
import { createIntlMessagesLoader } from '@vitnode/core/tanstack/i18n/server'
import { vitNodeServerConfig } from '@/vitnode.server.config'
export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig)packageMessages is generated from the plugins you configured, and messages is
where you reword a string a package translates differently to how you want it.
Languages & Localization 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 - and its
own localePrefix, domains and routePaths, which must mirror the web
app's so the content URLs it builds match. See Localized
URLs.
Request pipeline
TanStack Start expects a src/start.ts. In a VitNode app it is one call:
import { createVitNodeStart } from '@vitnode/core/tanstack/start'
import { vitNodeConfig } from '@/vitnode.config'
export const startInstance = createVitNodeStart({ config: vitNodeConfig })createVitNodeStart installs two middleware before any of yours, and neither is
optional:
| Order | Middleware | What it does |
|---|---|---|
| 1 | CSRF | Rejects cross-site requests to server functions (handlerType === 'serverFn') |
| 2 | Locale | Canonical 308 redirects and the remembered-locale cookie; on every HTML response, forces Cache-Control: private, no-store and adds the security headers |
| 3 | Your own | Whatever requestMiddleware lists, in order |
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
config | vitNodeConfig | Yes | - | The shared config; its i18n drives the locale redirects |
requestMiddleware | TanStack Start middleware array | No | [] | Your own middleware, run after CSRF and locale handling |
securityHeaders | Record<string, string | false> | No | - | Adds, changes or (false) removes a default security header |
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. The one exception proves the rule rather
than bending it: Swagger UI at
/api/swaggeris HTML, so it gets the document directive like any other page an operator reads.
Security headers on every page
Every HTML response also gets a small, conservative set of security headers:
| Header | Value | Why |
|---|---|---|
Content-Security-Policy | frame-ancestors 'self' | Other sites cannot frame your pages (hello, clickjacking) |
X-Frame-Options | SAMEORIGIN | The same, for browsers that predate frame-ancestors |
X-Content-Type-Options | nosniff | The browser trusts the declared content type |
Referrer-Policy | strict-origin-when-cross-origin | Paths and query strings stay out of other sites' logs |
There is no script policy and no HSTS by default - a strict script CSP breaks
plugins in creative ways, and HSTS is a promise you should only make once your
domain is HTTPS for good. A header a route sets for itself always wins. To add,
change or drop one install-wide, pass securityHeaders (false removes a
default):
export const startInstance = createVitNodeStart({
config: vitNodeConfig,
securityHeaders: {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'Referrer-Policy': 'no-referrer',
},
})Add your own middleware
For things your installation genuinely owns - a request id, a tracing span, a maintenance-mode gate:
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
vite.config.ts needs one VitNode plugin, vitnode(). It bundles the
environment handling, the dev server's dependency pre-bundling, the SSR
externals, the Tailwind source list, the plugin route generator, the
development error page, the WebSocket route and Nitro (the server your app
runs on), in the order they have to run. Add it before tanstackStart():
import tailwindcss from '@tailwindcss/vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import { vitnode } from '@vitnode/core/framework/vite'
import { defineConfig } from 'vite'
export default defineConfig({
resolve: { tsconfigPaths: true },
plugins: [
vitnode({ appRoot: import.meta.dirname }),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})A generated app also adds devtools() from @tanstack/devtools-vite, and a
single app that embeds the API passes webSocket: true.
vitnode() options
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
appRoot | string | Yes | - | The app folder, where src/vitnode.config.ts lives. Always import.meta.dirname |
clientEnv | string[] | No | [] | Extra environment variables published to the browser |
hostRoutesDir | string | null | No | routesDirectory in tsr.config.json, else src/routes | Where the app's own route files are, checked for collisions with plugin routes; null reads none |
nitro | Nitro config | No | - | Nitro's own options: preset, routeRules, handlers, prerender and the rest |
webSocket | boolean | No | false | Mounts the WebSocket route, so the app serves /api/ws itself. For an app that embeds the API |
Nitro
vitnode() already adds nitro(), so don't add it again. Nitro's own options go
in nitro - a deployment preset, routeRules, extra handlers, anything from
Nitro's config:
vitnode({
appRoot: import.meta.dirname,
nitro: {
preset: 'vercel',
rollupConfig: { external: [/^@sentry\//] },
routeRules: { '/legacy/**': { redirect: '/' } },
},
})VitNode adds its own Nitro settings on top of yours: the SSR externals in a
build, the development error page in vite dev, and the /api/ws route when
webSocket is true. Lists such as handlers are merged, so yours and VitNode's
both run.
The Node server from pnpm start sends static files and HTML uncompressed. Put
a reverse proxy, a CDN or a platform such as Vercel in front of it and let that
layer compress responses.
Nitro's compressPublicAssets can write a .br and a .gz twin next to every
built asset instead, but it stays off unless you turn it on in nitro yourself.
It compresses every file in the output at once, and on a 2-core, 8 GB build
machine that is enough to run out of memory. The twins only help when Nitro
itself serves the files: behind a CDN they are never read.
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.
Browser environment variables
Publish an extra key to the browser with clientEnv:
vitnode({ appRoot: import.meta.dirname, clientEnv: ['VITNODE_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.
If you ever need to drop or reorder one of the plugins inside vitnode(),
@vitnode/core/framework/vite also exports each of them: vitNodeEnv,
vitNodeOptimizeDeps, vitNodeSsrExternals, vitNodeTailwindSources,
vitNodePluginRoutes, vitNodeDevError and vitNodeWebSocket. The same entry
exports prerenderRoutes, which builds Nitro's prerender
list (see Prerendering).
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.
Which files Tailwind scans
You do not write @source lines for VitNode packages either. Tailwind only
generates a utility it has seen spelled out somewhere, and the components that
ship inside @vitnode/core and your plugins live in node_modules, which
Tailwind skips by default. So the plugin appends one @source per package to
every stylesheet that imports Tailwind:
@import 'tailwindcss';
@source "<app>/node_modules/@vitnode/core/dist/src/**/*.js";
@source "<app>/node_modules/site-notes/dist/src/**/*.js";The list is @vitnode/core plus every plugin in your vitnode.config.ts, and it
covers each package's whole build output. Installing a plugin is therefore still
one edit: its styles work the moment it is configured.
This one fails quietly in a way worth recognising, because nothing errors. The class name lands in the DOM and hot reloading works perfectly - the element just has no styling, and reloading the page does not help, because the rule was never generated. If you ever see that for a package of your own, it is missing from the scan, not broken.