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:
| Path | Owner | Edited by |
|---|---|---|
src/core/ | Template | Template maintainers (PRs upstream) |
src/modules/ | Project | You (freely) |
src/shared/ | Both | Template maintainers; types must stay back-compat |
The boundary
src/core/is the synchronised template area. Don't edit it casually. Improvements should land upstream viabun run sync:to-templateso 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 insrc/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>/:
- Model —
prisma/features/<resource>.prisma(orprisma/schema.prismafor project-required models). Runbun run prepare:schema && bunx prisma migrate devto apply. - Service —
src/modules/<resource>/<resource>.service.ts. InjectPrismaService, the relevant repo helpers,PermissionService, anything else fromsrc/core/. - Controller —
src/modules/<resource>/<resource>.controller.ts. Use@Can()fromsrc/core/permissions/can.guard.tsto gate handlers; use Zod schemas fromsrc/core/validation/for DTOs. - Module —
src/modules/<resource>/<resource>.module.ts. Register the service + controller; export anything other modules might inject. - 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:
| Path | Owner | Survives sync? |
|---|---|---|
src/modules/branding/brand.json | Project (committed) | Yes |
src/core/branding/brand.default.json | Template | No (synced upstream) |
Lookup precedence: project overlay → template default → schema built-in.
Editing the brand
Three options, in order of safety:
- Through the UI at
/hub/brand— the page reads/hub/brand.jsonand writes the project overlay viaPOST /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. - Direct edit of
src/modules/branding/brand.json. The file is in git — review-friendly diff, no surprise rollbacks. - 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:rootoverrides injected by the shell HTML before the statictokens.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 emails —
Barebonelayout readsbrand.primaryColorfor the dot logo + CTA buttons,brand.legalEntity+brand.supportEmailfor the footer. The legacy EJS templates take the same brand object. - OpenAPI —
info.title = brand.name,info.description = brand.tagline. Scalar UI and any kubb-generated SDK pick this up automatically. - Better-Auth — TOTP
issuer+ PasskeyrpNamefollowbrand.nameso authenticator apps and WebAuthn prompts say "Acme" instead of the template default. - EmailService —
defaultFromprecedence 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 installandbun run devflow 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.