I started building web applications in 2017 with PHP, HTML, CSS & JavaScript.Now it's almost exclusively TypeScript, Docker, Svelte, TailwindCSS, Bun, E2E Type Safety. The guide below is my 10/2026 blueprint: copy-paste ready, no product logic.

.md · 57.9 KB

Philipps 10/2026 Tech Stack Guide

Copy-paste blueprint for a Bun + SvelteKit + oRPC monorepo with end-to-end type safety, independent per-app releases, and Docker/Nginx deployment. No product-specific logic is documented here — only the structural pattern.


1. Overview

Layer Choice Why
Runtime Bun (ghcr.io/philippdormann/bun:1.4.4) Fast install/start, native Bun.serve, Bun.file/Bun.write — minimal + zero CVE
Package manager Bun workspaces (bun.lock at root) Single lockfile, workspace:* links, always version pinned deps
Language TypeScript (strict) strict: true, noUncheckedIndexedAccess, bundler resolution
Backend Bun.serve + oRPC v2 (beta) oRPC gives RPC + OpenAPI from one router
Validation Valibot (@orpc/valibot) Lightweight, converts to JSON Schema for OpenAPI
Frontend Svelte 5 + SvelteKit 3 + Vite 8 + Tailwind 4 + shadcn-svelte + cn Runes, adapter-static (SPA) or adapter-bun (SSR) — UI via shadcn-svelte (Tailwind + Bits UI) where available, no custom primitives; class merging via cn (not clsx/tailwind-merge/cnfast)
Fonts Fontsource (@fontsource/* self-hosted) Privacy — no Google Fonts CDN (fonts.googleapis.com), self-hosted subsets/weights, CSP-friendly (fontsource.org)
DB ORM Drizzle ORM v1.0.0-rc.5+ (drizzle-orm@1.0.0-rc.5 + drizzle-kit@1.0.0-rc.5) on Postgres via Bun's built-in Bun.SQL (drizzle-orm/bun-sql, no extra driver) Typed SQL via Bun native driver — no pg / postgres / mysql2 / better-sqlite3 needed (Bun SQL docs) — Postgres is the preferred DB; MySQL/MariaDB is legacy/avoid for new code. We track the v1 RC line (rc.5+, rc tag) — not latest (0.45.x). Pin drizzle-orm + drizzle-kit to the same RC; expect breaking changes between RCs (see §9)
Docs / content sites bongo (bongo.build) Preferred for plain docs / Markdown content sites — static HTML from Markdown + templates via Rolldown/LightningCSS (bun create bongo my-site). Do not reach for SvelteKit for docs-only sites (see §6.7)
Cache / Queues Valkey + BullMQ + Bun.cron Sessions, rate-limits, scheduled jobs
Object Storage Bun's built-in S3Client (bun — S3Client / s3 global, no extra SDK) S3-compatible (AWS / R2 / MinIO / Spaces) — no @aws-sdk/* needed (Bun S3 docs) — central new S3Client() per bucket
CDN / Assets Hono + Sharp (optional) Lightweight edge caching, image transforms
Observability Sentry (@sentry/bun / @sentry/browser + ORPCInstrumentation) Traces, error capture
CI/CD GitLab CI + Docker Buildx + Caddy audit stage 0 (bun audit gate) → tag-triggered per-app arm64 images
Security bun audit (stage audit, step 0) Blocking CI gate on every tagged build; fix before build starts

2. Directory Layout

.
├── package.json              # root workspaces definition
├── tsconfig.json             # shared base TS config
├── bun.lock
├── patches/                  # patchedDependencies (e.g. drizzle-kit)
├── Caddyfile                 # reverse proxy (prod)
├── docker-compose.yml        # local dev (valkey, mailpit, apps)
├── apps/*/Dockerfile         # file lives here: apps/backend/Dockerfile, apps/mobile/Dockerfile … (all minimal + zero-CVE based). Always build with repo-root context: `docker build -f apps/<app>/Dockerfile .`
├── .gitlab-ci.yml            # tag-triggered builds
├── scripts/                  # one-off codegen / maintenance scripts
│   └── update-*.ts
├── packages/
│   └── shared/               # @scope/shared — framework-agnostic utils + Svelte components
│       ├── package.json      # "exports" map, no bundling, direct .ts/.svelte imports
│       ├── *.ts
│       └── *.svelte
└── apps/
    ├── backend/              # Bun HTTP server, oRPC router, DB, queues
    │   ├── src/
    │   │   ├── server.ts     # Bun.serve entry (fetch + websocket) — sole listen point
    │   │   ├── index.ts      # type-only re-export of RouterType, no runtime
    │   │   ├── orpc/         # os, router, context, middleware, procedures/
    │   │   ├── db/           # drizzle clients + schema (generated)
    │   │   ├── middleware/   # cors, requestSize
    │   │   └── services/cron.ts  # Bun.cron jobs (+ cron-lease.ts single-flight guard)
    │   └── package.json      # @scope/backend, exports: { ".": { "types": "./src/index.ts" } } (types-only)
    ├── <frontend-a>/         # SvelteKit + adapter-static → nginx
    ├── <frontend-b>/         # SvelteKit + adapter-static → nginx
    ├── <frontend-c>/         # SvelteKit + adapter-static → nginx
    ├── website/              # SvelteKit + adapter-bun  → bun (SSR)
    └── cdn/                  # Hono server (Bun), optional

Rule: Anything imported by ≥2 apps lives in packages/shared. Anything app-specific stays in that app.


3. Package Manager & Workspaces

Root package.json:19-22:

{
	"private": true,
	"type": "module",
	"workspaces": ["packages/*", "apps/*"]
}
  • Use bun add -E <pkg> / bunx (never npx, never hand-edit dependencies).

  • Inter-package deps are workspace:*:

    { "dependencies": { "@scope/shared": "workspace:*" } }
    
  • Frontends depend on backend as devDependency only — they need its types, not its runtime:

    { "devDependencies": { "@scope/backend": "workspace:*" } }
    
  • Optional: patchedDependencies for upstream fixes without forking.

Root scripts delegate per-app:

{
	"scripts": {
		"check": "bun --filter @scope/backend typecheck && bun --filter @scope/app-a check && ...",
		"lint:all": "bun --filter @scope/backend lint && ..."
	}
}

bun --filter <pkg> runs the script in that workspace only.


4. TypeScript Baseline

Root tsconfig.json (shared):

{
	"compilerOptions": {
		"lib": ["ESNext"],
		"target": "ESNext",
		"module": "Preserve",
		"moduleResolution": "bundler",
		"allowImportingTsExtensions": true,
		"verbatimModuleSyntax": true,
		"noEmit": true,
		"strict": true,
		"skipLibCheck": true,
		"noFallthroughCasesInSwitch": true,
		"noUncheckedIndexedAccess": true,
		"noImplicitOverride": true
	}
}

Each app extends it. Backend enables noUnusedLocals/Parameters: true; frontends rely on svelte-check. Use rewriteRelativeImportExtensions: true in SvelteKit apps.


5. Backend — apps/backend

5.1 Server Entry (src/server.ts)

  • Bun.serve({ fetch, websocket }) — single process.

  • Health checks + migration gate before listening.

  • Two oRPC handlers from the same router:

    import { RPCHandler } from '@orpc/server/fetch';
    import { OpenAPIHandler } from '@orpc/openapi/fetch';
    import { CORSHandlerPlugin, ResponseCompressionHandlerPlugin } from '@orpc/server/plugins';
    import { OpenAPIReferenceHandlerPlugin } from '@orpc/openapi/plugins';
    import { ValibotToJsonSchemaConverter } from '@orpc/valibot';
    import { OpenAPIGenerator } from '@orpc/openapi';
    import { SmartCoercionHandlerPlugin } from '@orpc/json-schema';
    
    const converter = new ValibotToJsonSchemaConverter();
    const cors = () => new CORSHandlerPlugin({ origin: (o) => (isAllowed(o) ? o : null) });
    const rpcHandler = new RPCHandler(router, {
      plugins: [cors(), new ResponseCompressionHandlerPlugin()]
    });
    const openApiGenerator = new OpenAPIGenerator({ converters: [converter] });
    const openApiHandler = new OpenAPIHandler(router, {
      plugins: [
          cors(),
          new SmartCoercionHandlerPlugin({ converters: [converter] }),
          new OpenAPIReferenceHandlerPlugin({
              spec: () => openApiGenerator.generate(router, { base: { info, servers } }),
              provider: 'scalar',
              docsPath: '/docs',
              specPath: '/openapi.json'
          })
      ]
    });
    
    Bun.serve({
      async fetch(req, server) {
          if (server.upgrade(req)) return undefined;
          const ctx = { request: req.clone(), db, valkey };
          if (new URL(req.url).pathname.startsWith('/rpc')) {
              const res = await rpcHandler.handle(req, { prefix: '/rpc', context: ctx });
              return res.matched ? res.response : new Response('Not Found', { status: 404 });
          }
          const res = await openApiHandler.handle(req, { context: ctx });
          return res.matched ? res.response : new Response('Not Found', { status: 404 });
      },
      websocket: {
          open,
          message(ws, raw) {
              handleWsMessage(ws, raw);
          },
          close
      }
    });
    
  • Sentry + ORPCInstrumentation via onError interceptor.

  • Graceful shutdown via registerCleanup().

5.2 oRPC Router & Procedures

src/orpc/
├── os.ts            # baseOs.$context<ORPCContext>() — typed `os` used by all procedures
├── router.ts        # assembles os.router({ ... })
├── context.ts       # ORPCContext = { request, db, user? }
├── middleware.ts    # authMiddleware, requirePermission(), caching
├── errors.ts        # throwBadRequest / throwNotFound / ORPCError helpers + responseCodes
├── procedures/      # one file per domain, each exports .meta(openapi(...)).input(v.object).handler(...)
└── s3.ts / ws.ts    # infra helpers (storage via Bun S3Client — see §11 — / websocket)

Procedure pattern (oRPC v2 beta + Valibot validation + typed handler):

import { os } from "./os"; // baseOs.$context<ORPCContext>() from "@orpc/server"
import { openapi } from "@orpc/openapi";
import * as v from "valibot";

export const myProcedure = os
  .meta(openapi({
    method: "GET",            // also drives OpenAPI method
    path: "/v1/my-resource",  // also drives OpenAPI path
    tags: ["MyDomain"],
    summary: "...",
    description: "...",
  }))
  .input(v.object({ id: v.string(), includeArchived: v.optional(v.string()) }))
  // .use(authMiddleware).use(requirePermission("my:read"))
  .handler(async ({ input, context }) => {
    // context: ORPCContext — typed DB + request
    return { status: "ok", code: "OK", data: { ... } };
  });

Then aggregate in router.ts:

import { os } from './os';
import * as domain from './procedures/domain';
export const orpcRouter = os.router({ healthz, livez, ...domain });

5.3 Dual Transport

Transport Handler URL Consumer
RPC (JSON over POST) RPCHandler /rpc/* First-party frontends via @orpc/client
REST / OpenAPI OpenAPIHandler path as defined in .meta(openapi()) (e.g. /v1/...) Third parties, Scalar docs at /docs, raw spec at /openapi.json

Both handlers share the same router instance — no duplication.

5.4 End-to-End Type Safety

The entire chain is type-checked without codegen:

backend/src/orpc/router.ts  →  export type RouterType
        │
        │  re-exported via
        ▼
backend/src/index.ts        →  export type RouterType = RouterClient<typeof orpcRouter>
        │
        │  imported as devDependency  "@scope/backend": "workspace:*"
        ▼
frontend/src/lib/orpc.ts    →  createORPCClient<RouterType>(new RPCLink({ origin, url: "/rpc", headers: ... }))
        │
        ▼
frontend code               →  orpc.myProcedure({ id: "..." })  // fully typed input/output

Backend exports types only (package.json exports: { ".": { "types": "./src/index.ts" } }). Frontends install @scope/backend as devDependencies so the import is erased at build.

Client setup (per frontend, src/lib/orpc.ts — must stay SSR-safe for adapter-bun):

import { browser } from '$app/environment';
import { PUBLIC_API_BASE_URL } from '$env/static/public';
import { createORPCClient } from '@orpc/client';
import { RPCLink } from '@orpc/client/fetch';
import { RetryLinkPlugin, DedupeLinkPlugin } from '@orpc/client/plugins';
import type { RouterType } from '@scope/backend';

// `.env`: PUBLIC_API_BASE_URL=https://api.example.com
// Never touch `window` / `localStorage` at module top-level — this module also runs on the server.
const baseUrl =
	browser && window.location.hostname === 'localhost'
		? 'http://localhost:3000'
		: PUBLIC_API_BASE_URL;

const link = new RPCLink({
	origin: baseUrl,
	url: '/rpc',
	headers: () => {
		if (!browser) return {};
		const t = localStorage.getItem('auth');
		return t ? { Authorization: `Bearer ${t}` } : {};
	},
	fetch: (req, init) => {
		const timeout = AbortSignal.timeout(30_000);
		const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
		return globalThis.fetch(req, { ...init, signal });
	},
	plugins: [
		new RetryLinkPlugin({ default: { retry: 2, retryDelay: 1_000 } }),
		new DedupeLinkPlugin({
			filter: ({ request }) => request.method === 'GET',
			groups: [{ condition: () => true, context: {} }]
		})
	]
});

export const orpc = createORPCClient<RouterType>(link);

Only needed when procedures return an envelope ({ status, code, data } as in §5.2). Plain procedure results need no unwrapping:

// packages/shared/orpc-unwrap.ts
export function unwrapOrpcResponse<T>(raw: { data: T } | T): T {
	if (typeof raw === 'object' && raw !== null && 'data' in raw) {
		return raw.data;
	}
	return raw;
}

5.5 End-to-End Type-Safe WebSocket Messages

oRPC covers request/response. For real-time push (chat, live updates) use the same single-source-of-truth principle via a shared event catalog and Valibot schemas.

Shared catalog (packages/shared/ws.ts — constants + Valibot schemas only, no server code):

export const ChatClientType = {
	NEW_MESSAGE: 'newMessage',
	TYPING: 'typing'
	// ...
} as const;
export const WsServerType = {
	MESSAGE: 'message',
	TYPING: 'typing',
	UNREAD_UPDATED: 'unreadUpdated',
	ERROR: 'error'
	// ...
} as const;

Both backend (apps/backend/src/ws-hub.ts, apps/backend/src/ws/handler.ts) and frontends import event names from @scope/shared/ws — event names can never drift.

Backend: typed handler map (apps/backend/src/ws/handler.ts + apps/backend/src/ws-hub.ts):

import * as v from 'valibot';
import { ChatClientType, WsServerType } from '@scope/shared/ws';

const EnvelopeSchema = v.object({ type: v.string(), payload: v.optional(v.unknown()) });
type Envelope = v.InferOutput<typeof EnvelopeSchema>;
type AuthedClient = { userId: string };

const clients = new Map<ServerWebSocket, AuthedClient>();

const MessagePostSchema = v.object({
	target: v.string(),
	content: v.pipe(v.string(), v.maxLength(15_000)),
	media: v.optional(v.string())
});

type WsHandler = (ws: ServerWebSocket, msg: Envelope, client: AuthedClient) => void;

function sendError(ws: ServerWebSocket, code: string): void {
	ws.send(JSON.stringify({ type: WsServerType.ERROR, data: code }));
}

export const authedHandlers: Partial<Record<string, WsHandler>> = {
	[ChatClientType.NEW_MESSAGE]: (ws, msg, client) => {
		const parsed = v.safeParse(MessagePostSchema, msg.payload);
		if (!parsed.success) {
			sendError(ws, 'INVALID_PAYLOAD');
			return;
		}
		// ... use parsed.output with full type safety
	},
	[ChatClientType.TYPING]: (ws, msg, client) => {
		/* ... */
	}
};

// Single entry point wired to `Bun.serve({ websocket: { message } })` in `src/server.ts`
export function handleWsMessage(ws: ServerWebSocket, raw: string): void {
	let decoded: unknown;
	try {
		decoded = JSON.parse(raw);
	} catch {
		sendError(ws, 'INVALID_PAYLOAD');
		return;
	}
	const envelope = v.safeParse(EnvelopeSchema, decoded);
	if (!envelope.success) {
		sendError(ws, 'INVALID_PAYLOAD');
		return;
	}
	const client = clients.get(ws);
	if (!client) {
		sendError(ws, 'NOT_AUTHED');
		return;
	}
	authedHandlers[envelope.output.type]?.(ws, envelope.output, client);
}

Valibot gives the same DX as oRPC procedures: parse once, then parsed.output is fully typed — no type assertions needed.

Frontend: typed wrapper (apps/<frontend>/src/lib/websocket.ts):

import { browser } from '$app/environment';
import { PUBLIC_API_BASE_URL } from '$env/static/public';
import type { ChatSocketPayload } from '@scope/shared/ws';

// Native WebSocket served by `Bun.serve` (`src/server.ts` + `ws-hub` fan-out).
// Auth goes via `?auth=` query param. Reconnect + lifecycle (e.g. Capacitor
// `resume`/`background`) is handled by the caller.
let socket: WebSocket | null = null;

export function createWebsocket(auth: string): WebSocket {
	const wsUrl = new URL(`${PUBLIC_API_BASE_URL.replace(/^http/, 'ws')}/v1/ws`);
	wsUrl.searchParams.set('auth', auth);
	socket = new WebSocket(wsUrl.toString());
	return socket;
}

export function sendWebsocket(msg: ChatSocketPayload): void {
	if (browser) socket?.send(JSON.stringify(msg));
}

Note: oRPC covers request/response (RPC + OpenAPI streaming where needed). Push messages (chat, live updates) use a separate native WebSocket served by the same Bun.serve({ fetch, websocket: { open, message, close } }) in src/server.ts with fan-out in ws-hub.ts.

Why this works:

  • Event names are a shared as const union — rename in one place, TS errors everywhere.
  • Payloads are Valibot schemas shared or mirrored backend/frontend — no any / as unknown / as assertions needed.
  • The authedHandlers map is the websocket equivalent of os.router(): a single registry guarantees both sides agree on the contract.
  • Keep packages/shared free of server-only code; only the event constants (and optionally shared Valibot schemas) live there.

6. Frontend Apps — SvelteKit + Capacitor Native

6.0 Capacitor Native Integration (apps/mobile)

The mobile app is a SvelteKit SPA + Capacitor hybrid — same build/ output runs on web and as a native iOS/Android shell.

  • Config: apps/mobile/capacitor.config.ts:1-31 — appId: "mobile.my.app", webDir: "build", platform ios/android flags (allowsLinkPreview: false, zoomEnabled: false), Keyboard (resize: Body, style: Light) and push (FirebaseMessaging / PushNotifications with badge/sound/alert). Native shells live in apps/mobile/android/ and apps/mobile/ios/ (generated, platform-specific .gitignore).
  • Build pipeline: apps/mobile/package.json:6-12 exposes build:native, build:native:android, build:native:ios:
    bun run build                  # vite build → build/
    bunx cap sync                  # copy web assets into android/ios
    bun run set:versions           # syncs version → Info.plist / project.pbxproj / build.gradle
    bunx cap open android|ios      # opens Xcode / Android Studio
    
    set-native-version.ts:1-74 derives versionCode (major*10000 + minor*100 + patch) and MARKETING_VERSION from package.json/APP_VERSION and patches ios/App/App/Info.plist, ios/App/App.xcodeproj/project.pbxproj, android/app/build.gradle.
  • Plugins in use (apps/mobile/package.json:68-99): @capacitor/core|cli|android|ios, @capacitor/app|app-launcher|camera|clipboard|device|dialog|filesystem|haptics|inappbrowser|keyboard|network|preferences|share, @capacitor-firebase/messaging, @capawesome/capacitor-badge, @ebarooni/capacitor-calendar, @capacitor-community/* (in-app-review, media), capacitor-native-settings. All access is via @capacitor/* ESM imports — no native code in JS besides Capacitor.isNativePlatform() guards.
  • WebSocket + Capacitor: Real-time uses the same @scope/shared/ws catalog. On native, Capacitor network/keyboard lifecycle is respected (reconnect on resume, pause on background), but the transport stays standard WebSocket (Bun.serve websocket) — no extra native socket plugin required.
  • Rule: Never commit android/build/, ios/App/public/, or capacitor.config.json copies inside ios/App/App/; they are generated by cap sync. Version bumps go through set-native-version.ts, not hand-edits.

6.1 Per-App Package

Each frontend has its own package.json with independent version and release-it config (see §13).

6.2 Vite + SvelteKit Config

  • SPA frontends → adapter-static with SPA fallback + nginx:

    // vite.config.ts
    import adapterStatic from '@sveltejs/adapter-static';
    import { sveltekit } from '@sveltejs/kit/vite';
    import tailwindcss from '@tailwindcss/vite';
    export default defineConfig({
      plugins: [
          tailwindcss(),
          sveltekit({
              adapter: adapterStatic({
                  pages: 'build',
                  assets: 'build',
                  fallback: 'app.html',
                  precompress: true
              })
          })
      ],
      ssr: { noExternal: ['@scope/shared'] }
    });
    

    Dockerfile is two-stage: ghcr.io/philippdormann/bun:1.4.4 → bun run build → nginx:1.29-alpine3.23-slim (alpine) serving build/ with nginx.conf that does try_files $uri /app.html and long-cache on /_app.

  • SSR website → adapter-bun running build/index.js with bun build/index.js on PORT=80 (no node in the runtime image). Build + runtime both use ghcr.io/philippdormann/bun:1.4.4 (apps/website/Dockerfile:1,34) — minimal + zero CVE, prefer ghcr.io/philippdormann/bun everywhere (see §12 Docker policy).

    // vite.config.ts
    import adapterBun from '@sveltejs/adapter-bun';
    import { sveltekit } from '@sveltejs/kit/vite';
    import tailwindcss from '@tailwindcss/vite';
    export default defineConfig({
      plugins: [tailwindcss(), sveltekit({ adapter: adapterBun() })],
      ssr: { noExternal: ['@scope/shared'] }
    });
    

6.3 Nginx Pattern (nginx.conf)

server {
  error_page 404 /app.html;          # or /index.html — must match adapter fallback
  location /_app { expires 1y; }     # hashed SvelteKit assets — immutable
  location / { try_files $uri $uri/ /app.html; }
  gzip on; gzip_types text/css application/javascript ...;
}

6.4 Conventions Inside Frontends

  • #lib import alias via package.json imports: { "#lib": "./src/lib/index.js" }.
  • src/lib/orpc.ts owns the typed client (above).
  • Validation on the client with valibot mirrors backend schemas but is not coupled — backend is source of truth.
  • Sentry @sentry/browser per frontend.

6.5 UI Components — Prefer shadcn-svelte

Policy: For frontend design, prefer components from shadcn-svelte where available — do not hand-roll primitives that already exist there. shadcn-svelte is Tailwind 4 + Bits UI based, accessible, and matches the stack's styling (see §8). Use the CLI to vendor components into each app; customize via components.json / CSS variables, not by forking.

  • Install per frontend: bunx shadcn-svelte@latest init then bunx shadcn-svelte@latest add button card dialog ... (see SvelteKit install + CLI + theming). Components vendor to src/lib/components/ui/ — committed, not from node_modules.
  • Class merging: Frontends use cn — import { cn } from "cn" — as drop-in replacement for clsx + tailwind-merge (and cnfast). Do not add clsx, tailwind-merge, or cnfast in frontend projects. If cn is needed: bun add -E cn, then cn("px-2 py-1", isActive && "bg-blue-500", { "text-white": isActive }, className). Legacy lib/utils.ts wrapper (twMerge(clsx(...))) is replaced by export { cn } from "cn" — see cn docs + npx shadcn@latest migrate cn for existing projects.
  • Available primitives (check llms.txt for full catalog): button, input, select, checkbox, dialog, drawer, dropdown-menu, tabs, card, table, data-table (TanStack), chart (LayerChart), sonner (toast), avatar, skeleton, carousel, etc. — use these before building custom.
  • Keep app-specific composites on top of shadcn primitives (e.g. UserCard.svelte composes Card + Avatar + Badge from ui/). Truly shared UI (e.g. Logo.svelte, format-date) stays in packages/shared; shadcn primitives stay per-app (they vendor per project by design).
  • Theming: CSS variables + Tailwind 4 — configure in components.json and app.css per theming and Tailwind v4 migration.

6.6 Default UX Decisions

Policy: Apply these UX defaults to every frontend unless a feature explicitly opts out.

  • Confirm modals for critical interactions: Destructive or irreversible actions (delete, bulk delete, archive, publish, invite revoke, payment, permission change) must use a confirmation modal — never window.confirm/alert (see §15). Prefer AlertDialog from shadcn-svelte (see Alert Dialog) for destructive confirms and Dialog for non-destructive. Modal must state the impact in plain language, show the count/names of affected items, require explicit confirm (e.g. Delete 3 items button, disabled until acknowledged), and offer undo where feasible. No critical action on single-click without confirm.

  • Default to non-technical user-facing texts: All copy shown to end users defaults to plain, non-technical language (DE/EN). Avoid jargon, error codes, stack traces, table/column names, or IDs in UI. Map backend errors to friendly messages ("Could not save — please try again." not "500: pg error 23505"). Keep technical details in logs/Sentry, not in toasts/alerts. Write copy for easy i18n later — no concatenated strings, use complete sentences with placeholders.

  • Bulk actions where useful: Any list/table with ≥3 items that supports a per-item destructive or state-changing action must also offer bulk selection + bulk action. Use Checkbox + Data Table row selection (shadcn-svelte Data Table / Checkbox) with a sticky bulk bar (e.g. 3 selected — Delete | Archive | Export). Bulk actions reuse the same confirm modal (with count) and show progress / partial-failure handling. Design empty- and single-selection states explicitly.

6.7 Plain Docs / Content Sites — Prefer bongo

Policy: For plain docs / Markdown content sites (no app logic, no auth, no oRPC), prefer bongo over SvelteKit. SvelteKit stays the choice for interactive frontends (§6.1–6.6); bongo is the choice for docs-only sites.

  • Why: Markdown + templates + assets → fast static HTML in public/. Rolldown for scripts, LightningCSS for styles, Bun.Image for resized/WebP srcset images. No SvelteKit/Vite overhead for content that does not need it.
  • Scaffold: bun create bongo my-site → bun install → bun run dev (http://localhost:3000/), bun run build writes public/. Layout is content/ (.md/.hbs/.html pages) + layouts/ (html.hbs wraps each page) + partials/ + data/ ($data) + assets/ + bongo.config.ts ($site, plugins e.g. @bongo/markdown, @bongo/feed). See Quick start + Project layout.
  • Rule: If the site needs accounts, RPC, or client state — use SvelteKit. If it is docs/blog/marketing content — use bongo. Do not add SvelteKit to a bongo site (or vice versa) without justification.

7. Shared Package — packages/shared

packages/shared/package.json
{
  "name": "@scope/shared",
  "private": true,
  "type": "module",
  "exports": {
    "./format-date": { "types": "./format-date.ts", "default": "./format-date.ts" },
    "./Logo.svelte": { "types": "./Logo.svelte", "default": "./Logo.svelte" },
    ...
  }
}
  • No build step — consumers import .ts/.svelte directly. Vite/SvelteKit + Bun handle it (ssr.noExternal).
  • Holds only framework-agnostic helpers and presentational Svelte components (date formatting, debounce, storage, avatar placeholders, etc.). Do not duplicate shadcn-svelte primitives in shared — shadcn components vendor per-app via the CLI (see §6.5); only truly cross-app composites belong in shared.
  • Keep server-only code out of shared.

8. Styling / UI / Linting / Formatting

UI Policy: Frontend styling is Tailwind 4 + shadcn-svelte primitives (see §6.5) + cn for class merging. Prefer https://www.shadcn-svelte.com/llms.txt components (button, card, dialog, table, sonner, etc.) over custom CSS/primitives — install via shadcn-svelte CLI and theme via CSS variables (components.json). Use cn from "cn" (not clsx/tailwind-merge/cnfast) — cn("px-2 py-1", condition && "bg-blue-500", className).

Fonts Policy: Use Fontsource (@fontsource/*) self-hosted via bun add -E @fontsource/<font> — do not load Google Fonts CDN (fonts.googleapis.com / fonts.gstatic.com). Improves privacy (no third-party request), offline, CSP-friendly. Import in src/app.css or +layout.svelte: import "@fontsource/inter/latin-400.css" / import "@fontsource/inter/latin-700.css" — pick subsets/weights explicitly. See fontsource.org.

App Formatter Linter Typecheck
backend, cdn bun fmt oxlint bun check
Frontends bun fmt eslint + eslint-plugin-svelte + typescript-eslint svelte-check --tsconfig ./tsconfig.json

Root lint:all / check scripts fan out with bun --filter.

Policy: Formatting and typechecking use Bun builtins everywhere — bun fmt (Prettier-compatible output incl. .svelte, configured via bunfig.toml [fmt]) and bun check (same errors as tsc, uses every CPU core). Do not add prettier, prettier-plugin-*, oxfmt, or any other formatter. Linting stays on oxlint (backend/CDN) and eslint (frontends) until Bun ships a builtin linter.


9. Database & ORM — Postgres Preferred, Drizzle v1 RC

Policy: Postgres is the preferred database for all new code in this template. Drizzle is wired via drizzle-orm/bun-sql (apps/backend/src/db.ts:2, drizzle.config.ts:6 dialect: "postgresql"). MySQL/MariaDB is legacy / avoid — do not introduce mysql2/drizzle-orm/mysql-core for new features or new services.

Version policy: Drizzle v1.0.0-rc.5+ (RC line, not latest). Install with the rc tag and pin both packages to the same RC: bun add -E drizzle-orm@1.0.0-rc.5 drizzle-kit@1.0.0-rc.5. latest is still 0.45.x — do not use it for new code. RCs contain breaking changes — read the v1.0.0-rc.5 release notes before upgrading. Notable rc.5 breakings for this stack: relational where: undefined now throws (use EmptyFilter from drizzle-orm/relations), RQBv2 is the default query strategy for pg/mysql (opt out per-query with strategy: "basic"), View exports moved from drizzle-orm/sql/sql to drizzle-orm/view, withReplicas now routes to primary by default (use db.$replica explicitly), and sqlite-proxy executors were reworked (run/all/get + rowMode).

No extra DB driver needed: Postgres access uses Bun's built-in Bun.SQL (import { SQL } from "bun" / import { sql } from "bun"). Do not install pg, postgres (porsager/postgres), mysql2, or better-sqlite3 — Bun ships a native SQL client with pooling, prepared statements, and a unified API for Postgres/MySQL/SQLite. See Bun SQL docs. Drizzle plugs into it via drizzle-orm/bun-sql.

  • Single Drizzle client in apps/backend/src/db.ts:1-25 — thin wrapper over the native client:

    import { SQL } from 'bun'; // Bun built-in — no npm driver to install
    import { drizzle } from 'drizzle-orm/bun-sql'; // or "drizzle-orm/bun-sql/postgres"
    const client = new SQL(process.env.DATABASE_URL!, { max: 10, idleTimeout: 30 });
    export const db = drizzle({ client });
    export async function checkDbConnection() {
      await db.execute(sql`SELECT 1`);
    }
    // Raw Bun SQL also works without Drizzle: await client`SELECT 1`
    
  • Schema under src/db/schema/ — all imports from drizzle-orm/pg-core (apps/backend/src/db/schema.ts:12, src/index.ts:12 drizzle-orm/pg-core/migrator). Snapshots are dialect: "postgres" (apps/backend/drizzle/*/snapshot.json:5), service image is postgres:18.0-alpine3.22 (apps/backend/docker-compose.yml:14) and DATABASE_URL=postgresql://... (apps/backend/docker-compose.yml:10, .env.sample:7).

  • Generated via drizzle-kit pull (introspects existing DB) + drizzle-kit generate/migrate.

  • src/db/migrate.ts runs on startup when RUN_DB_MIGRATIONS_ON_STARTUP=true (see apps/backend/src/index.ts:48-55).

  • Keep migrations out of version control noise: PRs should not contain generated SQL — generate after merge (enforced via PR template).


10. Caching / Queues / Jobs

  • Valkey (single instance, Redis-compatible) via Bun's built-in Bun.RedisClient — no ioredis needed — for auth cache, rate limits, de-duplication, distributed locks:

    // Lazy singleton — falls back to in-memory outside prod; separate client for pub/sub
    const Ctor = (Bun as typeof Bun & { RedisClient?: new (url: string) => RedisLike }).RedisClient;
    const redis = process.env.REDIS_URL && Ctor ? new Ctor(process.env.REDIS_URL) : null;
    
  • BullMQ for mail queues, push queues.

  • Bun.cron for scheduled jobs (src/services/cron.ts + src/services/cron-lease.ts), guarded by a single-flight lease so only one replica runs:

    // startCronJob() — call once at boot; skip when CRON_ENABLED=false
    cronJobs = [
      Bun.cron('*/30 * * * *', async () => {
          if (!(await tryAcquireCronLease('monthly-reminder', 25 * 60))) return;
          try {
              await doWork();
          } finally {
              await releaseCronLease('monthly-reminder');
          }
      })
    ];
    // Lease backend: Valkey/Redis `SET key 1 PX ttl NX` (in-memory fallback for dev),
    // or a Postgres `cron_leases` row with atomic INSERT … ON CONFLICT DO UPDATE … WHERE expired.
    
  • Image/proxy helpers (imageproxy.ts, orpc/s3.ts) build presigned URLs in the backend and cache them where possible — frontends never construct storage URLs (see §11 for S3).


11. Object Storage — S3 via Bun-native S3Client

No extra S3 SDK needed: S3 access uses Bun's built-in S3Client (import { S3Client } from "bun" / import { s3 } from "bun"). Do not install @aws-sdk/client-s3, @aws-sdk/s3-presigner, aws-sdk, or minio — Bun ships a native S3 client with S3File (Blob-compatible), presigning, streaming/multipart, and s3:// in fetch/Bun.file. See Bun S3 docs.

Policy: Prefer a single central S3Client per bucket (e.g. apps/backend/src/lib/s3.ts). Pass it around; do not create ad-hoc new S3Client() in every procedure/handler.

  • Central client in apps/backend/src/lib/s3.ts:1-25 — sole place that reads env/credentials:

    import { S3Client } from 'bun'; // Bun built-in — no npm S3 SDK to install
    
    // Single central client per bucket — reuse everywhere
    export const s3 = new S3Client({
      bucket: process.env.S3_BUCKET!
      // credentials auto-read from S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY
      // (falls back to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) or set explicitly:
      // accessKeyId: process.env.S3_ACCESS_KEY_ID!,
      // secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
      // endpoint: process.env.S3_ENDPOINT, // R2 / MinIO / Spaces / Supabase — omit for AWS
      // region: process.env.S3_REGION, // e.g. "us-east-1" / "auto" for R2
    });
    
    // Optional: also export a helpers bucket for presigned URL / exists checks
    export function s3File(key: string) {
      return s3.file(key);
    } // lazy S3File ref
    
  • Usage from procedures / helpers (src/orpc/s3.ts:1-40, src/orpc/procedures/*):

    import { s3, s3File } from '../../lib/s3.ts';
    
    // Read (Blob-compatible — same API as Bun.file)
    const text = await s3File('user/123.json').text();
    const json = await s3File('user/123.json').json();
    const stream = s3File('large.bin').stream();
    
    // Write / upload (auto multipart for large streams)
    await s3.write('uploads/avatar.png', imageBuffer, { type: 'image/png' });
    // or
    await s3File('uploads/avatar.png').write(imageBuffer, { type: 'image/png' });
    await Bun.write(s3File('uploads/report.pdf'), pdfBytes);
    
    // Presign (sync — no network request) — backend builds, frontend uses
    const downloadUrl = s3File('private/doc.pdf').presign({ expiresIn: 3600 });
    const uploadUrl = s3File('uploads/incoming.jpg').presign({
      method: 'PUT',
      expiresIn: 600,
      type: 'image/jpeg'
    });
    
    // Exists / delete / stat
    const exists = await s3.exists('uploads/avatar.png');
    await s3.delete('tmp/old.json');
    const { size, etag } = await s3File('uploads/avatar.png').stat();
    
  • Credentials: S3Client auto-reads S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET, S3_ENDPOINT, S3_REGION (with AWS_* fallbacks) from env/.env — no process.env plumbing needed. Override per-client via constructor options — see Bun S3 credentials. Works with AWS S3, Cloudflare R2 (endpoint: "https://<account>.r2.cloudflarestorage.com"), DigitalOcean Spaces, MinIO (endpoint: "http://localhost:9000"), Supabase — same S3Client.

  • Keep packages/shared free of S3 code; only the presigned URL string leaves the backend. Frontends never construct s3:// paths or call S3Client — they receive https://... presigned URLs via oRPC (see §5.2 orpc/s3.ts).


12. Deployment

Docker — Minimal + Zero CVE Only

Policy: Use ghcr.io/philippdormann/bun:1.4.4 (minimal + zero CVE) for all Bun build/runtime stages. All Dockerfiles in this repo follow this — do not introduce oven/bun, debian/slim-bullseye/ubuntu bases for new services without justification.

  • One *.Dockerfile per app at repo root (so docker build -f apps/<app>/Dockerfile . gets the whole monorepo context — actual files at apps/backend/Dockerfile:1, apps/mobile/Dockerfile:1, apps/website/Dockerfile:1).
  • Backend/CDN: single-stage ghcr.io/philippdormann/bun:1.4.4 (apps/backend/Dockerfile:1), bun install --filter @scope/<app> --production, CMD ["bun","run","src/index.ts"].
  • Frontends (static): multi-stage ghcr.io/philippdormann/bun:1.4.4 build → nginx:1.29-alpine3.23-slim serve (apps/mobile/Dockerfile:1, apps/mobile/Dockerfile:28). The -alpine-slim nginx variant is still alpine-based.
  • Website (SSR): multi-stage ghcr.io/philippdormann/bun:1.4.4 build → ghcr.io/philippdormann/bun:1.4.4 runtime (apps/website/Dockerfile:1, apps/website/Dockerfile:34 production stage, bun run build/index.js). No node:*-alpine mixing unless strictly needed.
  • Verification: CI builds all images via docker buildx --platform linux/arm64 (.gitlab-ci.yml:61); local parity via docker-compose.yml per app (apps/*/docker-compose.yml). If you must use a non-minimal base, document why in the Dockerfile header comment.

Caddy (Caddyfile)

Caddy terminates TLS and reverse-proxies per subdomain:

api.example.com      → backend:3000
cdn.example.com      → cdn:3000
app.example.com      → frontend-a:80
admin.example.com    → frontend-b:80
example.com          → website:80

Add redir blocks for legacy URL compatibility (keep old links working).

Local Dev (docker-compose.yml)

valkey, mailpit, backend, and each frontend (:4000, :4001, …) for parity. Backend waits for valkey: healthy.

Security — bun audit (CI Step 0)

Requirement: Every tagged build runs bun audit as stage 0 before any build job. Fix vulnerabilities before the build is allowed to start.

In this repo (.gitlab-ci.yml:1-14):

stages: [audit, build] # audit is step 0 — always first
audit:
  stage: audit
  image: ghcr.io/philippdormann/bun:1.4.4 # minimal + zero CVE (see Docker policy)
  rules:
    - if: $CI_COMMIT_TAG =~ /^backend-.*$/
    - if: $CI_COMMIT_TAG =~ /^website-.*$/
    - if: $CI_COMMIT_TAG =~ /^mobile-.*$/
  script: [bun audit] # fails the pipeline on audit findings
  • docker-build-* jobs all have needs: [audit] (and docker-build-backend additionally needs: [audit, typecheck-backend] — .gitlab-ci.yml:69-89), so the audit gate is blocking — builds never start if auditing fails.
  • Locally, run bun audit before pushing a release tag. For a quick pre-release check: bun audit --help and bun pm pack for advisory details.
  • This gate exists to catch CVEs in the single bun.lock workspace before images are built/pushed.

13. Release Flow — Independent Per-App Versioning

Each app has its own version in apps/<app>/package.json and a release-it block:

{
	"version": "1.2.3",
	"scripts": { "release": "release-it" },
	"release-it": {
		"git": {
			"commit": true,
			"push": true,
			"tag": true,
			"requireBranch": "main",
			"requireCleanWorkingDir": true,
			"commitMessage": "chore(release): <app>-${version}",
			"tagName": "<app>-${version}",
			"tagAnnotation": "<app>-${version}"
		},
		"npm": { "publish": false },
		"hooks": { "after:bump": "bun run build-versioninfo.ts && git add versioninfo.ts" }
	}
}

Flow:

  1. bun --filter @scope/<app> release (or release-it directly) bumps package.json, creates commit chore(release): <app>-x.y.z, tags <app>-x.y.z, pushes.

  2. .gitlab-ci.yml has one job per app, triggered only by matching tag. All jobs are gated by audit (step 0) — builds only run after bun audit passes (.gitlab-ci.yml:69-89 needs: [audit]):

    audit: # stage: audit — step 0, blocking gate
      stage: audit
      image: ghcr.io/philippdormann/bun:1.4.4
      script: [bun audit]
    
    backend: # stage: build
      needs: [audit, typecheck-backend]
      rules: [{ if: '$CI_COMMIT_TAG =~ /^backend-\d+\.\d+\.\d+$/' }]
      script:
        - docker buildx build --platform linux/arm64 -f "apps/$APP_NAME/Dockerfile"
          -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" --push .
    

    This produces floating major tags like backend-4-arm64, frontend-a-2-arm64 — deploy pulls the major tag, no redeploy config change for patch/minor.

  3. The same pattern applies to every frontend and cdn/website. Every Dockerfile must use ghcr.io/philippdormann/bun:1.4.4 (minimal + zero CVE) for Bun stages (see §12 Docker policy); non-minimal bases require justification.

Optional scripts/update-*.ts codegen runs in prebuild of frontends (e.g. generate appversions.ts from the live API) so builds stay in sync.


14. How to Replicate This Pattern From Scratch

  1. Init monorepo:
    bun init
    # root package.json: { "private": true, "workspaces": ["packages/*", "apps/*"] }
    
  2. Add shared:
    mkdir -p packages/shared
    # packages/shared/package.json with "exports" map, no build
    
  3. Add backend:
    mkdir -p apps/backend/src/{orpc/procedures,db,middleware}
    bun add -E @orpc/server @orpc/openapi @orpc/client @orpc/valibot @orpc/json-schema valibot drizzle-orm@1.0.0-rc.5 drizzle-kit@1.0.0-rc.5
    # Drizzle v1 RC line (rc.5+) — pin drizzle-orm + drizzle-kit to the SAME RC, `latest` (0.45.x) is legacy for this stack (see §9).
    # DB: no extra driver — use Bun's built-in SQL (https://bun.sh/docs/runtime/sql)
    # via `import { SQL } from "bun"` + `drizzle-orm/bun-sql`. Do NOT add pg/postgres/mysql2.
    # S3: no extra SDK — use Bun's built-in S3Client (https://bun.sh/docs/runtime/s3)
    # via `import { S3Client } from "bun"`. Do NOT add @aws-sdk/* / aws-sdk / minio.
    # src/server.ts: Bun.serve entry (sole listen point)
    # src/index.ts: type-only `export type RouterType = RouterClient<typeof orpcRouter>`
    # oRPC v2 beta: procedures use `.meta(openapi({ method, path, ... }))` (not `.route()`); base `os` via `baseOs.$context<ORPCContext>()`; REST coercion via `SmartCoercionHandlerPlugin`.
    # package.json: { "name": "@scope/backend", "exports": { ".": { "types": "./src/index.ts" } } }
    
  4. Add a frontend:
    bunx sv create apps/app-a   # choose SvelteKit + TS — interactive apps only; plain docs sites use bongo instead (see §6.7)
    # add to apps/app-a/package.json:
    #   "devDependencies": { "@scope/backend": "workspace:*" }
    #   "dependencies": { "@scope/shared": "workspace:*", "@orpc/client": "..." }
    # create src/lib/orpc.ts as in §5.4 (SSR-safe: PUBLIC_API_BASE_URL + browser guard)
    # vite.config.ts: ssr.noExternal = ["@scope/shared"]
    # UI: prefer shadcn-svelte — https://www.shadcn-svelte.com/llms.txt (see §6.5)
    #   bunx shadcn-svelte@latest init && bunx shadcn-svelte@latest add button card dialog input select tabs
    # class merging: use `cn` from "cn" (https://npmx.dev/package/cn) — not clsx/tailwind-merge/cnfast
    #   bun add -E cn && export { cn } from "cn" in lib/utils.ts
    
  5. Wire e2e types: Import RouterType from @scope/backend in src/lib/orpc.ts and create the client. No codegen step needed — TS resolves via workspace:*.
  6. Add Dockerfiles per app at apps/<app>/Dockerfile (file lives here, always build with repo-root context docker build -f apps/<app>/Dockerfile .; all Bun stages minimal + zero CVE — ghcr.io/philippdormann/bun:*, nginx:*-alpine*-slim; see §12) + Caddyfile + docker-compose.yml.
  7. Add release-it per app (tagName: "<app>-${version}") and a CI job per app filtered on ^<app>-\d+\.\d+\.\d+$, gated by an audit stage 0 running bun audit with needs: [audit] on every build job (see §12 Security).
  8. Add Postgres as default DB — use Bun's built-in Bun.SQL (SQL from "bun"), no external driver (pg/postgres/mysql2 not needed) — see Bun SQL docs. Wire Drizzle v1.0.0-rc.5+ via drizzle-orm/bun-sql (postgres:*-alpine, dialect: "postgresql" — see §9, pin drizzle-orm + drizzle-kit to the same RC) and avoid MySQL/MariaDB for new code.
  9. Add Valkey for cache/queues (valkey/valkey:*-alpine, Redis-compatible — see §10): single valkey service in docker-compose.yml, Bun.RedisClient (Bun built-in, no ioredis) + BullMQ queues in backend, Valkey/Redis (SET NX PX) or Postgres lease as single-flight guard for Bun.cron jobs.
  10. Add S3 object storage — use Bun's built-in S3Client (new S3Client() from "bun"), no @aws-sdk/* / minio needed — see Bun S3 docs and §11. Create central apps/backend/src/lib/s3.ts with export const s3 = new S3Client({ bucket: process.env.S3_BUCKET! }) and reuse via s3.file() / s3.write() / .presign() (see §11).
  11. Add Capacitor integration for native shells if needed: capacitor.config.ts (webDir: "build"), bunx cap sync, version sync scripts, plugins via @capacitor/* (see §6.0).
  12. Add root scripts: check, lint:all via bun --filter.

15. Conventions & Gotchas

  • Timestamps: Store as UNIX seconds in DB, return UNIX seconds from backend, format in frontend (DD.MM.YYYY HH:mm default; DD.MM.YYYY without time; Europe/Berlin for mails). Avoid Date strings in the DB.
  • Asset URLs: Always built in the backend (presigned + cached via Bun S3Client — see §11). Frontends never interpolate storage paths or construct s3:// URLs.
  • UI components: Prefer shadcn-svelte where available (see §6.5) — do not re-implement button/card/dialog/table/sonner etc. when the registry provides them. Vendor via CLI, theme via components.json. For class merging use cn (import { cn } from "cn") — not clsx/tailwind-merge/cnfast.
  • Fonts: Use Fontsource self-hosted (@fontsource/* via bun add -E) — do not use Google Fonts CDN. Privacy-first, no external request, explicit subsets/weights (see §8 Fonts Policy).
  • Notifications (e.g. Telegram): Never include PII; link to profile/ID instead.
  • CORS: Central isOriginAllowed() used by both CORSHandlerPlugin instances (RPC + OpenAPI) and fallback 404 handler.
  • Auth: Validate JWT in middleware, cache resolved user in Valkey (auth:user:<id> or auth:user:<id>:tenant:<tenantId> for multi-tenant setups), invalidate on permission change. Guard against cross-user-type token reuse by checking usertype/role claims.
  • OpenAPI docs: OpenAPIReferenceHandlerPlugin with scalar + ValibotToJsonSchemaConverter (via OpenAPIGenerator) auto-derives the spec from the same .meta(openapi()) definitions — no manual spec.
  • No as assertions (except as const): No : any, as any, as unknown, as unknown as, as Record, as string, or other type assertions that kill type safety. Prefer discriminated unions, in / typeof narrowing, and Valibot inference (v.safeParse + parsed.output, v.InferOutput). Keep strict on.
  • Commits: Conventional commits with optional scope (fix(backend): ...), small commits, no generated migrations in PRs.
  • Confirm modals for critical interactions: Never use window.alert/confirm/prompt — use shadcn-svelte AlertDialog/Dialog (see §6.6). Every destructive/irreversible action requires explicit confirm modal with plain-language impact + count.
  • Non-technical user-facing texts by default: Default all UI copy to plain language (DE/EN), no jargon/IDs/codes in toasts or errors; map technical errors to friendly messages (see §6.6).
  • Bulk actions where useful: Lists/tables with per-item mutations must also expose multi-select + bulk bar + bulk confirm (see §6.6).

16. Further Reading

  • oRPC docs: https://orpc.dev/llms.txt and subpages (canonical reference for os, RPCHandler, OpenAPIHandler, RPCLink).
  • Svelte 5 runes: https://svelte.dev/docs/svelte/v5-migration-guide
  • shadcn-svelte (prefer where available — UI components via CLI, Tailwind 4 + Bits UI): https://www.shadcn-svelte.com/llms.txt (catalog: button, card, dialog, table, sonner, chart, data-table, etc.)
  • cn (class merging for Tailwind — use instead of clsx/tailwind-merge/cnfast in frontends): https://npmx.dev/package/cn (import { cn } from "cn")
  • Fontsource (self-hosted fonts — use instead of Google Fonts CDN, privacy-first): https://fontsource.org
  • Drizzle ORM v1 RC (we use rc.5+ — latest 0.45.x is legacy for this stack): https://orm.drizzle.team + v1.0.0-rc.5 release notes
  • bongo (preferred for plain docs / Markdown content sites — use instead of SvelteKit for docs-only sites, see §6.7): https://bongo.build/ + Quick start
  • Bun SQL (native driver, no pg/postgres/mysql2 needed): https://bun.sh/docs/runtime/sql
  • Bun S3 (native S3Client, no @aws-sdk/* needed — central new S3Client() per bucket): https://bun.sh/docs/runtime/s3
  • Release-it: https://github.com/release-it/release-it