Frontend Architecture
TanStack Start SSR setup, file-based routing, state management, component patterns, and shared UI package.
Overview
The web application (apps/web) is built with TanStack Start, a full-stack React framework powered by Vite and Nitro. It provides server-side rendering, file-based routing, and seamless integration with the TanStack ecosystem (Router, Query, Form, Store).
Key architectural decisions:
- SSR by default -- TanStack Start renders pages on the server via Nitro, then hydrates on the client. This gives fast initial loads and SEO support without a separate SSR framework.
- Proxy to API -- In both development and production, API requests (
/api/**) are proxied through Nitro to the NestJS backend, avoiding CORS complexity. - Shared UI package -- Presentational components live in
@repo/ui, while app-specific components live inapps/web/src/components/.
SSR and Rendering Pipeline
TanStack Start uses Nitro as its server runtime. The pipeline:
The server entry point (src/server.ts) wraps the default TanStack Start handler with Paraglide middleware for locale-aware URL handling:
// src/server.ts
import handler from '@tanstack/react-start/server-entry'
import { paraglideMiddleware } from './paraglide/server'
export default {
fetch(req: Request): Promise<Response> {
return paraglideMiddleware(req, () => handler.fetch(req))
},
}Vite Plugin Stack
The Vite configuration (vite.config.ts) chains several plugins in order:
| Plugin | Purpose |
|---|---|
@tanstack/devtools-vite | Unified TanStack devtools panel (dev only) |
@inlang/paraglide-js | Compile-time i18n with URL-based locale strategy |
nitro/vite | SSR runtime, API proxy, and production build |
vite-tsconfig-paths | Path alias resolution (@/ maps to src/) |
@tailwindcss/vite | Tailwind CSS v4 JIT compilation |
@tanstack/react-start/plugin/vite | TanStack Start framework integration |
@vitejs/plugin-react | React JSX transform and fast refresh |
API Proxy
Nitro proxies /api/** requests to the NestJS backend via a runtime server route (server/routes/api/[...path].ts). The target is read from the API_URL environment variable (defaulting to http://localhost:4000):
// apps/web/server/routes/api/[...path].ts
export default defineEventHandler((event) => {
const apiTarget = process.env.API_URL ?? `http://localhost:${process.env.API_PORT ?? 4000}`
const path = event.context.params?.path ?? ''
return proxyRequest(event, `${apiTarget}/api/${path}`)
})This means the frontend never calls the API directly across origins -- all requests go through the same domain. In preview environments, the server route also injects the x-vercel-protection-bypass header to reach SSO-protected API previews.
File-Based Routing
TanStack Router generates a typed route tree from the file system. Routes live in apps/web/src/routes/ and follow these conventions:
Route File Patterns
| Pattern | URL | Example File |
|---|---|---|
index.tsx | / | routes/index.tsx |
login.tsx | /login | routes/login.tsx |
demo.tsx | /demo (layout) | routes/demo.tsx |
demo/index.tsx | /demo (page) | routes/demo/index.tsx |
demo/store.tsx | /demo/store | routes/demo/store.tsx |
demo/start.ssr.full-ssr.tsx | /demo/start/ssr/full-ssr | Dot notation for nested paths |
demo/form/steps.tsx | /demo/form/steps | Directory for deeper nesting |
$.tsx | /* (root splat) | 404 fallback |
demo/api.tq-todos.ts | /demo/api/tq-todos | Server-only API route |
-components/ | (not routed) | Private directory prefix |
Configuration
Route generation is configured in tsr.config.json:
{
"routeFileIgnorePattern": "\\.test\\.tsx?$"
}Test files (*.test.tsx) colocated with routes are excluded from route generation.
Route Anatomy
Every route uses createFileRoute with a route path that matches its filesystem position:
// routes/login.tsx
export const Route = createFileRoute('/login')({
beforeLoad: async () => {
// Auth guard -- redirect if already logged in
const { data } = await authClient.getSession()
if (data) throw redirect({ to: '/' })
},
loader: fetchEnabledProviders,
component: LoginPage,
head: () => ({
meta: [{ title: 'Sign In | Roxabi' }],
}),
})Key hooks in the route lifecycle:
| Hook | Runs on | Purpose |
|---|---|---|
beforeLoad | Server + client navigation | Auth guards, feature flags, redirects |
loader | Server (SSR) + client (navigation) | Data fetching, server function calls |
head | Server | Sets <title>, <meta> tags |
component | Client (after hydration) | React component to render |
Layout Routes
A route file without child routes in a matching directory acts as a layout when it renders <Outlet />.
Root Route
The root route (__root.tsx) defines the HTML shell, global providers, and chromeless mode:
export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async () => {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('lang', getLocale())
}
},
head: () => ({ /* charset, viewport, title, stylesheet */ }),
notFoundComponent: NotFound,
shellComponent: RootDocument,
})The AppShell component renders the Header on all routes. The /docs and /talks routes have been removed from apps/web — docs are served by the standalone apps/docs site, and talks were migrated to external repos. CHROMELESS_PREFIXES is now an empty array.
Server API Routes
TanStack Start supports server-only API handlers using the server.handlers property:
// routes/demo/api.tq-todos.ts
export const Route = createFileRoute('/demo/api/tq-todos')({
server: {
handlers: {
GET: () => Response.json(todos),
POST: async ({ request }) => {
const name = await request.json()
todos.push({ id: todos.length + 1, name })
return Response.json({ id: todos.length, name })
},
},
},
})These files use the .ts extension (not .tsx) and run exclusively on the server.
State Management
The frontend uses a layered approach to state, matching each concern to the right tool:
| Layer | Tool |
|---|---|
| Server/async state | TanStack Query |
| Form state | TanStack Form |
| Route-level data | Route loaders (SSR) |
| Server mutations | Server functions (createServerFn) |
| Local component state | React useState |
TanStack Query
TanStack Query handles all asynchronous server state. It is initialized in src/integrations/tanstack-query/root-provider.tsx and integrated with the router for SSR data dehydration:
// src/router.tsx
const rqContext = TanstackQuery.getContext()
const router = createRouter({
routeTree,
context: { ...rqContext },
defaultPreload: 'intent',
})
setupRouterSsrQueryIntegration({ router, queryClient: rqContext.queryClient })The setupRouterSsrQueryIntegration call ensures query data fetched during SSR is serialized into the HTML and rehydrated on the client without duplicate requests.
Route loaders prefetch queries, and components consume them with useQuery:
// In a route component
const { data, refetch } = useQuery<Todo[]>({
queryKey: ['todos'],
queryFn: () => fetch('/demo/api/tq-todos').then((res) => res.json()),
initialData: [],
})TanStack Form
Form state uses @tanstack/react-form with a form hook factory pattern using createFormHook. This creates a useAppForm hook that bundles form field components with validation, enabling a composable API:
const form = useAppForm({
defaultValues: { title: '', description: '' },
validators: { onBlur: schema },
onSubmit: () => {},
})
// In JSX:
<form.AppField name="title">
{(field) => <field.TextField label="Title" />}
</form.AppField>Validation integrates with Zod schemas via the validators option.
Server Functions
TanStack Start's createServerFn provides type-safe server-only functions callable from loaders or components. These run on the server during SSR and via RPC on client navigation:
const getPunkSongs = createServerFn({ method: 'GET' })
.handler(async () => [
{ id: 1, name: 'Teenage Dirtbag', artist: 'Wheatus' },
// ...
])
// Used in a route loader
export const Route = createFileRoute('/demo/start/ssr/full-ssr')({
loader: async () => await getPunkSongs(),
})Server functions can also accept validated input:
const addTodo = createServerFn({ method: 'POST' })
.inputValidator((d: string) => d)
.handler(async ({ data }) => { /* server-side logic */ })Documentation Site (apps/docs)
Documentation is served by a standalone Next.js application at apps/docs/, powered by Fumadocs v16. It is deployed separately to docs.app.roxabi.com.
The MDX source files live at docs/ (repo root). apps/docs reads them via source.config.ts and serves them through a Next.js catch-all route.
Run the docs site locally:
bun run docs # starts apps/docs on port 3002apps/docs is a separate TurboRepo package (@repo/docs) with its own Next.js build pipeline; it is not part of the apps/web Vite/Nitro build.
Component Architecture
Components are organized in two layers: shared (@repo/ui) and app-specific (apps/web/src/components/).
Directory Structure
apps/web/src/
components/
Header.tsx # App header with nav, auth, locale
Footer.tsx # App footer
AuthLayout.tsx # Shared layout for auth pages
UserMenu.tsx # Authenticated user dropdown
OrgSwitcher.tsx # Organization switching dropdown
ThemeToggle.tsx # Dark/light mode toggle
LocaleSwitcher.tsx # Language selector
GithubIcon.tsx # GitHub link icon
FeatureCard.tsx # Landing page feature card
landing/ # Landing page sections
HeroSection.tsx
FeaturesSection.tsx
AiTeamSection.tsx
DxSection.tsx
TechStackSection.tsx
CtaSection.tsx
SectionHeading.tsxComponent Patterns
Route-specific components live in route directories using the - prefix to exclude them from routing:
routes/design-system/-components/ # Not routed
ColorPicker.tsx
ComponentShowcase.tsx
ThemeEditor.tsxPage components are defined inline in route files. Each route file exports a Route object and defines its component as a local function -- not as a separate file:
export const Route = createFileRoute('/login')({
component: LoginPage,
})
function LoginPage() {
// ...
}Section components break large pages into focused, testable units. The landing page composes sections:
function LandingPage() {
return (
<div>
<HeroSection />
<AnimatedSection><FeaturesSection /></AnimatedSection>
<AnimatedSection><AiTeamSection /></AnimatedSection>
{/* ... */}
</div>
)
}Auth client usage follows a consistent pattern: authClient.getSession() in beforeLoad for guards, and useSession() hook in components for reactive session state.
Shared UI Package (@repo/ui)
The packages/ui package provides the design system primitives consumed by apps/web.
Architecture
| Concern | Implementation |
|---|---|
| Bundler | tsup (ESM only, with "use client" banner) |
| Styling | Tailwind CSS v4 + class-variance-authority (CVA) |
| Primitives | Radix UI (headless) |
| Icons | Lucide React |
| Notifications | Sonner toast library |
| Theme engine | OKLCH color space with WCAG AA contrast checks |
Package Exports
The package exposes three entry points:
{
".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
"./styles.css": "./dist/styles.css",
"./theme.css": "./src/theme.css"
}@repo/ui-- All components, hooks, theme utilities, and thecn()helper@repo/ui/styles.css-- Compiled component styles@repo/ui/theme.css-- CSS custom properties for theming
Component Catalog
The package exports 30+ components, including:
| Category | Components |
|---|---|
| Layout | Card, Separator, Sheet, Tabs, Accordion |
| Forms | Input, Label, Checkbox, Select, Textarea, PasswordInput, Slider, Switch |
| Feedback | Alert, AlertDialog, Dialog, Tooltip, Sonner (Toaster), Spinner |
| Navigation | Button, NavigationMenu, DropdownMenu, Badge |
| Data | Table, Avatar, HoverCard, Skeleton, StatCounter |
| Animation | AnimatedSection (intersection observer) |
| Auth | OAuthButton (Google/GitHub) |
Theme Engine
The theme system uses OKLCH color space for perceptually uniform color manipulation:
import { applyTheme, deriveFullTheme, hexToOklch, meetsWcagAA } from '@repo/ui'Theme presets (base + color variants) can be composed and applied at runtime. The contrastRatio and meetsWcagAA utilities ensure accessibility compliance.
Integration with apps/web
Components are imported directly from the package:
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from '@repo/ui'The "use client" banner in the tsup build ensures all UI components are treated as client components in SSR contexts.
Client/Server Boundary
The architecture maintains a clear separation between server-side and client-side code:
Server-Only Code
| Location | Purpose |
|---|---|
src/server.ts | Server entry point, Paraglide middleware |
src/lib/apiClient.server.ts | Server-side API client (ofetch with correlation IDs) |
src/data/*.ts | Server functions (createServerFn) |
routes/**/api.*.ts | Server-only API route handlers |
Route loader functions | Run on server during SSR |
Route beforeLoad guards | Run on server during SSR, client during navigation |
Client-Only Code
| Location | Purpose |
|---|---|
src/components/*.tsx | React UI components |
src/lib/authClient.ts | Better Auth client (browser-side session) |
src/hooks/*.ts | Form hooks, custom React hooks |
src/integrations/tanstack-query/ | Query client setup and devtools |
Data Flow Patterns
SSR with loader -- Data is fetched server-side and available immediately on hydration:
SSR with TanStack Query -- Server-fetched queries are dehydrated and rehydrated:
Client-side API call -- Post-hydration data fetching through the Nitro proxy:
Server function RPC -- Type-safe server calls from client components:
Internationalization
The frontend uses Paraglide JS (Inlang) for compile-time i18n with URL-based locale routing.
| Concern | Implementation |
|---|---|
| Locale strategy | URL-based (/fr/..., /en/...) |
| Default locale | en |
| Supported locales | en, fr |
| Message files | apps/web/messages/{locale}.json |
| Compile step | paraglide-js compile generates typed message functions |
| Usage | import { m } from '@/paraglide/messages' then m.key() |
The router integrates locale-aware URL rewriting:
const router = createRouter({
routeTree,
rewrite: {
input: ({ url }) => deLocalizeUrl(url),
output: ({ url }) => localizeUrl(url),
},
})This means route definitions use unlocalized paths (/login), while the actual URLs include the locale prefix (/fr/login).
Error Handling
Error boundaries are handled by the root error boundary (__root.tsx) — catches unhandled errors in any route and displays a generic error page with a retry button using i18n messages. Uses react-error-boundary with a custom fallback component.
Developer Tooling
In development mode (import.meta.env.DEV), the root route renders a unified TanStack Devtools panel with two plugins:
| Plugin | Purpose |
|---|---|
| TanStack Router Devtools | Route tree inspection, navigation state |
| TanStack Query Devtools | Query cache, refetch status, stale tracking |
These are tree-shaken from production builds.
Related Documentation
- Architecture overview -- Monorepo structure and data flow
- Database architecture -- Drizzle ORM and schema conventions
- Frontend patterns -- Coding standards for React and TanStack
- Authentication guide -- Better Auth integration details
- Deployment guide -- Vercel deployment for web and API