Frontend Setup Guide
Wallow's frontend is two separate TanStack Start (React) applications:
apps/wallow-auth-- Login, register, password reset, email verification, MFA enrollment, consentapps/wallow-web-- Dashboard, settings, public pages
Both are part of the pnpm workspace and talk to Wallow.Api (the headless backend) for all
backend operations. They share branding configuration via packages/styles/branding.json,
consumed through the @bc-solutions-coder/styles package, which also owns the entire
Tailwind v4 build (see Styling and Tailwind Setup).
Each app hosts itself: TanStack Start owns the dev server, SSR, and route codegen, and
vite build emits a Nitro server bundle the app runs with
node .output/server/index.mjs. There is no shared host runtime and no hand-rolled dev
server — an app's backend-facing surface is just server routes (src/app/routes/ in the
two zoned apps, src/routes/ in a flat one), each delegating to a preset from
@bc-solutions-coder/sdk/server.
The two apps use the two different presets:
wallow-authmountscreateApiPassthrough()(from@bc-solutions-coder/sdk/server/passthrough) on/v1/**,/connect/**, and/.well-known/**, so the OIDC endpoints appear on the auth origin without the browser ever crossing origins. The passthrough owns no session: it forwards the request and returns the upstream response,Set-Cookieincluded, verbatim.wallow-webmountscreateWallowBffServer()(from@bc-solutions-coder/sdk/server) on/bff/**and/api/**: its server holds the OIDC token set in a session and proxies/api/**to the API with a bearer token attached.
Architecture
apps/wallow-auth (port 3002) ──► Wallow.Api (port 5001) ◄── apps/wallow-web (port 3000)
│ │ │
├─ Login, Register ├─ OpenIddict OIDC ├─ Dashboard
├─ Password Reset ├─ REST API (/v1) ├─ Settings
├─ Email Verification │ ├─ Organizations
├─ MFA Enroll / Challenge │ (server routes front ├─ Apps
├─ Consent │ /connect, /.well-known) └─ Public pages
└─ Terms / Privacy │
▼
PostgreSQL / Valkey / GarageHQ
Project Structure
Both apps split src/ into three zones — app/ (the host: routes, router, entries and
anything server-only), features/<name>/ (one directory per screen or vertical) and
shared/ (what more than one feature genuinely needs) — with a single vite.config.ts
that serves dev and emits the production bundle:
apps/wallow-auth/
├── src/
│ ├── app/ # The host zone
│ │ ├── routes/ # File-based routes (login, register, mfa, consent, ...)
│ │ │ ├── v1/$.ts # Server route: passthrough to the API
│ │ │ ├── connect/$.ts # Server route: OIDC endpoints on this origin
│ │ │ ├── [.]well-known/$.ts # Server route: discovery + JWKS
│ │ │ └── health.ts # Server route: 200 "ready" (container healthcheck)
│ │ ├── start.ts # createStart(): per-request SDK via request middleware
│ │ ├── router.tsx # createRouter + setupRouterSsrQueryIntegration
│ │ ├── styles.css # The single Tailwind entry, imported by __root.tsx
│ │ └── routeTree.gen.ts # Generated by the Start Vite plugin — never hand-edited
│ ├── features/<name>/ # One per screen; index.ts is its public contract
│ ├── shared/
│ │ ├── components/
│ │ │ └── ready-indicator.tsx # Stamps data-app-ready='true' after hydration
│ │ ├── lib/api-passthrough.server.ts # createApiPassthrough() wrapper the splat routes call
│ │ └── testing/ # Spec harnesses
├── tsconfig.json # The zone alias map (`paths`); vite + vitest read it
├── vite.config.ts # tanstackStart + react + nitro + wallowStyles
├── playwright.config.ts # E2E config (data-testid selectors, port 3002)
├── e2e/ # @playwright/test specs
└── package.json
apps/wallow-web/
├── src/
│ ├── app/
│ │ ├── routes/ # Dashboard, settings, public pages
│ │ │ ├── bff/$.ts # Server route: OIDC tunnel (login/callback/user/logout)
│ │ │ ├── api/$.ts # Server route: /api proxy with a bearer attached
│ │ │ └── health.ts # Server route: liveness JSON
│ │ ├── lib/bff.server.ts # createWallowBffServer() host the server routes call
│ │ └── start.ts, router.tsx, styles.css, routeTree.gen.ts
│ ├── features/<name>/ # organizations, apps, settings, mfa, inquiries
│ └── shared/ # components/, lib/, testing/
├── tsconfig.json
├── vite.config.ts
├── playwright.config.ts # E2E config (data-testid selectors, port 3000)
├── e2e/ # @playwright/test specs
└── package.json
apps/minimal-app (the external relying-party example) deliberately stays flat: zones
only start paying for themselves once an app has features to keep apart, and it has one page.
The zone rules
Cross-zone imports are spelled as aliases, so a boundary crossing is visible in the import block; relative specifiers stay correct — and required — within a zone.
| Zone | May reach |
|---|---|
app/ |
itself (relatively), @features/<name> (barrel only), @shared/* |
features/<x>/ |
its own feature (relatively), @shared/* |
shared/ |
itself (relatively) and nothing else |
Three things follow that are easy to get wrong:
- Server-only modules live in
app/, nevershared/.bff.tspulls innode:cryptoandopenid-client; the DAG is what keeps that out of the client graph, andshared/is reachable from everywhere by definition. - A feature is reachable only through its
index.tsbarrel —@features/organizationsis the contract,@features/organizations/components/OrganizationListreaches around it. shared/is not a junk drawer. Promotion into it is a decision: when two features need the same behaviour, the first answer is that the route composes both features. Promote only genuinely presentational, feature-agnostic pieces, and never at the cost of widening a prop from a feature type tounknown— duplication is cheaper than a bad abstraction.
The alias map is declared once per app, in tsconfig.json paths, and nowhere else. Vite
resolves against it natively (resolve.tsconfigPaths: true), vitest repeats that option
inside each test.projects entry — a root-level resolve is not inherited — and the
wallow/zone-dag lint rule reads the same file to derive the zone list it polices. Adding a
zone is that one edit, and the DAG guard picks it up immediately. The DAG itself is enforced by
a lint rule rather than convention: wallow/zone-dag (from @bc-solutions-coder/lint, switched
on in each app's .oxlintrc.json) resolves every specifier — static, side-effect and dynamic
import("…") alike — against its importer's real directory and judges the resulting edge.
The DAG constrains the product graph, not the test graph: a spec may import
@app/routes/<name> and mount the real route, because the component's contract is the
route's validateSearch schema.
Neither app has a server.ts, a dev-server.ts, or a second SSR Vite config. pnpm dev
is vite dev, pnpm build is vite build (emitting .output/server/index.mjs plus
.output/public), and pnpm start is node .output/server/index.mjs.
New App Bootstrap
A new TanStack Start app in this workspace is almost entirely wiring into five
@bc-solutions-coder packages. The app owns its own routes, router, server routes,
and vite.config.ts; everything cross-cutting (styling, components, the auth
client, and the test harness) comes from the shared packages. Hosting is not
one of them — TanStack Start and Nitro own that, per app.
The steps below build the flat shape — the right starting point for an app
with a handful of routes. Adopt the
zones once the app grows features worth keeping apart: move
routes/, router.tsx, start.ts, styles.css and any server-only module under
src/app/, declare the zone aliases in tsconfig.json paths with
resolve.tsconfigPaths: true in vite.config.ts and each vitest project, and set
srcDirectory: "src/app" alongside
importProtection: { include: ["src/**"] } in vite.config.ts. That second option
is not optional — without it Start scopes its env-boundary check to srcDirectory
and silently stops checking features/ and shared/.
| Package | Published | Entry points | What a new app pulls from it |
|---|---|---|---|
@bc-solutions-coder/styles |
yes | ., ./styles.css, ./vite, ./assets |
Tailwind v4 pipeline plugin (wallowStyles()), theme-token CSS, brand assets, the branding schema |
@bc-solutions-coder/ui |
no (private) |
., ./*, ./source.css |
The 60-component Base UI + CVA catalog — forms, overlays, navigation, feedback — via the root barrel (.) or a per-component subpath (./button), plus the app-wiring components (ReadyIndicator, FocusOnNavigate, DocumentStyles, ForkAttribution) and the Tailwind @source scan of its component tree. See Component Library |
@bc-solutions-coder/sdk |
yes | ., ./server, ./server/passthrough, ./server/forwarded, ./query |
Browser BFF client + typed API operations (.); the BFF server preset, handlers, API proxy, and session stores (./server); the pure reverse-proxy preset createApiPassthrough (./server/passthrough, a separate subpath so a passthrough-only app never pulls openid-client into its server bundle); the dependency-free trusted-proxy seam — createRequestOriginResolver, createClientAddressResolver, PeerRequest — safe in isomorphic modules (./server/forwarded); the TanStack Query layer — a generated {op}Options() / {op}QueryKey() / {op}Mutation() trio per operation plus the curated invalidation predicates (./query) |
@bc-solutions-coder/testing |
no (private) |
., ./render |
The createVitestProjects node + browser preset (.); the browser-mode render helper (./render) |
@bc-solutions-coder/query |
no (private) |
. |
The TanStack Query facade — every react-query symbol an app uses (useQuery, useMutation, QueryClientProvider, …) re-exported by reference, plus createQueryClient, the shared client factory. Only this package depends on @tanstack/react-query; apps never import it directly. See Frontend State |
@bc-solutions-coder/auth |
no (private) |
. |
The shared authn/authz layer — the canonical current-user query, useCurrentUser, the ensureCurrentUser beforeLoad primer, hasRole/hasPermission/isAdmin, and the SDK's route guards re-exported so an app's auth imports come from one package |
@bc-solutions-coder/navigation |
no (private) |
. |
The application shell — AppShell (collapsible desktop rail, mobile drawer, and the controls that drive them) plus the useNavStore singleton. An app supplies only its destinations manifest, a can visibility predicate, and the header/footer slots. One entry, never a per-component catalog: a second export path for the store would be a second store |
@bc-solutions-coder/logger |
no (private) |
., ./server |
Structured logging at both ends — the browser core that buffers and posts batches (.) and the app-server ingest handler that stamps and forwards them (./server). Apps never call console. See Logging |
@bc-solutions-coder/env |
no (private) |
./base-path, ./internal-origin |
Deployment-derived addressing — base path, internal origin. Subpath-only, zero dependencies |
@bc-solutions-coder/utils |
no (private) |
./format, ./guards, ./string |
The bottom of the graph: pure functions, zero dependencies, subpath-only |
Which app takes which. Everything an app takes is a workspace:* runtime
dependency — no app carries its own @tailwindcss/vite, tailwindcss, or vitest preset:
| App | Workspace dependencies |
|---|---|
wallow-web |
all ten above, plus forms |
wallow-auth |
the same minus navigation — its screens sit in its own auth-layout.tsx, so wallow-web is navigation's only consumer today |
minimal-app |
the SDK and api-errors only — it is the external relying-party example, built the way a consumer outside this repository would build it, so it deliberately takes none of the private packages |
The floor for a new in-repo app is the six core packages the steps below wire in
(env, query, sdk, styles, testing, ui); do not copy minimal-app as a
bootstrap skeleton — its constraint (published packages only) is not yours.
Bootstrapping a new app is these steps.
1. Depend on the core packages
In the app's package.json dependencies (not devDependencies — the SDK and
styles packages are imported by the app's server routes and vite.config.ts at
build time):
{
"dependencies": {
"@bc-solutions-coder/env": "workspace:*",
"@bc-solutions-coder/query": "workspace:*",
"@bc-solutions-coder/sdk": "workspace:*",
"@bc-solutions-coder/styles": "workspace:*",
"@bc-solutions-coder/testing": "workspace:*",
"@bc-solutions-coder/ui": "workspace:*"
}
}
Add logger and utils as soon as the app records an event or reaches for a shared
helper, forms when it renders its first form, auth when it has a signed-in user, and
navigation when it grows a shell.
The Start runtime itself (@tanstack/react-start, @tanstack/react-router,
@tanstack/react-router-ssr-query) is pinned exactly, with no ^, in every
app — these packages move together and a floating range silently mixes
incompatible plugin/runtime versions. nitro and @vitejs/plugin-react are
devDependencies; copy the versions from apps/wallow-auth/package.json.
2. CSS entry (src/styles.css)
Three lines:
@import "@bc-solutions-coder/styles/styles.css";
@import "@bc-solutions-coder/ui/source.css";
@source "./";
The first line pulls in the Tailwind base layer and branding-driven theme
tokens. The second makes Tailwind scan @bc-solutions-coder/ui's component tree
(Tailwind v4 skips node_modules, so the package ships its own @source
declaration for the app to import); omit it if the app does not use
@bc-solutions-coder/ui. The @source "./" is the one line every app must own —
Tailwind resolves @source relative to the declaring stylesheet, so the shared
packages can never scan the app's own component tree on its behalf.
Import this file for side effects once, from src/routes/__root.tsx:
import "../styles.css";
Do not also emit a stylesheet <link> from the root route's head(). Start's
client and SSR builds hash the emitted CSS independently, so a hand-written link
points at a hash only one of them produced — dev looks fine and the production
build serves an unstyled page.
The rest of the pipeline — the workspace dependency and the wallowStyles()
plugin this entry is compiled by — is in
Styling and Tailwind Setup.
3. Vite config (vite.config.ts)
One config serves dev and builds production. It spreads wallowAppConfig({ defaultPort })
from @bc-solutions-coder/config/vite/app — the preset every app in the workspace builds
with, which owns server.port (reading process.env.PORT), the SSR graph, the
use-sync-external-store aliases, and the copyPublicDir restore — and then composes four
plugins in this order:
import { wallowStyles } from "@bc-solutions-coder/styles/vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import react from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";
import { wallowAppConfig } from "@bc-solutions-coder/config/vite/app";
/** The port a bare `pnpm dev` lands on. */
const DEFAULT_PORT = 3010;
export default defineConfig({
...wallowAppConfig({ defaultPort: DEFAULT_PORT }),
plugins: [
tanstackStart({
// Specs are co-located, so a *.test.tsx under src/routes/ would otherwise
// be codegen'd in as a route.
router: { routeFileIgnorePattern: String.raw`\.(test|spec)\.(ts|tsx)$` },
}),
react(),
nitro(),
...wallowStyles(),
],
});
defaultPort matters because vite dev binds 3000 when PORT is unset and every fixture
(Playwright, compose, Aspire) expects the app's own port.
Two things this config must not do:
- Never set
vite: { installDevServerMiddleware: true }. The Start plugin auto-detects thatnitro()installs a non-runnable SSR environment and skips its own dev middleware; forcing the option on makesvite devfail to boot with "cannot install vite dev server middleware for TanStack Start". - Never add a
routes:generatescript or atsr.config.json. The Start plugin regeneratessrc/routeTree.gen.tsas a side effect ofvite devandvite build.
The use-sync-external-store aliases the preset supplies are what keep an app
that renders Base UI components on a single React: without them the production
build loads a second React at runtime and every store-backed Base UI part throws
"Invalid hook call" during SSR. That is one of the reasons the preset exists —
do not hand-roll the alias (or the port read) per app.
4. Vitest config via the testing preset (vitest.config.ts)
createVitestProjects returns the node + headless-Chromium two-project split. A
simple app supplies nothing at all — a *.test.tsx spec that renders via
react-dom/server and never mounts a live DOM is named *.ssr.test.tsx and the
preset routes it onto the node project by convention:
import { createVitestProjects } from "@bc-solutions-coder/testing";
import { defineConfig } from "vitest/config";
const { node, browser } = createVitestProjects();
export default defineConfig({ test: { projects: [node, browser] } });
createVitestProjects also accepts extraBrowserOptimizeDeps (packages a linked
workspace dependency drags in, which Vite does not pre-bundle by default), the
browserPlugins / browserSetupFiles pass-throughs an app uses to give the
browser project real CSS, and nodeProjectOverrides for anything else the node
project needs. apps/wallow-web/vitest.config.ts is the fullest example: it
states resolve and ssr.noExternal once at the config root, pulls them into
both projects with extends: true, and adds a browser-only alias on top.
5. Server routes (src/routes/**), not host files
There are no host files. A route module can export server.handlers alongside
(or instead of) a UI component, and that is the entire backend-facing surface —
the same files in dev (vite dev) and in the built Nitro bundle. A splat route
handles a whole prefix:
// src/routes/v1/$.ts — everything under /v1 goes to the API
import { createFileRoute } from "@tanstack/react-router";
import { handleApiPassthrough } from "@shared/lib/api-passthrough.server";
export const Route = createFileRoute("/v1/$")({
server: { handlers: { ANY: ({ request }) => handleApiPassthrough(request) } },
});
Use a single ANY handler rather than a method map: method policy belongs to the
SDK preset (a bare GET /bff/logout answers 405 + Allow: POST), and a
method-filtered route would swallow that as a local 404.
The preset behind the handler is built lazily and memoised at module scope, so importing the route module does not construct it:
// src/shared/lib/api-passthrough.server.ts
import {
createApiPassthrough,
type ApiPassthrough,
type PeerRequest,
} from "@bc-solutions-coder/sdk/server/passthrough";
let passthrough: ApiPassthrough | undefined;
export function handleApiPassthrough(request: PeerRequest): Promise<Response> {
passthrough ??= createApiPassthrough();
return passthrough.handle(request);
}
Two rules the reference apps encode:
- Pass the runtime's request through, never a copy. The preset reads the peer
address from
request.ip(srvx supplies it; a WHATWGRequesthas no socket) and stamps the resolved caller onto the upstreamX-Forwarded-For— believing a fronting proxy's ownX-Forwarded-*only when the peer is insideWALLOW_TRUSTED_PROXIES. Do not build anew Request(request, { headers }): it throws at runtime because the copy constructor reads a private field srvx's request class does not have, and a copy that did work would loseip. Need to change the URL (a base-path rebase)? Mutate the inbound request in place. - Reach a Node-only module through a dynamic
import()when the preset pulls innode:crypto/openid-client(ascreateWallowBffServerdoes). Every route module is a member of the tree the client graph also imports, so a top-level import breaks browser-mode specs on externalised Node builtins.apps/wallow-web/src/app/lib/bff.server.tsis the reference. It lives in theappzone, notshared/, precisely so nothing else can import it — and the.server.suffix is what makes that a build error rather than a convention. Start's import protection denies an imported file matching**/*.server.*from the client graph; it does not know thatredisornode:cryptoare server-only, so a plainly-named wrapper around them builds clean and ships to the browser. Name every server-only module*.server.*. Start's own import protection is what enforces it at build time — a client module reaching a*.server.*file fails the build, not a spec.
src/start.ts completes the picture: createStart() registers a global request
middleware that mints one SDK per request (never per module — a module-global
client shared across concurrent renders leaks one user's forwarded cookie into
another user's render) and hands it down the Start context, which getRouter()
lifts into the router context.
6. What the app still owns
The shared packages leave exactly the app-specific surface to the app:
- Router, routes, and route components — file-based routing under
src/routes/(src/app/routes/once an app is zoned), composing@bc-solutions-coder/uicomponents (import { Button, Card, Dialog } from "@bc-solutions-coder/ui";). The app writes screens, not primitives: dialogs, menus, selects, and form controls all come from the shared catalog rather than hand-rolled markup. See Component Library. - Server routes and hosting — which prefixes it serves and which SDK preset sits
behind them (
src/routes/**and the module behind them;src/app/routes/**+src/app/lib/once an app is zoned), plus its ownvite.config.ts,Dockerfile, andPORTdefault. A same-origin reverse proxy like wallow-auth'screateApiPassthrough, or a BFF token tunnel like wallow-web'screateWallowBffServer— the presets are shared, the mounting is per-app. - Backend-facing slices — its own
src/features/**, each with anapi.tsthat re-exports the SDK's./querylayer (the generated{op}Options()/{op}Mutation()factories) so routes and components import data access from./apiand never reach into the SDK directly — and the request middleware insrc/start.tsthat mints onecreateWallowSdk()instance per request for the router to lift into route context. See Frontend State: TanStack Query vs. Zustand for the query/Zustand boundary and the three-step pattern for adding a new query.
Branding, theme tokens, the component library, the auth client, and the test harness all stay in the shared packages — no source changes needed to rebrand, and nothing cross-cutting is duplicated into the app. Hosting is the deliberate exception: it belongs to TanStack Start and the app's own config.
Component Library
Both apps build their screens from @bc-solutions-coder/ui, the shared Base UI + CVA catalog:
import { Button, Card, Dialog } from "@bc-solutions-coder/ui";
import { buttonRecipe } from "@bc-solutions-coder/ui/button";
Component Library is the reference — the catalog itself, both import
styles, multi-part namespaces, the surface axis, theming, and the steps for adding a component.
It is the only page that states the catalog's size; this one deliberately does not repeat it.
One app-level rule belongs here rather than there: copy — headings and body text alike — goes
through the catalog's Text primitive (or PageHeader for a page title, MutedText for secondary
copy) rather than a raw p/span/h1–h6, so the type scale is decided in one place. In
apps/wallow-web an app-local oxlint rule enforces it; the other apps follow it by convention.
Forms
Screens do not wire form controls to state by hand. @bc-solutions-coder/forms layers TanStack Form
state onto the ui catalog. An app adds it once it renders a form — wallow-auth and wallow-web
both do, minimal-app does not:
<AppForm form={form} testIdPrefix="organization-create">
<form.AppField name="name">
{(field) => <field.TextField label="Name" testId="organization-name" />}
</form.AppField>
<FormError />
<SubmitButton>Create organization</SubmitButton>
</AppForm>
One useAppForm call holds the zod schema, the generated {operation}Mutation({ client }) and the
success work; the shell owns the <form> element, the pending state and the RFC 7807 error split,
and every data-testid is derived from testIdPrefix. See Forms.
Testing
Frontend specs run under Vitest 4 browser mode: any component/DOM test executes in real
headless Chromium via the Vitest playwright provider (@vitest/browser-playwright +
vitest-browser-react) — jsdom, happy-dom, and jest are not used. pnpm test is the same
command as before but now drives a real browser for component specs; each app's vitest.config.ts
splits a node project (pure-logic *.test.ts) from a browser project (*.test.tsx).
packages/ui adds a third storybook project that runs every component story as a test case, and
neither app may mock @bc-solutions-coder/ui — see Component Library.
End-to-end tests are per-app @playwright/test suites (apps/wallow-auth/e2e/,
apps/wallow-web/e2e/), run with pnpm --filter ./apps/<app> test:e2e or the one-command
./scripts/e2e.sh runner. See .claude/rules/TESTING.md and .claude/rules/E2E.md for the full
rules.
Running Locally
# Start infrastructure
pnpm backend:infra
# Start the API (required by both frontends) plus the rest of the stack via Aspire
pnpm backend
# No package build is needed first — in-repo every @bc-solutions-coder/* exports map
# resolves to that package's src/, so the apps run and typecheck straight from source.
# Start both frontends together (wallow-web on 3000, wallow-auth on 3002)
pnpm dev
# Or start one app at a time (separate terminals):
pnpm --filter @bc-solutions-coder/wallow-auth dev
pnpm --filter @bc-solutions-coder/wallow-web dev
pnpm dev (root package.json) runs both apps' own dev scripts through
turbo run dev --filter @bc-solutions-coder/wallow-web --filter @bc-solutions-coder/wallow-auth,
interleaving their output; Ctrl-C stops both. Turbo's dev task is cache: false and
persistent: true, and unlike build/typecheck/test it declares no ^build dependency —
it reads package source directly. It does not start
apps/minimal-app and does not start the .NET backend — pair it with pnpm backend (or
pnpm backend:infra + a manually run API) for a working stack.
To run an app the way its container does, build it and run the Nitro bundle:
pnpm --filter @bc-solutions-coder/wallow-auth build # vite build -> .output/
pnpm --filter @bc-solutions-coder/wallow-auth start # node .output/server/index.mjs
vite build emits .output/server/index.mjs (the server entry, what each app's
Dockerfile runs) and .output/public (the content-hashed client bundle plus the
brand assets). Both are gitignored; there is no checked-in dist/ or public/ to
serve. Point either mode at the API with WALLOW_API_INTERNAL_URL — it defaults to
http://localhost:5001, which is right for a bare pnpm dev and overridden
explicitly by Aspire, both compose stacks, and Playwright.
Default Dev Credentials
| Field | Value |
|---|---|
admin@wallow.dev |
|
| Password | Admin123! |
Local URLs
| App | URL |
|---|---|
| API | http://localhost:5001 |
| Web (TanStack) | http://localhost:3000 |
| Auth (TanStack) | http://localhost:3002 |
| Docs (DocFX) | http://localhost:5004 |
Both modes honour PORT and fall back to the defaults above: in dev through
server.port in the app's vite.config.ts, and in the built bundle through Nitro's
own listener. The dev default has to be spelled out per app — vite dev binds 3000
when PORT is unset, so without it wallow-auth would claim wallow-web's port and
Playwright would wait on 3002 forever. Keep any new local port clear of those and of
Grafana on 3001.
Do not hand-roll the PORT read. wallowAppConfig({ defaultPort }) from
@bc-solutions-coder/config/vite/app — the preset every app in the workspace builds with —
already resolves server.port as Number(process.env.PORT ?? defaultPort). A new app passes
its default and inherits the behaviour.
Styling and Tailwind Setup
@bc-solutions-coder/styles owns the entire Tailwind v4 pipeline: the Tailwind compiler plugin,
the brand-assets (icon/logo) static-file wiring, and the theme token CSS emitted from
packages/styles/branding.json. Bootstrapping a new TanStack Start app in this workspace needs only three
steps:
Add the workspace dependency to the app's
package.json:{ "dependencies": { "@bc-solutions-coder/styles": "workspace:*" } }No
@tailwindcss/viteortailwindcssdevDependency is needed —@bc-solutions-coder/stylesdepends on both directly and re-exports the Vite plugin.Register
wallowStyles()(from@bc-solutions-coder/styles/vite) in the app's Vite plugin list. There is exactly one place to do this — the app's singlevite.config.ts, which serves dev and builds production alike:import { wallowStyles } from "@bc-solutions-coder/styles/vite"; export default defineConfig({ plugins: [react(), ...wallowStyles()], // ... });wallowStyles()returns aPluginOption[]containing the Tailwind compiler plugin and a brand-assets plugin that pointspublicDirat the shared package'sassets/directory (brand icon, etc.) through its ownconfig()hook — the app never setspublicDiritself. Becausenitro/viteturns the client environment'scopyPublicDiroff, an app must setenvironments.client.build.copyPublicDir: trueback on or those brand assets 404 in the built output while dev still serves them (see New App Bootstrap).Create the CSS entry at
src/styles.css— the three lines, what each one does, and how the app imports it are in CSS entry (src/styles.css).
That's the entire setup — nothing else is required. No per-app @tailwindcss/vite
devDependency, no manual publicDir wiring, no explanatory boilerplate duplicated into the
app's own CSS file (the shared package's styles.css already documents the @source
constraint).
Docker builds
Because the app's Tailwind build depends on @bc-solutions-coder/styles and, transitively, on
packages/styles/branding.json, an app's Dockerfile must, before building the app image:
COPY packages/styles/package.json packages/styles/alongside the other workspace manifests (beforepnpm install --frozen-lockfile)COPY packages/styles packages/styles(before the build step) — this also brings inbranding.json, which lives at the package root, so it needs no COPY line of its own
No package build step is needed before the app build. In-repo, every
@bc-solutions-coder/* exports map resolves to the package's src/, so the app's single
vite build compiles workspace package source — styles and branding.json included —
directly; nothing resolves through dist/. (Verified by building and running both app
images with no package build step: both serve fully branded pages.)
apps/wallow-auth/Dockerfile and apps/wallow-web/Dockerfile are the reference examples.
Branding Customization
Edit packages/styles/branding.json to customize identity across both apps:
{
"appName": "YourProduct",
"appIcon": "your-icon.svg",
"tagline": "Your product tagline",
"theme": {
"defaultMode": "dark",
"light": { "primary": "oklch(0.55 0.15 250)" },
"dark": { "primary": "oklch(0.65 0.15 250)" }
}
}
Branding Ownership
The canonical branding schema lives in packages/styles (@bc-solutions-coder/styles,
src/branding.ts), the TypeScript source of truth that parses packages/styles/branding.json and
emits the theme CSS every frontend consumes.
The Configuration Guide documents every key, the runtime-overridable pair, and what each theme token is for. Deliberately not repeated here — a second key list is a second thing to keep in step.
CSS Variable Customization
Theme colors from branding.json are emitted as CSS custom properties by
@bc-solutions-coder/styles. The tokens use OKLCH color format and map to standard shadcn/ui
variable names:
--background, --foreground, --card, --card-foreground,
--popover, --popover-foreground, --primary, --primary-foreground,
--secondary, --secondary-foreground, --muted, --muted-foreground,
--accent, --accent-foreground, --destructive, --destructive-foreground,
--border, --input, --ring, --radius,
--sidebar, --sidebar-foreground, --sidebar-accent,
--success, --success-foreground,
--warning, --warning-foreground
The sidebar-*, success-* and warning-* families — seven mappings in styles.css — carry a
two-level fallback the older tokens do not need, for example
--color-sidebar: var(--sidebar, var(--foreground)) and
--color-warning: var(--warning, var(--primary)). packages/styles/branding.json is merge=ours in
.gitattributes, so a fork whose copy predates these keys never receives them from an upstream
merge and the theme emits no custom property for them; the fallback lands such a fork on a colour
its palette has always carried rather than on nothing at all.
The sidebar-* family is the theme's general inverted-surface family, named after its first
consumer rather than after what it now means (only one of its current consumers is an actual
sidebar). The name is deliberately not being changed: branding.ts merges a per-client theme
override over the fork's palette by key name, so a stored override still spelled sidebar
would silently stop applying the moment the fork renamed the key.
Adding a New Design Token
Adding a new semantic design token — warning and success already ship, so pick a genuinely new
name — touches exactly two files; nothing per-app needs to change:
packages/styles/branding.json— add the new key under boththeme.lightandtheme.darkwith an OKLCH value.packages/styles/styles.css— add the matching@thememapping (e.g.--color-info: var(--info, var(--primary));) so Tailwind exposes it as a utility class. Give any token added from now on the two-level form: a fork onmerge=ourswill not receive your newbranding.jsonkey, and the fallback is what keeps its palette rendering.
packages/styles/src/branding.ts parses packages/styles/branding.json and emits every theme.light/
theme.dark key as a CSS custom property at render time, so no app-level code references the
token directly — apps just use the Tailwind utility (bg-info, text-info, etc.) once
it exists in styles.css. packages/styles/src/theme-css.test.ts guards this rule: it asserts
every CSS variable emitted from forkBranding.theme has a corresponding @theme mapping in
styles.css, so a forgotten step 2 fails the build instead of silently rendering an unstyled
token.
Dark Mode
@bc-solutions-coder/styles emits three custom-property blocks from packages/styles/branding.json — a
:root block carrying the fork's theme.defaultMode palette, plus a .dark and a .light block.
Nothing in that package puts either class on the document; activation is the app's job, and it takes
three lines in src/app/routes/__root.tsx. Both apps/wallow-web and apps/wallow-auth wire it
identically:
import { DocumentStyles, ReadyIndicator, ThemeProvider, ThemeScript } from "@bc-solutions-coder/ui";
<html lang="en" className={branding.defaultMode}>
<head>
<HeadContent />
<DocumentStyles themeCss={renderThemeStyle(branding)} stylesheetHref={null} />
<ThemeScript defaultMode={branding.defaultMode} />
</head>
<body>
<ThemeProvider defaultMode={branding.defaultMode}>{children}</ThemeProvider>
<ReadyIndicator />
<Scripts />
</body>
</html>;
Each piece answers a different problem:
className={branding.defaultMode}on<html>is the server's best guess. It makes the fork's default scheme resolve with no client JS at all.<ThemeScript/>in<head>corrects that guess. It is a blocking inline script — nodefer, noasync— that readslocalStorageandmatchMediasynchronously and stamps the resolved class ondocument.documentElementbefore first paint. Without it a visitor who chose dark sees the fork default flash past on every entry into the app.<ThemeProvider/>publishes what the script already decided, throughuseSyncExternalStore, and owns the setterThemeTogglecalls. It deliberately does not compute the class in an effect: the script has decided already, and recomputing on mount is the hydration flash.
The resolution order, lowest priority first, is the fork's theme.defaultMode, then the OS
(prefers-color-scheme), then the visitor's persisted choice. The persisted value is a
preference ("light", "dark" or "system"), not a mode, and lives under the wallow-theme
key in localStorage — "system" is the default and has to remain reachable, which is why
ThemeToggle cycles three states instead of toggling two. Anything else in that key (junk left by
another app on the same origin, or a storage that throws under a blocked-cookies policy) resolves as
"no preference" and hands the decision one level down.
Scoping Dark Mode
The mode class only works on document.documentElement. Wrapping a subtree in
<div className="dark"> is vacuous — it compiles, it renders, and it paints the light palette.
The reason is CSS custom-property substitution timing. The emitted .dark block rebinds the raw
variables (--sidebar, --background, …), while Tailwind's @theme declares the token
(--color-sidebar: var(--sidebar, …)) on :root alone. A var() inside a custom property is
substituted at computed-value time on the declaring element, so a descendant .dark rebinds the
raw variable long after the token above it has already computed its light value — and that computed
value is what inherits down to your utilities. Measured against the real fork theme, bg-sidebar
renders rgb(40, 21, 12) under a .dark wrapper and under a .light wrapper alike, and only
becomes rgb(35, 17, 8) with .dark on the document element.
So a spec or a story that needs to render or assert a scheme must stamp the class on
document.documentElement and clean up after itself — a shared document means leakage between cases
is the thing to design around. There is no wrapper-scoped shortcut.
The worked example is the component catalog's packages/ui/.storybook/scheme-decorators.tsx: a
lightScheme/darkScheme Storybook decorator pair that adds the class in a layout effect and
removes it on unmount, with each scheme-scoped story asserting the palette it actually paints. That
second half is the part worth copying — a scheme is invisible in a class string, so only a measured
colour can tell a working scope from a vacuous one.
Authentication
Wallow uses OpenIddict as its OIDC provider, hosted in Wallow.Api (wired up in
Wallow.Identity.Infrastructure). apps/wallow-auth provides the authentication UI (login,
register, password reset, consent) and serves the OIDC endpoints on its own origin by
same-origin proxying them to the API. apps/wallow-web authenticates users via OpenID Connect
through its BFF server.
OIDC Endpoints (Wallow.Api)
| Endpoint | Method | Purpose |
|---|---|---|
/connect/authorize |
GET | Start authorization |
/connect/token |
POST | Exchange code for tokens |
/connect/logout |
GET/POST | End session |
/connect/userinfo |
GET/POST | Get user profile claims |
Pre-Registered Dev Clients
Every client api/seed.json registers is confidential — it holds a secret. A client seeded
without one is registered as an OpenIddict public client, which authenticates on client id
alone and is therefore spoofable by anything that learns the id, so do not add one.
Whether a seeded client is first-party is the seed's "firstParty": true flag, never its id:
a first-party client skips the consent screen and is bound to no organization, while every
other client is bound to exactly one organization (tenantName) and always sees consent.
seedMembers/seedMemberRoles are allowed only on those third-party clients. The seeder
refuses to start on a client that violates either rule.
wallow-web-client (confidential, first-party, for apps/wallow-web):
- Redirect URI:
http://localhost:3000/bff/callback - Secret:
wallow-web-secret - Scopes:
openid,email,profile,roles,offline_access - Bound to no organization; the dev admin's membership in the seeded Wallow organization
comes from the seed's
adminblock, not from the client.
bff-example-client (confidential, third-party, for apps/bff-example):
- Redirect URI:
http://localhost:3003/bff/callback - Secret:
bff-example-secret - Bound to the seeded Wallow organization; every login goes through the consent screen.
React Readiness
Both apps stamp [data-app-ready='true'] on the document once React hydration completes (emitted
by src/shared/components/ready-indicator.tsx). E2E tests wait for this marker before interacting with
the page.
Fork Adaptation
Forks customize identity through configuration, not code changes:
- Edit
packages/styles/branding.jsonfor name, icon, tagline, and theme colors - Update
appsettings.jsonfor backend configuration .gitattributesmarksbranding.jsonandappsettings*.jsonasmerge=ours, so upstream merges preserve fork config
API Documentation
The Wallow API serves its OpenAPI spec via Scalar at http://localhost:5001/openapi/v1.json. The
Scalar UI is available at http://localhost:5001/scalar/v1.