Documentation

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.

FileHoldsRuns inSection
src/vitnode.config.tsLocales, metadata, logo, menus, theme, enabled pluginsBrowser, server and your Vite buildShared config
src/vitnode.server.config.tsMessage loadersServer onlyServer config
src/start.tsThe request middlewareServerRequest pipeline
vite.config.tsThe vitnode() Vite plugin and its optionsVite (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:

apps/web/src/vitnode.config.ts
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

NameTypeRequiredDefaultDescription
i18nobjectYes-Languages and locale routing. See Languages & Localization
metadataobjectYes-Site title, used in page titles as {page} - {shortTitle ?? title}
pluginsbuildPlugin() resultsYes-The installed plugins' frontend factories. See Enabled plugins
editorobjectNo-Settings shared by every Editor in the app
logocomponentNoVitNode logoThe header logo. See Logo
navigationobjectNo-The header menu and the phone bottom bar. See Navigation
pageCacheobjectNo-Server-rendered HTML cache storage and per-route overrides. See Page cache reference
themeobjectNo-Theme provider options, such as defaultTheme: 'system'
viewTransitionsbooleanNotruefalse turns off the page fade and logo morph. See View transitions

Who reads it

Three very different readers depend on this file:

  1. The document shell renders metadata, logo and theme, 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.
Plain data only

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:

apps/web/src/vitnode.config.ts
export type Locale = (typeof vitNodeConfig.i18n.locales)[number]['code']

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.

src/components/logo.tsx
export const Logo = () => (
  <img
    alt="Acme"
    className="h-8 w-auto"
    height={32}
    src="/logo.svg"
    width={96}
  />
)
src/vitnode.config.ts
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 holds the header menu and the phone bottom bar. Each link is an href, a title message key and an optional icon component:

src/vitnode.config.ts
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:

src/vitnode.config.ts
export const vitNodeConfig = buildConfig({
  // ...
  viewTransitions: false,
})

See View transitions for what this changes.

Enabled plugins

Register a plugin with its own factory:

src/vitnode.config.ts
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 fileFromLoaded
src/plugin-routes.gen.tsthe plugin's src/routes.tsper route, behind lazy()
src/admin-nav.gen.tsthe plugin's admin/navwith the AdminCP shell
src/content-registry.gen.tsthe plugin's admin/contentbehind a dynamic import() in src/router.tsx
src/content-modules.gen.tsthe plugin's content moduleby the API, through buildApiConfig({ contentModules })
src/blocks.gen.tsthe plugin's widgets moduleonly by routes that render blocks
src/package-messages.gen.tsthe factory's localeFilesper request, one locale at a time
src/page-cache.gen.tsroute cache optionsby src/server.ts, to pick each page's HTML cache policy
src/api-registry.gen.tsthe plugin's config.apinever. Type-only, it teaches the fetcher which plugins exist
src/plugin-inventory.gen.tsthe factory's metadataby 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.

Where its locale files come from

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.

Build-time cost

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:

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 '@/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:

apps/web/src/server/messages.server.ts
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.

The API has its own config

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:

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 two middleware before any of yours, and neither is optional:

OrderMiddlewareWhat it does
1CSRFRejects cross-site requests to server functions (handlerType === 'serverFn')
2LocaleCanonical 308 redirects and the remembered-locale cookie; on every HTML response, forces Cache-Control: private, no-store and adds the security headers
3Your ownWhatever requestMiddleware lists, in order
OptionTypeRequiredDefaultDescription
configvitNodeConfigYes-The shared config; its i18n drives the locale redirects
requestMiddlewareTanStack Start middleware arrayNo[]Your own middleware, run after CSRF and locale handling
securityHeadersRecord<string, string | false>No-Adds, changes or (false) removes a default security header
Why the factory exists

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 caching stays the API's

/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/swagger is 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:

HeaderValueWhy
Content-Security-Policyframe-ancestors 'self'Other sites cannot frame your pages (hello, clickjacking)
X-Frame-OptionsSAMEORIGINThe same, for browsers that predate frame-ancestors
X-Content-Type-OptionsnosniffThe browser trusts the declared content type
Referrer-Policystrict-origin-when-cross-originPaths 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):

apps/web/src/start.ts
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:

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

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():

apps/web/vite.config.ts
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

NameTypeRequiredDefaultDescription
appRootstringYes-The app folder, where src/vitnode.config.ts lives. Always import.meta.dirname
clientEnvstring[]No[]Extra environment variables published to the browser
hostRoutesDirstring | nullNoroutesDirectory in tsr.config.json, else src/routesWhere the app's own route files are, checked for collisions with plugin routes; null reads none
nitroNitro configNo-Nitro's own options: preset, routeRules, handlers, prerender and the rest
webSocketbooleanNofalseMounts 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:

apps/web/vite.config.ts
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 pluginsWhy
vite devinlinedVite owns the modules, so a rebuilt package reaches the server render immediately
vite buildexternalthe 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:

what your styles.css effectively becomes
@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.