τjs Configuration
Complete reference for configuring τjs applications.
Overview
Section titled “Overview”τjs uses a declarative configuration file (taujs.config.ts) where you define:
- Server settings (host, port, HMR)
- Applications and their entry points
- Routes with rendering strategies
- Security policies (CSP, authentication)
- Data loading patterns
All configuration is validated at startup with helpful error messages.
Basic Configuration
Section titled “Basic Configuration”import { defineConfig } from "@taujs/server/config";
export default defineConfig({ server: { host: "localhost", port: 5173, hmrPort: 5174, }, apps: [ { appId: "web", entryPoint: "client", routes: [ { path: "/", attr: { render: "ssr", data: async () => ({ message: "Hello World" }), }, }, ], }, ],});Type Definitions
Section titled “Type Definitions”type TaujsConfig = { server?: ServerConfig; security?: SecurityConfig; apps: AppConfig[]; alias?: Record<string, string>; vite?: TaujsViteOverride;};
type ServerConfig = { host?: string; // Default: 'localhost' port?: number; // Default: 5173 hmrPort?: number; // Default: 5174};
type AppConfig = { appId: string; entryPoint: string; plugins?: PluginOption[]; routes?: AppRoute[];};
type AppRoute = { path: string; attr?: RouteAttributes;};The vite and alias fields are the declared Vite customisation surface - see
Vite Configuration below for their types and merge behaviour.
Server Configuration
Section titled “Server Configuration”Control where and how τjs runs.
export default defineConfig({ server: { host: "localhost", port: 5173, hmrPort: 5174, },});Configuration Precedence
Section titled “Configuration Precedence”Values are resolved in this order (highest precedence first):
- CLI flags:
--host,--port,--hmr-port
npm run dev -- --host 0.0.0.0 --port 3000-
Environment variables:
HOSTorFASTIFY_ADDRESSPORTorFASTIFY_PORTHMR_PORT
-
Config object:
server.*properties -
Defaults:
{ host: 'localhost', port: 5173, hmrPort: 5174 }
Host Values
Section titled “Host Values”server: { host: "localhost"; // Loopback only (not accessible from network) host: "0.0.0.0"; // All interfaces (accessible from network)}CLI shorthand:
npm run dev -- --host # Automatically becomes 0.0.0.0App Configuration
Section titled “App Configuration”Define frontend applications with their entry points and routes.
apps: [ { appId: "web", entryPoint: "client", routes: [ /* ... */ ], plugins: [ /* optional Vite plugins */ ], }, { appId: "admin", entryPoint: "admin", routes: [ /* ... */ ], },];App Properties
Section titled “App Properties”| Property | Type | Required | Description |
|---|---|---|---|
appId | string | Yes | Unique identifier for this app |
entryPoint | string | Yes | Directory under client root |
routes | AppRoute[] | No | Route definitions |
plugins | PluginOption[] | No | Vite plugins for this app |
Any standard Vite plugin is accepted in plugins - the τjs renderer plugins are the
scaffolded defaults, not a closed set. Scope differs by mode: at build time each app is
built with exactly its own list; in development τjs runs one shared Vite dev server, so all
apps’ plugin lists are composed into one list and duplicate plugin names are dropped (first
occurrence wins, and every collision is reported at warn level - see
Plugin composition).
apps[].plugins is one of three declared plugin channels; the top-level
vite field and the taujsBuild({ vite }) escape hatch are the
other two. τjs never reads a vite.config.* - if one sits where Vite used to probe for it,
τjs emits a migration warning naming the file and pointing at these channels
(see Vite Configuration).
Entry Point Structure
Section titled “Entry Point Structure”Each entryPoint directory must contain:
client/{entryPoint}/├── index.html # HTML template├── entry-client.tsx # Client hydration entry└── entry-server.tsx # SSR render entryVite Configuration
Section titled “Vite Configuration”τjs owns the Vite topology - roots, inputs, output directories, manifests, aliases, and the
single shared development server - and exposes the fields it does not own through two
declared channels in taujs.config.ts:
alias- the declarative home for path aliases, applied identically in dev and build.vite- an allowlisted Vite override (TaujsViteOverride), applied symmetrically to the shared dev server and to every per-app build.
A third channel, the taujsBuild({ vite }) option, remains as a build-only escape hatch (see
the build guide).
τjs never reads a vite.config.*. Both the dev server and every build pin
configFile: false, so Vite never probes for one. If a vite.config.* sits where Vite used
to discover it (the shared client base root in dev, each per-app entry root in build), τjs
emits a migration warning naming the file, stating that it is not loaded, and pointing at the
vite / alias fields. A project-root vite.config.* was never read and is not warned about.
This is not a limitation of the ecosystem - the vite field is your Vite configuration, with
a topology-aware home (see Reusing Vite fragments).
The vite field
Section titled “The vite field”type TaujsViteOverride = TaujsViteConfig | ((ctx: TaujsViteContext) => TaujsViteConfig);
type TaujsConfig = CoreTaujsConfig & { // ... alias?: Record<string, string>; vite?: TaujsViteOverride;};vite is either a static TaujsViteConfig object or a function of a discriminated
serve/build context. The type is an explicit allowlist - only the supported fields appear, so
the editor refuses a protected field up front rather than the merge dropping it silently.
type TaujsViteConfig = { // Appended to the framework plugin list (append + dedupe by name). plugins?: PluginOption[]; // Shallow-merged with the framework defines. define?: Record<string, unknown>; // Per-engine deep merge; only preprocessorOptions is admitted. css?: { preprocessorOptions?: CSSOptions["preprocessorOptions"]; }; // Dev-only (see below); never reaches build configs. optimizeDeps?: TaujsOptimizeDeps; esbuild?: ESBuildOptions | false; logLevel?: LogLevel; // resolve subset - alias is intentionally excluded (use the top-level alias field). resolve?: ResolveOptions; // Build-tuning subset - the framework owns everything else under build. build?: { sourcemap?: BuildOptions["sourcemap"]; minify?: BuildOptions["minify"]; terserOptions?: BuildOptions["terserOptions"]; rollupOptions?: { external?: Rollup.ExternalOption; output?: { manualChunks?: Rollup.ManualChunksOption; }; }; };};The function form receives a discriminated context. Dev invokes it once with the serve
arm (there is no appId - the shared dev server is not per-app); build invokes it per app with
the build arm:
type TaujsViteContext = | { command: "serve"; mode: string; isSSRBuild: false; appId?: never; entryPoint?: never; clientRoot: string; } | { command: "build"; mode: string; isSSRBuild: boolean; appId: string; entryPoint: string; clientRoot: string; };export default defineConfig({ vite: { define: { __APP_VERSION__: JSON.stringify(version) }, plugins: [visualizer()], }, apps: [{ appId: "main", entryPoint: "", plugins: [pluginVue()] }],});// Function form - branch on the serve/build context.export default defineConfig({ vite: (ctx) => ({ // A visualiser only makes sense for client builds. plugins: ctx.command === "build" && !ctx.isSSRBuild ? [visualizer()] : [], }), apps: [/* ... */],});optimizeDeps (dev-only)
Section titled “optimizeDeps (dev-only)”optimizeDeps tunes Vite’s dependency pre-bundling on the shared dev server. It is
development-only - nothing from it reaches a client or SSR build. τjs admits a subset:
type TaujsOptimizeDeps = Pick< DepOptimizationOptions, "include" | "exclude" | "esbuildOptions">;includeforces a dependency into pre-bundling,excludekeeps an incompatible one out, andesbuildOptionsaccommodates dependency transforms, loaders, and esbuild plugins.includeandexcludeare deduplicated. The same package appearing in both is a config-validation error - it cannot be force-included and excluded at once.- The remaining Vite optimiser fields (
entries,noDiscovery,force, and the experimental set) are deliberately withheld: τjs retains authority over how the shared development application graph is discovered.
The alias field
Section titled “The alias field”alias is the declarative home for path aliases - the field the previous docs described but
that did not exist. It is sourced by both dev and build and merged over the framework
defaults (@client / @server / @shared), user values winning on conflict:
export default defineConfig({ alias: { // Relative values resolve against the project root before the map is handed to Vite. "@components": "./src/client/shared/components", // Absolute values pass through untouched. "@icons": "/opt/shared/icons", }, apps: [/* ... */],});Normalisation rule: Vite does not resolve relative alias replacements - it expects
absolute paths. τjs therefore normalises declarative values before the alias map is handed to
Vite (during dev-server setup and in taujsBuild, once the project root is known - not in
defineConfig itself): a relative replacement resolves against the project root, an absolute
one passes through untouched. This keeps the config file free of path.resolve(...)
boilerplate without shipping strings Vite would misread.
The project root is taujsBuild({ projectRoot }) at build time and the projectRoot option
on createServer in development (default process.cwd()). Pass the same directory to both -
the scaffold already does - so relative aliases resolve identically in dev and build.
The programmatic alias options on createServer (dev) and taujsBuild (build) remain as
escape hatches, layered above the declarative field (see the
build guide). Programmatic values are passed
through untouched (callers already hold real paths); a per-key override of a differing
declarative value is logged at debug level, never warned.
Vite support matrix
Section titled “Vite support matrix”The matrix is the supported set. Dev is the shared development server; Client build and
SSR build are the per-app production builds.
| Surface | Dev | Client build | SSR build | Merge behaviour |
|---|---|---|---|---|
plugins | Yes | Yes | Yes | Append + dedupe by name (first wins) |
define | Yes | Yes | Yes | Shallow merge |
css.preprocessorOptions | Yes | Yes | Yes | Per-engine deep merge |
optimizeDeps (include/exclude/esbuildOptions) | Yes | N/A | N/A | Dev-only subset; stripped from builds |
esbuild, logLevel | Yes | Yes | Yes | Override |
resolve.* (not alias) | Yes | Yes | Yes | Merge per key |
build.sourcemap / minify / terserOptions | N/A | Yes | Yes | Override |
build.rollupOptions.external | N/A | Yes | Yes | Override |
build.rollupOptions.output.manualChunks | N/A | Yes | Yes | Merge into output |
| aliases | Yes | Yes | Yes | Via top-level alias only |
root, base, publicDir, configFile, appType, server.*, build.outDir, build.ssr / ssrManifest, build.format / target / manifest, build.rollupOptions.input, resolve.alias | Protected | Protected | Protected | Rejected; logged at warn |
Protected fields are absent from TaujsViteConfig, so they cannot be supplied through the
typed surface at all. If one reaches the merge engine anyway (a JavaScript config, or an
as any cast), it is rejected and logged at warn rather than silently applied - including
build.manifest, which warns like its siblings. In dev the whole build key is rejected
(builds are a per-app concern), and optimizeDeps never reaches any build config.
How τjs composes Vite config
Section titled “How τjs composes Vite config”One precedence chain runs through one merge engine, in both dev and build:
framework invariants -> config.vite -> taujsBuild({ vite })- Each layer merges over the previous with the per-field rules in the matrix. A later layer
wins field conflicts while unrelated fields from earlier layers survive - so a CI wrapper
passing
taujsBuild({ vite: { build: { sourcemap: true } } })tunes only that field and keeps everyplugins,define, and CSS setting declared intaujs.config.ts. - Both layers coexisting is normal operation and is silent. A genuine per-field conflict between the two user layers is reported at warn, naming the field, both sources, and the winner (the programmatic layer). A framework default being overridden by a user layer is never warned.
- The dev server reads
config.viteonly;taujsBuild({ vite })is build-only and is not consulted in development.
Plugin composition
Section titled “Plugin composition”Plugins from every channel are composed by one rule, in declared order, deduped by plugin
name with the first occurrence winning across all sources. The order is:
- Dev (shared server): every app’s
pluginsin config order, thenconfig.vite.plugins, then the internal framework plugin(s). - Build (per app): the app’s
plugins, thenconfig.vite.plugins, thentaujsBuild.vite.plugins, then the internal framework plugin(s).
Every cross-source name collision is reported at warn with the plugin name, each declaring
source, and the winner. Plugin options are never serialised or compared - identity is by
name alone; a nameless plugin passes through undeduped. Internal framework plugins are
appended last and are exempt from the user dedupe. The τjs- name prefix (Greek tau,
U+03C4) is reserved: a user plugin carrying it is dropped with a warning, so it can neither
displace nor impersonate a framework plugin. The renderer wrappers use ordinary Latin names
(@taujs/react’s taujs:react-refresh-preamble-fix, @taujs/vue’s vite:vue) and are not
affected.
Reusing Vite fragments
Section titled “Reusing Vite fragments”Not auto-loading vite.config.ts does not close the Vite ecosystem. Reusable configuration
lives in an ordinary module, shareable with tools that genuinely are Vite-hosted:
import type { TaujsViteConfig } from "@taujs/server/config";
export const sharedVite = { define: { __VERSION__: JSON.stringify(version) }, plugins: [ecosystemPlugin()],} satisfies TaujsViteConfig;import { defineConfig } from "@taujs/server/config";import { sharedVite } from "./vite.shared";
export default defineConfig({ vite: sharedVite, apps: [/* ... */] });Vitest, Storybook, or a standalone Vite app import the same sharedVite pieces into their own
config files; τjs simply never discovers those files implicitly. The satisfies TaujsViteConfig
check keeps the shared fragment within the supported surface.
Route Configuration
Section titled “Route Configuration”Routes define URL patterns, rendering strategies, and data requirements.
Basic Route
Section titled “Basic Route”{ path: '/about', attr: { render: 'ssr' }}Route with Parameters
Section titled “Route with Parameters”{ path: '/users/:id', attr: { render: 'ssr', data: async (params) => ({ userId: params.id }) }}Route Properties
Section titled “Route Properties”| Property | Type | Required | Description |
|---|---|---|---|
path | string | Yes | URL pattern (path-to-regexp) |
attr | RouteAttributes | No | Rendering and data config |
Route Attributes
Section titled “Route Attributes”| Property | Type | Default | Description |
|---|---|---|---|
render | 'ssr' | 'streaming' | Required | Rendering strategy |
hydrate | boolean | true | Add React on client |
meta | Record<string, unknown> | {} | Metadata for head |
middleware | Middleware | undefined | Auth and CSP |
data | DataHandler | undefined | Data loader |
head | HeadAttributes | undefined | Dynamic head data loader: { data, timeoutMs?, optional? }, resolved before the render starts on both strategies and passed to headContent as headData. timeoutMs must be positive finite (default 3000 ms); optional: true degrades loader failures to headData: undefined instead of failing the request |
Rendering Strategies
Section titled “Rendering Strategies”SSR (Server-Side Rendering)
Section titled “SSR (Server-Side Rendering)”Complete HTML rendered before sending:
{ path: '/products', attr: { render: 'ssr', data: async () => { const products = await db.products.findAll(); return { products }; } }}Characteristics:
- Data fully loaded before rendering
- Complete HTML in single response
- Guaranteed data in
headContent attr.head(if declared) resolves before the render and arrives asheadData
React renderer semantics (@taujs/react): the ssr strategy renders complete HTML with
React’s prerenderToNodeStream, so React.lazy and use() content is included in the response.
Earlier versions used renderToString, which silently replaced any suspending subtree with its
Suspense fallback. The render is bounded by the renderer’s ssrOptions.prerenderTimeoutMs
(default 10000 ms). On expiry, a page whose shell completed is served with its unfinished
Suspense boundaries in their fallback state - the client completes them after hydration - while a
page whose shell never completed fails the request instead of serving a blank page. Set
prerenderTimeoutMs: 0 to wait indefinitely.
Streaming SSR
Section titled “Streaming SSR”Progressive HTML delivery:
{ path: '/dashboard', attr: { render: 'streaming', meta: { // Required for streaming title: 'Dashboard', description: 'User dashboard' }, data: async () => { const metrics = await fetchMetrics(); return { metrics }; } }}Characteristics:
- Shell sent immediately
- Content streams as it renders
- Route
datamay not be ready whenheadContentruns - declareattr.headfor DYNAMIC head data (resolved before the shell, delivered asheadData);metaremains the static layer - Requires
metaproperty
Static (No Hydration)
Section titled “Static (No Hydration)”SSR without client-side JavaScript:
{ path: '/terms', attr: { render: 'ssr', hydrate: false }}Data Loading
Section titled “Data Loading”Direct Return
Section titled “Direct Return”{ path: '/about', attr: { render: 'ssr', data: async (params, ctx) => { const res = await fetch('https://api.example.com/about'); return await res.json(); } }}Service Descriptor
Section titled “Service Descriptor”{ path: '/users/:id', attr: { render: 'ssr', data: async (params) => ({ serviceName: 'UserService', serviceMethod: 'getUser', args: { id: params.id } }) }}Request Context
Section titled “Request Context”Data handlers receive context:
data: async (params, ctx) => { // ctx.traceId: Request trace ID // ctx.logger: Scoped logger // ctx.headers: Request headers
ctx.logger.info({ userId: params.id }, "Loading user");
return { user: await getUser(params.id) };};Security Configuration
Section titled “Security Configuration”Content Security Policy
Section titled “Content Security Policy”export default defineConfig({ security: { csp: { directives: { "default-src": ["'self'"], "script-src": ["'self'"], "style-src": ["'self'", "'unsafe-inline'"], "img-src": ["'self'", "data:", "https:"], }, }, },});CSP with Reporting
Section titled “CSP with Reporting”security: { csp: { directives: { 'default-src': ["'self'"], 'script-src': ["'self'"] }, reporting: { endpoint: '/api/csp-violations', reportOnly: false, onViolation: (report, req) => { console.log('CSP violation:', report); } } }}Per-Route CSP
Section titled “Per-Route CSP”{ path: '/embed', attr: { render: 'ssr', middleware: { csp: { mode: 'merge', // or 'replace' directives: { 'frame-ancestors': ["'self'", 'https://trusted.com'] } } } }}Dynamic CSP
Section titled “Dynamic CSP”{ path: '/user/:id', attr: { render: 'ssr', middleware: { csp: { directives: ({ params }) => ({ 'img-src': [ "'self'", `https://cdn.example.com/users/${params.id}/` ] }) } } }}Disabling CSP
Section titled “Disabling CSP”// Hard disable - no header{ path: '/legacy', attr: { middleware: { csp: false } }}
// Soft disable - use global only{ path: '/report', attr: { middleware: { csp: { disabled: true } } }}Authentication
Section titled “Authentication”Require Authentication
Section titled “Require Authentication”{ path: '/dashboard', attr: { render: 'ssr', middleware: { auth: {} } }}Role-Based Access
Section titled “Role-Based Access”{ path: '/admin', attr: { render: 'ssr', middleware: { auth: { roles: ['admin', 'superadmin'] } } }}Custom Auth Metadata
Section titled “Custom Auth Metadata”{ path: '/api/data', attr: { render: 'ssr', middleware: { auth: { strategy: 'api-key', redirect: '/login' } } }}Note: τjs doesn’t interpret roles, strategy, or redirect. These are metadata for your authenticate decorator to read.
Complete Examples
Section titled “Complete Examples”Single Page Application
Section titled “Single Page Application”export default defineConfig({ server: { port: 3000, }, apps: [ { appId: "web", entryPoint: "client", routes: [ { path: "/", attr: { render: "ssr", data: async () => ({ title: "Home", content: "Welcome", }), }, }, ], }, ],});Multi-App Configuration
Section titled “Multi-App Configuration”export default defineConfig({ server: { host: "localhost", port: 5173, }, apps: [ { appId: "customer", entryPoint: "app", routes: [ { path: "/app/:feature?/:id?", attr: { render: "streaming", meta: { title: "App" }, middleware: { auth: { strategy: "jwt" } }, }, }, ], }, { appId: "admin", entryPoint: "admin", routes: [ { path: "/admin/:section?/:id?", attr: { render: "ssr", middleware: { auth: { strategy: "session", roles: ["admin"], }, }, }, }, ], }, ], security: { csp: { directives: { "default-src": ["'self'"], "script-src": ["'self'"], "style-src": ["'self'", "'unsafe-inline'"], }, }, },});Validation
Section titled “Validation”τjs validates configuration at startup:
[τjs] [config] Loaded 2 app(s), 15 route(s) in 2.3ms[τjs] [security] CSP configured (15/15 routes) in 0.8ms[τjs] [auth] ✓ 5 route(s) require authCommon Errors
Section titled “Common Errors”| Error | Cause | Solution |
|---|---|---|
| ”At least one app must be configured” | Empty apps array | Add at least one app |
| ”Routes require auth but authenticate() missing” | Auth routes without decorator | Add authenticate() to Fastify |
| ”Route path declared in multiple apps” | Duplicate paths | Use unique paths per app |
| ”Entry client file not found” | Missing build artifacts | Run npm run build |
| ”meta required for streaming routes” | Streaming without meta | Add meta: {} to route |
Best Practices
Section titled “Best Practices”1. Use defineConfig
Section titled “1. Use defineConfig”// type checkingexport default defineConfig({ apps: [ /* ... */ ],});
// less ideal - no type checkingexport default { apps: [ /* ... */ ],};2. Group Routes by Feature
Section titled “2. Group Routes by Feature”const authRoutes: AppRoute[] = [ { path: "/login", attr: { render: "ssr" } }, { path: "/register", attr: { render: "ssr" } },];
const dashboardRoutes: AppRoute[] = [ { path: "/dashboard", attr: { render: "streaming", meta: {} } }, { path: "/settings", attr: { render: "ssr" } },];
export default defineConfig({ apps: [ { appId: "web", entryPoint: "client", routes: [...authRoutes, ...dashboardRoutes], }, ],});3. Use Service Descriptors
Section titled “3. Use Service Descriptors”// testable, reusabledata: async (params) => ({ serviceName: "UserService", serviceMethod: "getUser", args: { id: params.id },});
// less ideal - mixed concernsdata: async (params) => { const res = await fetch(`/api/users/${params.id}`); return await res.json();};4. Provide Complete Meta for Streaming
Section titled “4. Provide Complete Meta for Streaming”// reliable SEO{ path: '/blog/:slug', attr: { render: 'streaming', meta: { title: 'Blog Post', description: 'Read our latest blog', ogType: 'article' } }}5. Use Structured Logging
Section titled “5. Use Structured Logging”data: async (params, ctx) => { ctx.logger.info({ userId: params.id }, "Loading user");
try { const user = await getUser(params.id); return { user }; } catch (err) { ctx.logger.error({ userId: params.id, error: err }, "Load failed"); throw err; }};Environment-Specific Configuration
Section titled “Environment-Specific Configuration”Using Environment Variables
Section titled “Using Environment Variables”export default defineConfig({ server: { host: process.env.HOST || "localhost", port: parseInt(process.env.PORT || "5173"), }, apps: [ { appId: "web", entryPoint: "client", routes: [ /* ... */ ], }, ],});Conditional Configuration
Section titled “Conditional Configuration”const isDev = process.env.NODE_ENV !== "production";
export default defineConfig({ server: { port: isDev ? 5173 : 3000, }, security: { csp: { directives: { "script-src": isDev ? ["'self'", "'unsafe-inline'"] // Dev only : ["'self'"], // Production }, }, },});