| name | kana-monorepo-fullstack-typescript |
| description | Best-practices guide for building a moon + pnpm TypeScript monorepo with a Hono/oRPC/Drizzle/better-auth backend and a TanStack Router SPA frontend. Use when scaffolding a new full-stack app, adding a feature (use-case + router + route + UI), or reviewing code against the saas-boilerplate reference layout. |
Kana Monorepo Full-Stack TypeScript Skill
Reference stack (see https://github.com/kana-consultant/saas-boilerplate):
| Layer | Tech |
|---|
| Monorepo | moon + pnpm workspaces |
| Backend | Hono + oRPC (RPC + OpenAPI), Drizzle ORM, better-auth, ioredis |
| Frontend | React 19 + TanStack Router SPA (file-based) + Vite + Tailwind v4 + shadcn/ui |
| Data | TanStack Query + oRPC client, typed end-to-end via @saas/api types |
| Lint/format | Biome (tabs, double quotes, no semicolons) |
| Test | Vitest |
0. Before you scaffold — ASK
Before generating any code from this skill, stop and ask the user one question:
Will this app be multi-tenant (organization-scoped), or single-tenant?
The answer changes the scaffold materially. Do not guess. Default to asking even if the prompt looks obvious — a wrong assumption here costs a full refactor later.
If multi-tenant (default for this skill)
Keep everything below as written:
organization, member, role aggregates in domain/
$orgSlug.tsx + $orgSlug/ layout in routes/_authenticated/
_authenticated/org/ onboarding (create / select org)
- Org-role middleware (
requireRole, requirePermission)
member.organizationId FK with onDelete: "cascade" on every org-scoped table
orgRole on router context, ctx.orgRole in use-cases
- Two-layer role system (platform
super-admin + org owner|admin|member)
If single-tenant
Strip the following before scaffolding — do not leave dead code:
- Delete
domain/{organization,member,role}/ and their repos.
- Delete
routes/_authenticated/org/ and collapse $orgSlug.tsx + $orgSlug/ — promote its children directly under _authenticated/.
- Drop
orgRole from router context + AuthedContext; drop requireActiveOrg, assertOutranksTarget.
- Keep
requireRole but on the single user.role axis only (admin | user).
- Drop
organizationId FK columns and all WHERE organization_id = ... scopes.
- Drop the org-side of
better-auth plugins (organizationClient()); keep adminClient for platform-admin UX.
If unsure or "mixed" (most of app single-tenant, one feature org-scoped later)
Scaffold single-tenant now. Adding tenancy later is a well-defined migration (add organization, add FK on one table, add scope on one repo). Pre-building tenancy you never use is not.
1. Workspace layout
apps/
api/ # @saas/api — Hono backend
drizzle/ # generated SQL migrations (committed)
drizzle.config.ts
moon.yml
tsconfig.json # paths: "#/*": ["./src/*"]
src/
index.ts # type-only barrel: AppRouter, Session, AppRole, AppRouterClient
main.ts # Hono entry, wires deps, mounts /auth /rpc /api, optional SPA static
migrate.ts # standalone migration runner (prod entrypoint)
polyfill.ts
domain/ # pure types + ports (NO framework imports)
activity/ member/ organization/ role/ session/ user/
ports/ # cache.ts, auth-service.ts, ...
application/ # use-cases (pure functions of deps)
shared/ # context.ts, errors.ts, authorization.ts (+ tests)
user/ # ban-user.ts, create-user.ts, list-users.ts, set-role.ts, ...
role/ activity/ auth/
use-cases.ts # buildUseCases(deps) → single UseCases object
infrastructure/ # concrete adapters (drizzle, redis, better-auth, env)
auth/ # better-auth.ts, auth-service.ts
cache/ # redis.ts
config/ # env.ts (Zod-validated)
db/ # client.ts, schema.ts, seed.ts, repositories/
observability/ # logger.ts
presentation/
orpc/ # context.ts, middleware.ts, schemas.ts, error-mapping.ts
routers/ # index.ts + <aggregate>.ts (activity, auth, role, user, ...)
web/ # @saas/web — SPA
vite.config.ts # proxies /rpc /auth /api to :3001 in dev
tsconfig.json # paths: "#/*": ["./src/*", "../api/src/*"] (types-only!)
src/
main.tsx
router.tsx
routeTree.gen.ts # generated — NEVER edit, keep in biome ignore
styles.css
components/
ui/ # shadcn/ui primitives
{feature}/ # only if shared across routes; feature UI colocates in routes
hooks/ # global app-wide hooks (distinct from route-local `_hooks/`)
libs/
auth/ # better-auth react client + shared permissions
orpc/ # typed client (imports AppRouter from @saas/api)
tanstack-{query,form,table,db,store}/
clsx/ errors/ hooks/
paraglide/ posthog/ # OPTIONAL — add when needed
routes/
__root.tsx
index.tsx # landing
_public.tsx / _public/ # layout file + children folder (pair)
_authenticated.tsx / _authenticated/ # same pair pattern
_components/ # auth-scope shared UI
_data/ # auth-scope shared static/mock data
org/ # org onboarding (pre-$orgSlug)
$orgSlug.tsx / $orgSlug/ # org layout file + children folder
<feature>.tsx # default — feature as single file
<feature>/ # promote to folder when it grows
index.tsx
_apis/ _components/ _hooks/ # feature-local, scope by placement
.moon/
workspace.yml # projects: apps/*, vcs.defaultBranch
toolchain.yml # node + pnpm versions
tasks.yml # inherited: check, lint, format
pnpm-workspace.yaml # packages: apps/*
tsconfig.base.json # strict, verbatimModuleSyntax, allowImportingTsExtensions
biome.json # tabs + double quotes + asNeeded semicolons
docker-compose.yml # prod: single image
docker-compose.dev.yml # dev: postgres + redis
Rule: web imports types only from @saas/api. All runtime calls go over HTTP (/rpc or /api). Never import backend code into the frontend.
2. Backend — hexagonal (clean) architecture
2.1 domain/ — framework-free
One folder per aggregate: user/, member/, organization/, role/, session/, activity/, ports/.
- Entities: plain TS interfaces.
user/user-repository.ts: the repository interface lives here.
ports/: interfaces for external systems the domain needs (AuthService, Cache).
export interface User { id: string; name: string; email: string; role: string; banned: boolean; createdAt: Date }
export interface UserRepository {
listOrgMembers(organizationId: string): Promise<OrgMemberListing[]>
}
export interface Cache {
get<T>(key: string): Promise<T | null>
set(key: string, value: unknown, ttlSeconds: number): Promise<void>
del(...keys: string[]): Promise<void>
delPattern(pattern: string): Promise<void>
ping(): Promise<boolean>
}
Domain must not import from application/, infrastructure/, or presentation/.
2.2 application/ — use-cases via factories
Every use-case is makeX(deps) → (input, ctx) => Promise<Result>.
export interface BanUserInput { userId: string; banReason?: string }
export interface BanUserDeps { auth: AuthService; memberRepo: MemberRepository; activityRepo: ActivityRepository }
export function makeBanUser(deps: BanUserDeps) {
return async (input: BanUserInput, ctx: AuthedContext) => {
assertNotSelf(ctx.session.user.id, input.userId, "ban")
const activeOrgId = requireActiveOrg(ctx)
await assertOutranksTarget(deps.memberRepo, ctx.orgRole, input.userId, activeOrgId)
await deps.auth.banUser(input.userId, input.banReason, { headers: ctx.headers })
await deps.activityRepo.insert({ userId: ctx.session.user.id, organizationId: activeOrgId, action: "ban", resource: "user", resourceId: input.userId, metadata: { banReason: input.banReason } })
return { success: true as const }
}
}
application/shared/ holds cross-cutting: context.ts (AuthedContext, OptionalAuthContext, requireActiveOrg), errors.ts (typed AppError + factories), authorization.ts (assertNotSelf, assertOutranksTarget).
application/use-cases.ts wires everything into one buildUseCases(deps): UseCases — a single source of truth for what the presentation layer can call.
- All side-effects (auth, db, cache) go through deps — makes unit tests trivial with
vi.fn().
- Log audit events via
activityRepo.insert inside the use-case, not at the router.
2.3 application/shared/errors.ts — typed errors
export type AppErrorCode = "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" | "BAD_REQUEST" | "CONFLICT" | "INTERNAL_ERROR"
export class AppError extends Error {
readonly code: AppErrorCode
constructor(code: AppErrorCode, message: string) { super(message); this.code = code; this.name = "AppError" }
}
export const unauthorized = (m: string) => new AppError("UNAUTHORIZED", m)
export const forbidden = (m: string) => new AppError("FORBIDDEN", m)
The oRPC middleware maps AppError → ORPCError using the same code strings.
2.4 infrastructure/ — concrete adapters
One folder per capability: auth/, cache/, config/, db/, observability/.
db/client.ts: createDb(url) using drizzle-orm/node-postgres.
db/schema.ts: Drizzle schema, colocated.
db/repositories/*-repository.ts: create<Name>Repository(db): <Name>Repository — factory returns a plain object matching the domain interface.
auth/better-auth.ts: buildAuth({ db, activityRepo }) constructs the better-auth instance.
auth/auth-service.ts: adapts better-auth to the AuthService port.
cache/redis.ts: createRedisCache(url): Cache.
config/env.ts: Zod-validated env with loadEnv() that throws with a listed-issues message. Export env.
observability/logger.ts: pino.
const envSchema = z.object({
DATABASE_URL: z.string().min(1),
REDIS_URL: z.string().min(1).default("redis://127.0.0.1:6379"),
BETTER_AUTH_SECRET: z.string().min(16, "use `openssl rand -hex 32`"),
BETTER_AUTH_URL: z.string().url().default("http://localhost:3000"),
WEB_ORIGIN: z.string().url().default("http://localhost:3000"),
PORT: z.coerce.number().int().positive().default(3001),
WEB_DIST_PATH: z.string().optional(),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
})
2.5 presentation/ — oRPC
orpc/context.ts: ORPCContext { headers, session, orgRole, useCases }.
orpc/middleware.ts: publicProcedure (maps AppError → ORPCError), protectedProcedure (requires session), requireRole(...roles), requirePermission(resource, actions), shortcuts adminProcedure, ownerProcedure, platformSuperAdminProcedure. Plus toAuthedContext(ctx) helper.
orpc/error-mapping.ts: single source of truth for AppErrorCode → ORPCError code + HTTP status. Middleware imports the mapping here — do not inline status choices anywhere else.
orpc/schemas.ts: Zod input schemas reused across procedures.
routers/<aggregate>.ts: buildXRouter(useCases.x) — thin handlers that call use-cases. Flat file per aggregate (activity, auth, role, user, …), no sub-folders.
routers/index.ts: buildRouter(useCases) composing health, me, auth, admin: { ...user, ...role, ...activity }. Export type AppRouter.
export function buildUserRouter(useCases: UseCases["user"]) {
return {
listUsers: adminProcedure.handler(({ context }) => useCases.list(toAuthedContext(context))),
banUser: adminProcedure.input(banUserSchema).handler(({ input, context }) => useCases.ban(input, toAuthedContext(context))),
setRole: ownerProcedure.input(setRoleSchema).handler(({ input, context }) => useCases.setRole(input, toAuthedContext(context))),
}
}
2.6 main.ts — composition root
- Build infrastructure (
createDb, createRedisCache, buildAuth, createAuthService, each createXRepository).
buildUseCases({ ... }) once.
buildRouter(useCases).
- Hono app with:
requestId(), structured request logger, cors({ origin: WEB_ORIGIN, credentials: true }), /healthz, /ready (db + redis ping), /api/auth/* → auth.handler, /rpc/* → RPCHandler, /api/* → OpenAPIHandler (with SmartCoercionPlugin + OpenAPIReferencePlugin for docs).
- If
WEB_DIST_PATH set: serve SPA static + fall through to index.html (single-image prod deploy).
buildContext(headers) resolves session + orgRole via ts-pattern (super-admin bypasses to "owner").
2.7 Two-layer role system
- Platform role (
user.role): super-admin bypasses all org checks.
- Org role (
member.role): owner / admin / member — permissions stored in DB and editable from the UI via a permissions matrix.
3. Frontend
3.1 Routing
- TanStack Router, file-based,
autoCodeSplitting: true.
- Layout = file + sibling folder pair.
_public.tsx is the layout component (guard + <Outlet/>); _public/ holds children rendered inside it. Same for _authenticated.tsx/_authenticated/ and $orgSlug.tsx/$orgSlug/. Both must exist — the .tsx alone renders no children, the folder alone has no guard.
- Route hierarchy:
__root.tsx → (_public.tsx | _authenticated.tsx) → $orgSlug.tsx → feature.
- Features start as single files under
$orgSlug/ (e.g. users.tsx, dashboard.tsx). Promote to a folder (users/index.tsx + _apis/ _components/ _hooks/) when the single file outgrows readability. Do not pre-promote.
routeFileIgnorePattern: "^(_apis|_components|_data|_hooks)". Underscore folders are invisible to the router and may live at any layout level — scope is implicit from placement:
_authenticated/_components/, _authenticated/_data/ → auth-scope shared
$orgSlug/_apis/, $orgSlug/_hooks/ → org-scope shared
<feature>/{_apis,_components,_hooks}/ → feature-local
- Global (cross-layout) hooks and UI live at
src/hooks/ and src/components/ — not under routes/.
- Router context carries
{ queryClient, session, orgRole }; __root beforeLoad fetches session via the oRPC client.
_authenticated.tsx redirects to /auth/login when session is null.
- Page-level role gates in
beforeLoad (e.g. redirect non-admins from /users).
routeTree.gen.ts is generated — never edit, exclude from lint.
3.2 oRPC client (libs/orpc/client.ts)
import { createORPCClient } from "@orpc/client"
import { RPCLink } from "@orpc/client/fetch"
import { createTanstackQueryUtils } from "@orpc/tanstack-query"
import type { AppRouterClient } from "@saas/api"
const API_BASE = import.meta.env.VITE_API_URL || (typeof window !== "undefined" ? window.location.origin : "")
const link = new RPCLink({ url: `${API_BASE}/rpc`, fetch: (i, init) => fetch(i, { ...init, credentials: "include" }) })
export const client: AppRouterClient = createORPCClient(link)
export const orpc = createTanstackQueryUtils(client)
Usage: useQuery(orpc.admin.listUsers.queryOptions()) / useMutation(orpc.admin.banUser.mutationOptions()).
3.3 Auth client (libs/auth/client.ts)
export const authClient = createAuthClient({
baseURL: `${API_BASE}/api/auth`,
fetchOptions: { credentials: "include" },
plugins: [adminClient({ ac, roles }), organizationClient()],
})
Permissions matrix (ac, roles) is shared between frontend and backend (same source of truth).
3.4 TanStack Query
- Single
QueryClient via getQueryClient() singleton.
<TanStackQueryProvider> wraps app; devtools lazy-loaded in dev only.
- Prefer
useSuspenseQuery + queryOptions from loaders when data is required before render; useQuery for optional fetches.
3.5 Vite proxy
Dev proxies /api, /rpc → http://localhost:3001 so cookies work same-origin. In prod the Hono server serves the SPA directly from WEB_DIST_PATH (no CORS, no proxy).
3.6 Manual chunks
Split large vendors in vite.config.ts rollupOptions.output.manualChunks: posthog-js, @tabler/icons-react, zod, recharts+d3.
3.7 i18n (Paraglide)
?lang= query param drives locale. validateSearch on __root with Zod enum. Messages live in libs/paraglide/messages/. Generated code (libs/paraglide/generated/**) excluded from lint.
3.8 TanStack frontend conventions
Naming & prefixes
- Prefix
T for types (e.g. TUserItems, TCreateUserPayload), I for interfaces (e.g. IUserData), E for enums (e.g. EUserGender).
- Always use kebab-case for file and folder names (e.g.
user-profile.tsx, auth-guard.tsx).
Component organization
src/components/
├── ui/ # Reusable UI primitives (Button, Input, Card, Table)
├── features/ # Domain-specific composites (UserForm, AccountTable)
└── layout/ # Layouts, guards (DashboardLayout, ProtectedRoute)
State management
- Server state: TanStack Query (via oRPC
queryOptions / mutationOptions)
- Client state: TanStack Store with selectors
- Forms: TanStack Form + Zod validation
Query key factories
Every feature API must define a query key factory:
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (params?: TListParams) => [...userKeys.lists(), params] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
Response types
Standardize API response wrappers:
type TSingleResponse<T> = { data: T; message: string }
type TListResponse<T> = { data: T[]; pagination: TPaginationMeta }
Design principles
- Reusability-first: extract shared logic before the third call site.
- Single responsibility per file: one component, one hook, one service.
- High cohesion: group related logic together within modules.
- Low coupling: minimize dependencies between modules, use clean interfaces.
Rules
- Always use query key factories — never inline query keys.
- Invalidate queries on mutations.
- Separate types from implementation.
- Use barrel exports (
index.ts).
- Keep components pure when possible.
- Extract reusable logic to hooks.
- Use Zod for all form validation.
4. TypeScript conventions
tsconfig.base.json: strict, noUnusedLocals, noUnusedParameters, noFallthroughCasesInSwitch, verbatimModuleSyntax, allowImportingTsExtensions, noEmit.
- Always use
.ts/.tsx file extensions in relative imports (required by verbatimModuleSyntax + allowImportingTsExtensions).
- Path alias
#/* → project src/*. Web also aliases #/* to include ../api/src/* for type-only imports.
import type { ... } for pure type imports.
- Prefix
T for types, I for interfaces, E for enums (see §3.8).
- Prefer
interface for public shapes, type for unions/utility types.
- Strict TypeScript — no
any.
- Use
ts-pattern (match(x).with(...).otherwise(...)) for exhaustive branching on tagged unions or session shapes.
5. Database (Drizzle)
- Schema in
apps/api/src/infrastructure/db/schema.ts.
- Migrations generated into
apps/api/drizzle/, committed.
- Dev workflow:
pnpm db:push (direct schema apply).
- Prod workflow: edit schema →
pnpm db:generate → commit SQL → deploy runs apps/api/src/migrate.ts as a standalone entrypoint (not the server). Keeps migrations decoupled from app boot.
- Seeding:
infrastructure/db/seed.ts is the one authoritative seed script — invoked by pnpm db:seed. Idempotent; safe to re-run.
- CI enforces no drift: runs
drizzle-kit generate on every PR and fails if the diff is non-empty.
- Repositories never leak Drizzle types — return domain shapes.
6. Caching
Cache port in domain/ports/cache.ts; Redis adapter in infrastructure/cache/redis.ts.
- Keys are namespaced and lowercase-dashed:
user:default-org:${userId}, org:permissions:${orgId}:${role}.
- Invalidate inside mutation use-cases after the write completes (e.g.
cache.del(\user:default-org:${created.id}`)`).
- Never cache session state — better-auth owns that.
7. Testing
- Vitest, test files colocated as
*.test.ts next to source.
- Unit test use-cases by passing fake deps built with
vi.fn() — no DB, no HTTP.
- Follow the shape in
application/user/ban-user.test.ts: makeSession, makeCtx, makeDeps helpers, then one describe per factory with cases for happy path + each guard/error branch.
- Cross-cutting helpers (
authorization.ts, errors.ts) have their own .test.ts.
- E2E tests (if added) should live in
apps/web and hit a running backend — do not mock oRPC calls in e2e.
8. Lint / format (Biome)
- Tabs, double quotes, semicolons
asNeeded.
recommended: true; disable useExhaustiveDependencies and noArrayIndexKey.
- Ignore generated files:
apps/*/src/routeTree.gen.ts, apps/*/src/styles.css, apps/web/src/libs/paraglide/generated/**.
- Override rules for
main.ts, better-auth.ts, seed.ts, polyfill.ts to allow noNonNullAssertion + noExplicitAny.
- Shadcn primitives (
apps/web/src/components/ui/**): allow noDocumentCookie and noDangerouslySetInnerHtml.
9. Moon tasks
Root .moon/tasks.yml defines inherited check, lint, format (Biome). Each project's moon.yml declares dev/build/test and persistent tasks. API adds db-generate, db-migrate, db-push, db-studio, db-seed. Web dependsOn: ['api'] so api types are available first.
Root package.json scripts are thin wrappers: moon run :dev, moon run :build, etc.
10. Dev environment
- Mac/Linux:
devenv.sh + direnv auto-starts Postgres + Redis on shell enter.
- Windows:
scripts/setup-windows.ps1 runs Docker Compose (docker-compose.dev.yml) for Postgres + Redis, creates .env.local with generated BETTER_AUTH_SECRET, installs deps, pushes schema. Safe to re-run.
- VS Code Dev Container option works on any OS.
Required env: DATABASE_URL, REDIS_URL, BETTER_AUTH_URL, BETTER_AUTH_SECRET, WEB_ORIGIN. Optional: VITE_API_URL (only when web + api are on different origins in prod), GOOGLE_CLIENT_ID+SECRET (must be both-set-or-both-empty), VITE_POSTHOG_KEY, WEB_DIST_PATH.
11. Production deploy
- Single-image path (recommended): multi-stage Dockerfile builds web SPA, ships api image with
WEB_DIST_PATH pointing at the built dist/. Hono handles /rpc, /auth, /api, and falls through to index.html.
- Split deploy: deploy web + api separately and set
VITE_API_URL at web build time; ensure WEB_ORIGIN on api matches for CORS.
12. Adding a new feature — standard workflow
To add e.g. "projects" (an org-scoped entity):
- Domain:
domain/project/project.ts (entity), domain/project/project-repository.ts (interface).
- Schema: add Drizzle table in
infrastructure/db/schema.ts.
- Repository:
infrastructure/db/repositories/project-repository.ts implementing the interface.
- Use-cases:
application/project/{list,create,update,delete}-project.ts as makeX(deps) factories. Colocate tests.
- Wire: add
projectRepo to Dependencies + project: { list: makeListProjects(...) } in application/use-cases.ts.
- Presentation: add Zod input schemas to
presentation/orpc/schemas.ts, add presentation/routers/project.ts with buildProjectRouter(useCases.project), spread it into admin in routers/index.ts.
- Composition root: add
createProjectRepository(db) in main.ts and pass into buildUseCases.
- Migration:
pnpm db:generate, commit the generated SQL, run pnpm db:push locally.
- Frontend route:
apps/web/src/routes/_authenticated/$orgSlug/projects.tsx using useQuery(orpc.admin.listProjects.queryOptions()) + a colocated ../_components/projects-data-table.tsx.
- Role gate: enforce in
beforeLoad on the page and (authoritatively) with adminProcedure/requirePermission(...) on the router.
13. Non-negotiable rules
- Web imports types only from
@saas/api. Any attempt to import a runtime symbol = bug.
- Domain stays framework-free. No
drizzle-orm, hono, better-auth, or ioredis imports in domain/.
- Use-cases take deps as a parameter — never import
db, redis, or auth directly.
- All inputs validated with Zod at the oRPC boundary; env validated with Zod at startup.
- Authorization at two places:
beforeLoad in the route (UX) AND requireRole/requirePermission on the procedure (authoritative).
- Audit log mutations via
activityRepo.insert inside the use-case.
- Invalidate cache in the same use-case that performs the write.
- Never edit
routeTree.gen.ts or the paraglide/generated/ folder.
- Migrations are source-controlled —
db:push is dev-only; prod uses generated SQL.
- Use
.ts extensions in relative imports. Biome: tabs, double quotes, semicolons-as-needed.