Skip to content

Micro-Frontends

How τjs uses build-time orchestration to support multiple frontend applications.

τjs enables multiple frontend applications in a single deployment through build-time composition and server-side routing. Unlike runtime federation systems, τjs builds each application separately and routes requests at the HTTP layer.

Key characteristics:

  • Each app has its own Vite build
  • Each app has its own bundle
  • Server routes requests to the correct app
  • No runtime coordination between apps
  • No shared runtime state

When you run taujs build:

  1. Vite builds each app separately

    • Each entryPoint directory is built independently
    • Produces separate bundles with hashed filenames
    • Generates manifests for each app
  2. Assets organised per app

dist/client/
├── app/ # Customer app bundle
│ ├── assets/
│ ├── manifest.json
│ └── index.html
└── admin/ # Admin app bundle
├── assets/
├── manifest.json
└── index.html
  1. Tree-shaking per app
    • Vite analyses each app’s imports
    • Only includes code that app uses
    • Shared dependencies optimised automatically

When a request arrives:

  1. Fastify receives request
GET /admin/users
  1. τjs matches route
// Finds matching route in config
{
path: '/admin/:section',
appId: 'admin'
}
  1. Server loads correct app

    • Reads admin app’s manifests
    • Loads SSR bundle for admin app
    • Uses admin app’s assets
  2. Response delivered

    • HTML rendered with admin app code
    • Client receives only admin bundle
    • No customer app code sent

No runtime coordination - each request is independent.

Define multiple apps in your τjs config:

taujs.config.ts
import { defineConfig } from "@taujs/server/config";
export default defineConfig({
apps: [
{
appId: "customer",
entryPoint: "app",
routes: [
{
path: "/app/:feature?/:id?",
attr: {
render: "streaming",
middleware: { auth: { strategy: "jwt" } },
},
},
],
},
{
appId: "admin",
entryPoint: "admin",
routes: [
{
path: "/admin/:section?/:id?",
attr: {
render: "ssr",
middleware: {
auth: {
strategy: "session",
roles: ["admin", "superadmin"],
},
},
},
},
],
},
{
appId: "docs",
entryPoint: "docs",
routes: [
{
path: "/docs/:slug*",
attr: {
render: "ssr",
hydrate: true,
},
},
],
},
],
});
project/
├── client/
│ ├── app/ # Customer application
│ │ ├── entry-client.tsx
│ │ ├── entry-server.tsx
│ │ ├── index.html
│ │ └── App.tsx
│ ├── admin/ # Admin application
│ │ ├── entry-client.tsx
│ │ ├── entry-server.tsx
│ │ ├── index.html
│ │ └── App.tsx
│ ├── docs/ # Documentation application
│ │ ├── entry-client.tsx
│ │ ├── entry-server.tsx
│ │ └── index.html
│ └── shared/ # Shared code (optional)
│ ├── components/
│ ├── hooks/
│ └── utils/
├── server/
│ ├── index.ts
│ └── services/
└── taujs.config.ts
Terminal window
npm run build

For each app:

  1. Client build (dist/client/{entryPoint}/)

    • Vite scans entry-client.tsx
    • Bundles all imported code
    • Outputs hashed assets
    • Generates manifest.json
  2. SSR build (dist/ssr/{entryPoint}/)

    • Vite scans entry-server.tsx
    • Bundles SSR-compatible code
    • Outputs server.js
    • Generates ssr-manifest.json
  3. Dependency resolution

    • Shared dependencies (React) included in each bundle
    • Vite optimises bundle splitting automatically
    • No manual shared chunk configuration needed

Dependencies appear in each app’s bundle based on imports:

// Customer app imports
import React from "react";
import { useQuery } from "@tanstack/react-query";
// Admin app imports
import React from "react";
import { create } from "zustand";

Result:

  • Both bundles include React (shared dependency)
  • Customer bundle includes react-query
  • Admin bundle includes zustand
  • No runtime coordination needed

Vite tree-shakes per app:

shared/utils.ts
export function formatDate(date: Date) {
/* ... */
}
export function formatCurrency(amount: number) {
/* ... */
}
export function parseJSON(str: string) {
/* ... */
}
// Customer app only imports formatDate
import { formatDate } from "@shared/utils";
// Customer bundle only includes formatDate
// formatCurrency and parseJSON are tree-shaken away

Navigating between apps requires a full page load:

// Customer app
<a href="/admin/users">Go to Admin</a>
// Full page load when clicked

Why: Different apps = different bundles. Browser must load new bundle.

Within an app, use client-side routing:

// Customer app - same bundle
import { BrowserRouter, Route, Link } from "react-router-dom";
function CustomerApp() {
return (
<BrowserRouter>
<Link to="/app/dashboard">Dashboard</Link> {/* No page reload */}
<Link to="/app/settings">Settings</Link> {/* No page reload */}
<Routes>
<Route path="/app/dashboard" element={<Dashboard />} />
<Route path="/app/settings" element={<Settings />} />
</Routes>
</BrowserRouter>
);
}
apps: [
{
appId: "marketing",
entryPoint: "marketing",
routes: [
{ path: "/", attr: { render: "ssr", hydrate: false } },
{ path: "/pricing", attr: { render: "ssr", hydrate: true } },
{ path: "/about", attr: { render: "ssr", hydrate: false } },
],
},
{
appId: "app",
entryPoint: "app",
routes: [
{
path: "/app/:page*",
attr: {
render: "streaming",
middleware: { auth: {} },
},
},
],
},
];
apps: [
{
appId: "customer",
entryPoint: "app",
routes: [
{
path: "/app/:page*",
attr: {
render: "streaming",
middleware: { auth: { strategy: "jwt" } },
},
},
],
},
{
appId: "admin",
entryPoint: "admin",
routes: [
{
path: "/admin/:section*",
attr: {
render: "ssr",
middleware: {
auth: {
strategy: "session",
roles: ["admin"],
},
},
},
},
],
},
];

Use multiple apps when:

  • Clear domain boundaries (customer vs admin vs marketing)
  • Different teams own different parts
  • Different deployment schedules needed
  • Security isolation required (admin code never sent to customers)
  • Different performance characteristics

Use single app when:

  • Application is cohesive with no clear boundaries
  • Small team maintaining everything
  • Shared navigation and state throughout
  • Similar performance needs across all pages