A scaffold built on TanStack Start 1.x + Drizzle ORM + TailwindCSS 4 for building shop-floor MES applications on the Tier0 platform. The goal is to give agents a light but complete starting point: authentication, database connectivity, platform deployment, build outputs, and layout contracts are pre-wired, while business pages and app-local components are generated for the target application instead of being constrained by a fixed template library.
| Layer | Technology |
|---|---|
| Framework | TanStack Start 1.x (Vite 8/Rolldown + TanStack Router, React 19 SSR) |
| ORM | Drizzle ORM + node-postgres |
| Validation | Zod (drizzle-zod derives schemas and types) |
| Styling | TailwindCSS 4 (Vite plugin, no PostCSS) + Tier0 design tokens |
| UI | Lightweight layout contracts + Tailwind utilities + Tier0 tokens; no prebuilt business component library |
| Charts | Recharts 3 (must be wrapped in <ResponsiveContainer>) |
| Tables | TanStack React Table 8 |
| Drag and drop | dnd-kit |
| Tier0 Platform SDK | @tier0/sdk (OpenAPI REST + MQTT over WebSocket) |
| Icons | Lucide React |
| Toasts | Sonner (Toaster is mounted in __root.tsx) |
| Motion | motion imported from @/lib/motion, never directly from motion/react |
npm install
# Configure DATABASE_URL in .env
npx drizzle-kit push # Optional: pre-sync schema locally; services can also bootstrap at runtime
npx tsx src/db/seed.ts # Optional: explicit seed / fixture reset
npm run dev # -> http://localhost:5173When the agent runs inside the Executor Pod, do not run
npm installmanually. The platform manages installs automatically, and manual installs can conflict with the shared volume. See AGENTS.md.
There is no explicit mode switch. If a variable is missing, the scaffold falls back to local defaults. If the variable is set, the corresponding capability is enabled.
| Variable | Required | When unset | When set |
|---|---|---|---|
DATABASE_URL |
✅ | — | Connects to PostgreSQL |
SESSION_SECRET |
Production ✅ | Random local value generated at boot | HMAC signing key for the session cookie; production must use 32+ chars |
DIRECT_DATABASE_URL |
— | Direct database connection for drizzle-kit push and seed.ts |
|
DB_SCHEMA |
Uses public |
Runs all queries inside the specified schema | |
APP_ID |
Defaults to "monoapp" |
Returned by /api/manifest as the app id |
|
VITE_BASE_PATH |
No URL prefix | Applied to Vite base, router basepath, and apiUrl() |
|
NEXT_PUBLIC_BASE_PATH |
Legacy fallback only | Used by apiUrl() when VITE_BASE_PATH is absent |
Tier0 SDK auth and connection variables are injected by the platform at deploy
time. Do not write them into .env.example, and do not expose UI in the
scaffold or generated apps for users to configure SDK auth, API keys, tokens,
OpenAPI hosts, MQTT hosts, or workspace bindings. Those values belong to the
SDK, the platform gateway, or the runtime environment.
| Variable | Purpose |
|---|---|
TIER0_API_HOST |
OpenAPI service host used by @tier0/sdk/openapi |
TIER0_API_KEY |
API credential used by both OpenAPI and MQTT |
TIER0_MQTT_HOST |
MQTT WebSocket broker host |
TIER0_MQTT_PORT |
MQTT WebSocket port, default 8084 |
If the platform explicitly requires browser-side Vite env access, the runtime
is responsible for injecting VITE_TIER0_*. Generated apps should not turn
those values into user-editable settings.
@tier0/sdk@0.1.3 ships as a dual ESM/CJS package. This scaffold still keeps
SDK access behind server-side lazy loaders so preview startup and normal SSR do
not execute optional platform I/O during page initialization. The SSR policy is
pinned in vite.config.ts:
ssr: {
external: ["pg", "@tier0/sdk", "mqtt"],
}SDK calls are loaded on the server through the lazy helper in
src/lib/tier0.ts. Generated apps should not top-level import SDK submodules,
should not move the SDK into ssr.noExternal, and should not bypass the SDK
with fallback MQTT clients or hand-written fetch wrappers.
For deployment details, see docs/platform-integration.md.
src/
start.ts <- TanStack Start global request middleware (SSO gateway, do not modify)
router.tsx <- Router factory exporting getRouter() (do not modify)
routeTree.gen.ts <- Generated by @tanstack/router-plugin (gitignored, do not edit)
styles/globals.css <- TailwindCSS 4 + theme tokens + keyframes (do not replace)
routes/ <- Interface layer (HTTP): file names define URLs and nesting
__root.tsx <- HTML document shell with Toaster (do not modify)
_app.tsx <- Workspace layout with Shell for management/planning/analytics/admin
_app.index.tsx <- Blank "/" scaffold placeholder; finished apps must replace or redirect it
station.tsx <- Station layout without sidebar for /station/* task-first workflows
review.tsx <- Review layout without sidebar for /review/* evidence/approval flows
login.tsx <- Role selection page outside Shell
api/ <- Server routes: thin wrappers that delegate to services/
health.ts <- Health check
manifest.ts <- Role manifest (do not modify)
auth/{select-role,me,logout}.ts <- Cookie auth handlers (do not modify)
services/ <- Domain layer: business rules, state machines, multi-step transactions
components/
Shell.tsx <- Left navigation shell; update defaultModules here
layouts/ <- StationLayout, ReviewLayout, MonitorLayout, and similar shell contracts
login-role-selector.tsx <- Client-side login buttons (do not modify)
toaster.tsx <- Sonner mount point
client-only.tsx <- Hydration boundary for SSR-incompatible libraries
<domain>/ <- App-local components generated per business need
db/{index,schema,seed}.ts <- Data layer: Drizzle client, table definitions, seed script
lib/ <- Cross-cutting utilities, not domain logic
auth.ts <- getCurrentUser() / requireAuth() (do not modify)
gateway.ts <- Gateway header parsing (do not modify)
route-handlers.ts <- withErrors() response/error wrapper (do not modify)
users.ts <- AppUser type (do not modify)
permissions.ts <- Permission matrix definition
hooks.ts <- Polling and request hooks
motion.ts <- motion/react re-export
tier0.ts <- Server-side lazy loader for @tier0/sdk
utils.ts <- cn(), apiUrl()
server.mjs <- Production Node HTTP entrypoint (do not modify)
vite.config.ts <- TanStack Start + Tailwind v4 + Vite 8 paths + Tier0 SDK SSR external rules
TanStack Router is file-based:
| File | URL | Notes |
|---|---|---|
routes/__root.tsx |
— | Wraps every page |
routes/_app.tsx |
— | Workspace layout for management/planning/analytics/configuration |
routes/station.tsx |
/station |
Station layout for scan/tap/confirm execution |
routes/review.tsx |
/review |
Review layout for exceptions/quality/approvals |
routes/_app.index.tsx |
/ |
Blank scaffold placeholder; finished apps must replace or redirect it |
routes/_app.work-orders.tsx |
/work-orders |
Under _app |
routes/station.receiving.tsx |
/station/receiving |
Under station |
routes/review.exceptions.tsx |
/review/exceptions |
Under review |
routes/_app.work-orders.$id.tsx |
/work-orders/:id |
Dynamic param |
routes/login.tsx |
/login |
Outside _app, no Shell |
routes/api/work-orders.ts |
/api/work-orders |
Server route |
routes/api/work-orders/$id.ts |
/api/work-orders/:id |
Nested server route |
_prefix: pathless, contributes no URL segment.separator: URL nesting$prefix: dynamic param- Choose layout by workflow:
stationfor execution/scan flows,reviewfor review/approval flows,_appfor management/analytics work - If built-in layouts do not fit, create a new prefixed layout such as
monitor.tsx,wizard.tsx,portal.tsx, oreditor.tsx
// src/routes/_app.work-orders.$id.tsx
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
export const Route = createFileRoute("/_app/work-orders/$id")({
validateSearch: z.object({ tab: z.enum(["details", "history"]).optional() }),
component: Page,
});
function Page() {
const { id } = Route.useParams();
const { tab } = Route.useSearch();
return <div>Order {id}, tab: {tab ?? "details"}</div>;
}The matching API route is src/routes/api/work-orders/$id.ts, where the
handler reads params.id from the first argument.
Only workspace pages belong under _app and appear in Shell.tsx
defaultModules. The scaffold keeps exactly one intentionally blank route,
_app.index.tsx, on purpose. A finished app must replace / with a real
dashboard, a redirect, or the actual entry experience. If the app has only one
primary page, make that page own / instead of leaving / blank and placing
the only real screen at a secondary route.
// src/routes/_app.work-orders.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_app/work-orders")({
component: WorkOrdersPage,
});
function WorkOrdersPage() {
return <div className="p-6">Orders</div>;
}Task-first station pages belong under station and should not appear in the
sidebar:
// src/routes/station.receiving.tsx
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/station/receiving")({
component: ReceivingPage,
});If the workflow needs a fundamentally different interaction model, create a
custom layout such as src/routes/monitor.tsx with
src/components/layouts/MonitorLayout.tsx. Custom layouts should stay minimal
and should not preload business cards, charts, or forms. Do not create an
empty pathless layout with no child pages, because it will conflict with /.
Single-experience apps such as monitor, station, review, or kiosk should not
keep the default sidebar home page. / must route directly to the real app
experience.
Step 1: Write the service in src/services/work-orders.ts. This is where
business logic, state machines, and transactions belong. The db client may
only appear in services/ and db/seed.ts. Each implemented module should
define runtime bootstrapModule(...) behavior near the top of the service so
preview and new tenant schemas can create tables, indexes, and baseline data on
first use. Shared bootstrap helpers create all module schemas/tables/indexes
first and seed afterward; do not hand-roll local create/seed ordering in each
service.
Step 2: Write the route in src/routes/api/work-orders.ts. Keep it thin:
requireAuth() -> schema.parse() -> service call -> Response.json(...),
wrapped in withErrors(...). Throw HttpError for client-facing failures or
let Zod issues bubble naturally.
For a full state-machine example with multi-step db.transaction(...) logic
and Zod validation, see
AGENTS.md §Build Order — Step 3.
In pages, always call fetch(apiUrl("/api/work-orders")). Do not hardcode
/api/... without apiUrl() or deployments with a base path will break.
Browser
|
| fetch(apiUrl("/api/..."))
v
src/routes/api/**.ts <- Interface layer: requireAuth -> Zod.parse -> service -> Response.json
| Wrapped by withErrors for {status,message} / Zod issue mapping
v
src/services/**.ts <- Domain layer: business logic, state machines, multi-step transactions
| Pure TypeScript, no HTTP concepts
v
src/db/{index,schema}.ts <- Data layer: Drizzle client + table definitions
v
PostgreSQL
Hard rules:
dbmay only be imported fromsrc/services/**andsrc/db/seed.tsRequest,Response, andHeadersbelong only in routes,src/start.ts, andsrc/lib/{auth,gateway,route-handlers}.ts- Multi-step writes (2+ DB operations) must run inside
db.transaction(async tx => ...) - Service entry points should
awaittheir module runtime bootstrap first - Server cookie/header helpers such as
getCookieorgetRequestbelong only in server route handlers,createServerFnbodies, orsrc/start.ts
The platform gateway injects the user identity headers. The app does not manage passwords or a user-account table.
Supported gateway header formats:
# Format 1: JSON
user: {"userID":"u123","userName":"mercy","email":"m@x.com"}
# Format 2: separate headers
X-App-User-ID: u123
X-App-User-Name: mercy
X-App-User-Email: m@x.com
# Format 3: minimum set
X-App-User-ID: u123
Flow:
- Gateway injects headers into the request
src/start.tsmiddleware checks the request- Existing
mes-sessioncookie -> continue - Gateway header without cookie -> redirect to
/login?from=... - User selects role ->
POST /api/auth/select-rolewrites the cookie - Later requests enforce role access via
requireAuth("admin")
Roles are app-owned. Define the role-to-action mapping in
src/lib/permissions.ts as PERMISSION_MATRIX. The platform reads
GET /api/manifest to build role-assignment UI.
See docs/platform-integration.md for the full platform model.
npm run dev # preview-compatible Vite dev server -> http://localhost:5173
npm run dev:local # local Vite dev; port can drift if occupied
npm run dev:force # rebuild the Vite dependency cache before starting
npm run typecheck # TypeScript noEmit
npm run build # Vite 8/Rolldown build -> dist/{client,server}
npm run build:check # build + typecheck + lint
npm run start # node server.mjs
npm run lint # eslint
npm run db:push # push schema
npm run db:seed # seed baseline data
npm run db:studio # Drizzle Studionpm run build writes to dist/:
dist/client/: browser static assetsdist/server/server.js: SSR + server-routes fetch handler
server.mjs wraps that handler with node:http, listens on PORT (default
3000), and serves dist/client/ as static assets. For container deployment,
see artifact.toml.
The full build instructions live in AGENTS.md. For greenfield work, agents follow this order:
- Schema (
src/db/schema.ts) - Auth Config (
permissions.ts) - Services + Runtime Bootstrap (
src/services/**) - Server Routes / API (
src/routes/api/**) - Frontend (choose
_app,station,review, or a custom layout as needed) - Build (
npm run build)
On an existing project, respond directly to the requested change instead of restarting the full build order.