Customization Guide

May 6, 2026 · View on GitHub

This server is a template. Many projects share the same core; each project adds its own resources on top. The split lives in two folders:

PathOwnerEdited by
src/core/TemplateTemplate maintainers (PRs upstream)
src/modules/ProjectYou (freely)
src/shared/BothTemplate maintainers; types must stay back-compat

The boundary

  • src/core/ is the synchronised template area. Don't edit it casually. Improvements should land upstream via bun run sync:to-template so every project benefits — see the Core-Contribution-Guide.
  • src/modules/ is the project-specific area. Add your domain models, services, controllers, jobs, and integrations here. Nothing in src/modules/ is touched by the template sync.

Activating optional features

The template ships every feature behind a flag. Edit src/config/features.ts (written by bun run setup) — it imports FeaturesSchema from the core and parses your selection at boot, so a typo is caught before any code runs:

import { FeaturesSchema } from '../core/features/features.js';

export const features = FeaturesSchema.parse({
  multiTenancy: { enabled: true },
  email: { enabled: true, provider: 'brevo' },
  webhooks: { enabled: true },
  search: { enabled: false },
  // …
});

Toggle a feature off — its module is not loaded, its env vars aren't required, its Prisma models stay out of the schema. Footprint zero.

Adding a new resource

A new resource lives entirely in src/modules/<resource>/:

  1. Modelprisma/features/<resource>.prisma (or prisma/schema.prisma for project-required models). Run bun run prepare:schema && bunx prisma migrate dev to apply.
  2. Servicesrc/modules/<resource>/<resource>.service.ts. Inject PrismaService, the relevant repo helpers, PermissionService, anything else from src/core/.
  3. Controllersrc/modules/<resource>/<resource>.controller.ts. Use @Can() from src/core/permissions/can.guard.ts to gate handlers; use Zod schemas from src/core/validation/ for DTOs.
  4. Modulesrc/modules/<resource>/<resource>.module.ts. Register the service + controller; export anything other modules might inject.
  5. Wire — add the module to AppModule (or a sub-module that already aggregates project modules).

The src/core/ codebase already provides PrismaService, PermissionService, the output pipeline, the request-context middleware, the rate-limiter, the audit-log helpers, and the realtime/webhook integrations — everything a typical resource needs. You shouldn't be reaching outside src/modules/ for domain code.

Branding

The look-and-feel of every Dev-Portal page, every transactional email, and the OpenAPI document title is driven by a single brand config — a JSON file under the source tree. Two files cooperate:

PathOwnerSurvives sync?
src/modules/branding/brand.jsonProject (committed)Yes
src/core/branding/brand.default.jsonTemplateNo (synced upstream)

Lookup precedence: project overlay → template default → schema built-in.

Editing the brand

Three options, in order of safety:

  1. Through the UI at /hub/brand — the page reads /hub/brand.json and writes the project overlay via POST /hub/brand. The dev runner's watcher detects the file change and restarts the API so OpenAPI title, Better-Auth issuer, and email defaults pick up the new values.
  2. Direct edit of src/modules/branding/brand.json. The file is in git — review-friendly diff, no surprise rollbacks.
  3. Reset with POST /hub/brand/reset (or delete the file manually). Falls back to the template default.

Schema (subset)

{
  "name": "Acme",
  "shortName": "acme",
  "tagline": "Acme runs on this server",
  "primaryColor": "#ff00aa",
  "primaryColorInk": "#ffffff",
  "backgroundColor": "#020203",
  "surfaceColor": "#06070a",
  "textColor": "#e4e4e7",
  "mutedTextColor": "#71717a",
  "fromEmail": "no-reply@acme.test",
  "legalEntity": "Acme Holdings GmbH",
  "supportEmail": "support@acme.test",
  "supportUrl": "https://acme.test/support"
}

Hex colors must be 6-digit (#RRGGBB); shorthand #fff is rejected for deterministic CSS-var generation. Email and URL fields are validated by the same Zod schema (src/core/branding/brand-schema.ts) on every load and on every POST /hub/brand write.

Where the brand surfaces

  • Dev-Portal SPA--accent, --bg, --surface-1, --fg, --fg-dim :root overrides injected by the shell HTML before the static tokens.css. The brand name appears in <title> and in the sidebar. window.__BRAND__ is inlined for the SPA so the first paint is correct.
  • Transactional emailsBarebone layout reads brand.primaryColor for the dot logo + CTA buttons, brand.legalEntity + brand.supportEmail for the footer. The legacy EJS templates take the same brand object.
  • OpenAPIinfo.title = brand.name, info.description = brand.tagline. Scalar UI and any kubb-generated SDK pick this up automatically.
  • Better-Auth — TOTP issuer + Passkey rpName follow brand.name so authenticator apps and WebAuthn prompts say "Acme" instead of the template default.
  • EmailServicedefaultFrom precedence is env (SMTP_FROM) → brand.fromEmail → final placeholder.

Service credentials are NOT in brand.json

SMTP_*, BREVO_API_KEY, MAXMIND_LICENSE_KEY, etc. live in .env (gitignored). The brand file is committed in git — putting secrets there would leak them on every push. Edit credentials in .env directly; the dev runner watches that file too and restarts on save.

Project-specific environment variables

Add them to .env.example and parse them via src/core/config/env.ts if you want them validated alongside the framework's own env. Project-local config modules live in src/modules/<resource>/<resource>.config.ts.

Prisma in pnpm-hoisted monorepos

lt fullstack init --next scaffolds the API under projects/api/ next to a Nuxt app and a shared package. pnpm hoists shared dependencies to the workspace-root node_modules/, which breaks Prisma 7 in driver-adapter mode if you do nothing.

The mechanic: @prisma/client/default.js ships as a forwarding shim that does require('.prisma/client/default'). Node walks node_modules/ directories upward from @prisma/client looking for .prisma/client/default.js. The Prisma generator writes that file under the consuming package's node_modules/.prisma/client/. In the hoisted layout @prisma/client lives at the workspace root, so the upward walk never reaches projects/api/node_modules/.prisma/ and bun run dev crashes with Cannot find module '.prisma/client/ default'. Tests pass because vitest resolves modules from projects/api/, hiding the issue from CI.

The template fixes this with scripts/postinstall-prisma-symlink.ts, wired into both postinstall and prisma:generate. The script:

  • locates the first ancestor node_modules/ above the package root (the workspace root in a pnpm layout, none in a single-package checkout)
  • creates a symlink <workspace-root>/node_modules/.prisma → <package-root>/node_modules/.prisma, completing the upward resolution chain
  • is idempotent (no-op if the parent already resolves), safe (refuses to clobber a real directory at the target path), and layout-agnostic (works for projects/api/, packages/api/, or any monorepo nesting)
  • gracefully no-ops in single-package mode — the template's own bun install and bun run dev flow is unchanged

Pure planner + thin runner: src/core/setup/prisma-client-symlink.ts and …/prisma-client-symlink-runner.ts. Tests in tests/stories/prisma-client-symlink.story.test.ts exercise the hoisted layout against the real filesystem (mkdtemp + symlink) so the fix can't silently regress.

If the postinstall ever surfaces refusing to clobber real directory, inspect the contents of <workspace-root>/node_modules/.prisma (it likely came from a stale pnpm install against an older Prisma version), remove it, and re-run bun run prisma:generate.

When you do need a core change

It happens — a generic capability you're tempted to copy-paste between projects. The right move is a PR to the template; see Core-Contribution-Guide. Editing src/core/ in place works for an emergency hotfix, but the next sync:from-template will overwrite it. Record the divergence in OPEN_QUESTIONS.md so future-you sees it.