Configuration

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.

A VitNode app has two configuration files, and the line between them is one question: may a browser hold this?

FileHoldsReached by
src/vitnode.config.tslocales, metadata, theme, debug, enabled plugin idsthe browser, the server, and your Vite build
src/vitnode.server.config.tsmessage loadersthe server only

vitnode.config.ts - the shared config

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.

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.

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/lib/i18n/shared.ts
export type Locale = (typeof vitNodeConfig.i18n.locales)[number]['code']

Enabled plugins

Register a plugin with its own factory:

import { blogPlugin } from '@vitnode/blog/config'

plugins: [blogPlugin()]

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

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.

Also register its locale files

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 has the detail.

Build-time cost

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:

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:

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 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.


start.ts - the 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 three things, and none of them is optional:

MiddlewareWhat it does
CSRFRejects cross-site requests to server functions (handlerType === 'serverFn')
LocaleCanonical 308 redirects, the remembered-locale cookie, and the document cache directive
DocumentsForces Cache-Control: private, no-store onto every HTML response

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.

Adding 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.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:

apps/web/vite.config.ts
import { vitnode } from '@vitnode/core/framework/vite'

export default defineConfig({
  plugins: [
    vitnode({ appRoot: import.meta.dirname }),
    nitro({ compressPublicAssets: { brotli: true, gzip: true } }),
    tailwindcss(),
    tanstackStart(),
    viteReact(),
  ],
})

compressPublicAssets writes a .br and a .gz twin next to every built asset, so the Node server from pnpm start serves scripts and styles compressed the way a CDN would. The server-rendered HTML itself is not compressed by the Node server; put a reverse proxy or a platform such as Vercel in front of it for that.

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:

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 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.

Learn More