Skip to content

Build & Deployment

How τjs builds applications and deployment strategies.

τjs uses Vite to build frontend applications and produces:

  • Client assets - Browser JavaScript and CSS
  • SSR bundles - Server-side rendering modules
  • Manifests - Mapping of source files to built assets

The build process is designed for multi-app architectures with separate bundles per application.

A complete production build involves:

  1. Client assets (Vite)
  2. SSR bundles (Vite)
  3. Server bundle (esbuild/rollup)

These steps are intentionally separate for flexibility.

Terminal window
npm run build:client

What happens:

  1. Cleans dist/ directory
  2. Runs Vite for each app in config
  3. Outputs browser assets to dist/client/{entryPoint}/
  4. Generates manifest.json per app
  5. Copies index.html if present

Output structure:

dist/client/
├── app/
│ ├── assets/
│ │ ├── entry-client-abc123.js
│ │ ├── App-def456.js
│ │ └── index-ghi789.css
│ ├── manifest.json
│ └── index.html
└── admin/
├── assets/
├── manifest.json
└── index.html
Terminal window
npm run build:ssr
# or
BUILD_MODE=ssr npm run build

What happens:

  1. Does not clean dist/ (preserves client assets)
  2. Runs Vite in SSR mode for each app
  3. Outputs SSR bundles to dist/ssr/{entryPoint}/
  4. Generates ssr-manifest.json per app

Output structure:

dist/ssr/
├── app/
│ ├── ssr-manifest.json
│ └── server.js
└── admin/
├── ssr-manifest.json
└── server.js
Terminal window
npm run build:server

Bundles your Fastify server code (not part of τjs):

dist/server/
└── index.js

This is your own server code bundled for production.

Customise Vite configuration per app:

taujs.config.ts
export default defineConfig({
apps: [
{
appId: "web",
entryPoint: "client",
plugins: [
react(),
visualizer(), // Bundle analyser
],
},
],
});

τjs provides default aliases:

'@client' → app root (e.g., client/app/)
'@server' → project/src/server
'@shared' → project/src/shared

Override or extend:

// taujs.config.ts with custom aliases
export default defineConfig({
alias: {
"@components": path.resolve(__dirname, "client/shared/components"),
"@utils": path.resolve(__dirname, "client/shared/utils"),
},
apps: [
/* ... */
],
});

Client build:

  • Uses public/ at project root
  • All apps share the same public directory
  • Assets copied to dist/client/

SSR build:

  • Sets publicDir: false
  • No public assets processed during SSR build

After a complete build:

dist/
├── client/
│ ├── app/
│ │ ├── assets/
│ │ ├── manifest.json
│ │ └── index.html
│ └── admin/
│ ├── assets/
│ ├── manifest.json
│ └── index.html
├── ssr/
│ ├── app/
│ │ ├── ssr-manifest.json
│ │ └── server.js
│ └── admin/
│ ├── ssr-manifest.json
│ └── server.js
└── server/
└── index.js

τjs supports selective per-app builds, allowing you to build only the apps you care about instead of running a full multi-app build every time. This is useful for:

  • Monorepos with many apps
  • CI pipelines that detect changed apps
  • Local workflows where you only want to build a single surface

There is no cross-app bundling or shared-chunk inference. Each app remains an isolated build unit, which keeps MFE boundaries clean.


If you run your build script without any flags or env variables, τjs builds every app defined in your taujs.config.ts.

Terminal window
node scripts/build.mjs

Example build script:

scripts/build.mjs
import path from "node:path";
import { fileURLToPath } from "node:url";
import { taujsBuild } from "@taujs/server";
import config from "../taujs.config.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
await taujsBuild({
clientBaseDir: path.resolve(__dirname, "src/client"),
projectRoot: __dirname,
config,
});

To build only one app, pass --app, --apps, or -a:

Terminal window
node scripts/build.mjs --app admin

Multiple apps:

Terminal window
node scripts/build.mjs --apps admin,marketing

The filter matches both:

  • appId from taujs.config.ts
  • entryPoint (usually the client subfolder name)

So any of these work:

Terminal window
node scripts/build.mjs --app admin
node scripts/build.mjs --app @acme/admin

Build specific apps using environment variables (CI-friendly)

Section titled “Build specific apps using environment variables (CI-friendly)”

CI pipelines often prefer environment variables instead of CLI args:

Terminal window
TAU_APP=admin node scripts/build.mjs

Multiple:

Terminal window
TAU_APPS=admin,marketing node scripts/build.mjs

Precedence: CLI flags override environment variables if both are present.


τjs uses BUILD_MODE to determine which bundle to produce:

BUILD_MODEResult
clientClient bundle(s)
ssrSSR bundle(s)
(unset)Falls back to default logic in your build script

Examples:

Terminal window
BUILD_MODE=client node scripts/build.mjs --app admin
BUILD_MODE=ssr TAU_APP=admin node scripts/build.mjs

Typical package.json setup:

{
"scripts": {
"build:client": "BUILD_MODE=client node scripts/build.mjs",
"build:ssr": "BUILD_MODE=ssr node scripts/build.mjs",
"build:client:admin": "BUILD_MODE=client TAU_APP=admin node scripts/build.mjs",
"build:ssr:admin": "BUILD_MODE=ssr TAU_APP=admin node scripts/build.mjs"
}
}

Selective builds are per-app, not cached or partial builds.

  • Client builds always wipe dist/ before building.
  • SSR builds do not delete dist/ by default.
  • Only the targeted apps are passed through Vite.

This avoids stale assets and keeps each app isolated.


Fastify serves both static assets and SSR:

server/index.ts
import Fastify from "fastify";
import fastifyStatic from "@fastify/static";
import { createServer } from "@taujs/server";
import path from "node:path";
const fastify = Fastify({ logger: false });
// Serve static assets
await fastify.register(fastifyStatic, {
root: path.join(process.cwd(), "dist", "client"),
prefix: "/",
wildcard: false,
});
// τjs handles SSR
await createServer({
fastify,
config,
serviceRegistry,
clientRoot: "dist/client",
});
await fastify.listen({ port: 3000, host: "0.0.0.0" });

Deployment:

Terminal window
npm run build
npm start

Upload client assets to CDN:

Build and upload:

Terminal window
npm run build:client
# Upload dist/client/ to CDN
aws s3 sync dist/client/ s3://my-bucket/assets/
npm run build:ssr
npm run build:server
# Deploy server with SSR bundles

CDN cache rules:

/assets/* → max-age=31536000, immutable
/*.html → no-cache
Other files → no-cache unless hashed

Server configuration:

// Don't serve static assets - CDN handles them
await createServer({
fastify,
config,
serviceRegistry,
clientRoot: "dist/client", // Still needed for manifests
registerStaticAssets: false, // Disable static middleware
});

Nginx serves static assets, proxies SSR to Node:

# Nginx configuration
upstream nodejs {
server localhost:3000;
}
server {
listen 80;
server_name example.com;
# Static assets - long cache
location ~ ^/.+/assets/ {
root /app/dist/client;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Try static files first, then proxy to Node
location / {
root /app/dist/client;
try_files $uri @nodejs;
}
# Proxy to Node.js for SSR
location @nodejs {
proxy_pass http://nodejs;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
CMD ["node", "dist/server/index.js"]
/assets/entry-client-abc123.js → max-age=31536000, immutable
/assets/index-def456.css → max-age=31536000, immutable

Why: Filenames include content hash. New content = new filename.

/app/index.html → no-cache
/admin/index.html → no-cache

Why: Entry points reference hashed assets. Must always be fresh.

Never invalidate:

  • Hashed assets in /assets/

Always invalidate:

  • HTML entry points
  • Unhashed files (if any)

CDN purge example:

Terminal window
# Only purge HTML files
aws cloudfront create-invalidation \
--distribution-id DISTID \
--paths "/*.html"

Problem: Assets not loading in production

Check:

  1. Did client build run first?
  2. Are assets in dist/client/{app}/assets/?
  3. Is static middleware configured?

Problem: Server can’t find SSR bundle

Check:

  1. SSR bundles in dist/ssr/{app}/?
  2. ssr-manifest.json present?
  3. clientRoot points to dist/client/?

Problem: Users seeing old code

Solution: Invalidate HTML on deployment:

Terminal window
# After deploy
aws cloudfront create-invalidation \
--distribution-id DISTID \
--paths "/*.html"

Vite automatically code splits:

// Lazy load heavy components
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
);
}

Enable compression in production:

server/index.ts
import fastifyCompress from "@fastify/compress";
await fastify.register(fastifyCompress, {
global: true,
encodings: ["gzip", "deflate"],
});