Working With The Codebase
Conventions, patterns, and how to extend the starter.
Last updated on
6 min readSyntaxKit isn't just code. It's a set of conventions: one way to name files, one way to flow types from a Zod schema to a React component, one way to write tests, one way to add a new env var. This page is your quick reference for those choices, sitting above the subsystem guides.
One schema, many guarantees
How a single Zod schema types the form, the API, and the database at once.
Predictable conventions
Naming, path aliases, generated files, and the form pattern.
Four testing layers
Unit, integration, Stripe live, and end-to-end - each with its own command.
Extend by convention
Recipes for env vars, packages, pages, translations, and procedures.
How Type Safety Flows
One schema, four type guarantees. The same Zod object validates the form submission, types the mutation arguments, types the procedure's input parameter, and (after Prisma generates) lines up with what the database expects. Change a field, every layer's TypeScript catches the drift.
packages/api/src/client.ts only re-exports the Router type, not the implementation. That keeps server-side code (Prisma, Better Auth, Stripe SDK) out of the browser bundle. Consumers import type { Router } from "@syntaxkit/api/client" and get the contract without the cost.
The diagram source lives at apps/docs/diagrams/typesafety-flow.mmd. Rerun pnpm --filter @syntaxkit/docs diagrams:build after editing it to refresh both SVG variants.
Conventions At A Glance
| Topic | Convention | Example |
|---|---|---|
| File names | kebab-case | login-form.tsx, user-locale-form.tsx |
| Component names | PascalCase exports | LoginForm, UserLocaleForm |
| Unit tests | *.test.ts(x) co-located | storage.test.ts next to storage.ts |
| Integration tests | *.integration.test.ts | bootstrap-admin.integration.test.ts |
| Live tests | *.live.test.ts (or .live.integration.test.ts) | webhook.live.test.ts |
| oRPC procedures | One file per namespace | packages/api/src/router/user.ts exports getUserSession, updateUserName, etc. |
| Generated files | auth.generated.prisma, packages/database/generated/ | Regenerated via pnpm auth:generate / pnpm db:generate |
| Path aliases | @/* for app root, @syntaxkit/ui/* for UI source | import { foo } from "@/lib/orpc" |
| Type imports | consistent-type-imports (warn) | import type { Router } from "@syntaxkit/api/client" |
| Form library | React Hook Form + Zod resolver | useForm({ resolver: zodResolver(schema) }) |
TypeScript runs with noUncheckedIndexedAccess: true across every package. Index access (arr[0], obj[key], regex match[1]) is typed T | undefined. Defensive checks are the norm; the compiler catches forgotten ones at build time.
pnpm install does not auto-run prisma generate. After a fresh clone or after editing any *.prisma file, run pnpm db:generate manually. The trade-off is faster installs at the cost of one extra command after schema changes. dev, build, and the integration-test pipelines all declare a Turbo dependency on ^db:generate, so they regenerate automatically when those tasks run.
The Build Pipeline
Turborepo orchestrates every script that crosses package boundaries. Three patterns matter day to day:
| Pattern | What it buys you |
|---|---|
dependsOn: ["^db:generate"] on build, dev, integration / live / e2e | Prisma client is always up to date before typed code runs. No "type errors disappear when I rerun" surprises. |
cache: false on dev, every db:*, integration, live, e2e | Live work runs fresh every time. build and test:run still cache aggressively. |
Per-task env allowlist | A Stripe key change invalidates only the live-test cache. Nothing over-invalidates the world. |
The most-used scripts at the root: pnpm dev, pnpm docs:dev, pnpm test:run, pnpm test:integration, pnpm test:stripe, pnpm test:e2e, plus the pnpm db:* family.
Forms
Every form in the kit follows the same shape: a Zod schema in apps/web/lib/schemas/ or @syntaxkit/shared, React Hook Form for state, the Zod resolver to wire validation, and either an oRPC mutation (for typed procedures) or the Better Auth client (for signIn / signUp). The contact form is a clean reference because it covers all three layers at once:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { isDefinedError } from "@orpc/client";
import {
contactClientSchema,
type ContactClientFormData,
} from "@syntaxkit/shared";
import { orpc } from "@/lib/orpc";
export function ContactForm() {
const form = useForm<ContactClientFormData>({
resolver: zodResolver(contactClientSchema),
});
const mutation = useMutation(
orpc.contact.submit.mutationOptions({
onError: (error) => {
if (isDefinedError(error)) toast.error(error.message);
else toast.error("Something went wrong");
},
onSuccess: () => toast.success("Sent"),
})
);
return (
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))}>
{/* fields */}
</form>
);
}Sign-in and sign-up use the Better Auth client (signIn.email, signUp.email) instead of an oRPC mutation, because Better Auth owns the cookie and redirect semantics. The form library and validation pattern are still React Hook Form + Zod; see Authentication for the full flow.
Testing Layers
Four layers, each with its own command and naming convention.
| Layer | Command | File pattern | When to use |
|---|---|---|---|
| Unit | pnpm test:run | *.test.ts(x) co-located | Pure logic, mocked dependencies |
| Integration | pnpm test:integration | *.integration.test.ts | Real Prisma client + a test database |
| Stripe live | pnpm test:stripe (sets RUN_STRIPE_LIVE=1) | *.live.test.ts, *.live.integration.test.ts | Real Stripe test-mode API calls |
| End-to-end | pnpm test:e2e | apps/web/e2e/*.spec.ts | Full browser flow via Playwright |
The live and e2e suites disable Upstash abuse throttling (DISABLE_ABUSE_PROTECTION=true) so runs are deterministic. That flag is hard-blocked at boot in production. Better Auth's own per-route rate limits widen automatically under NODE_ENV=test, so no separate env var is needed for the test suites to bypass them.
The canonical mocking pattern lives in packages/api/src/router/storage.test.ts: vi.hoisted to declare mocks before the system-under-test imports them, then vi.mock for @syntaxkit/auth / @syntaxkit/storage, plus vi.importActual to keep parts of @syntaxkit/shared real. Mirror that pattern for any new oRPC procedure test that crosses workspace packages.
Cross-Cutting Concerns
A few files wire up global behavior. Most developers won't touch them, but knowing they exist saves an hour of "where does this happen?" hunting.
Read env through those seams instead of process.env; a lint rule enforces it for validated server vars. See Environment Variables.
Common Workflows
Five recipes every developer will hit within their first week. Expand the one you need.
Adding a new env var
- Add it to
apps/web/.env.examplewith a descriptive comment. - Extend
SetupEnv(and the relevantSetupCapabilitiesblock) inpackages/shared/src/setup.tsso the kit's capability table knows about it. - If
apps/webreads it server-side, expose a typed accessor inapps/web/lib/env.server.ts. If the browser reads it (onlyNEXT_PUBLIC_*), expose it inapps/web/lib/env.ts. - Add it to the relevant Turbo task's
envallowlist inturbo.jsonso caches invalidate correctly when the value changes. - Run
pnpm setup:doctorto confirm the doctor reports it.
Adding a new workspace package
- Mirror the smallest existing package;
packages/i18nis a clean reference. Setname: "@syntaxkit/your-package",private: true,type: "module", and define theexportsmap. tsconfig.jsonextends@syntaxkit/typescript-config/base.json(orreact-library.jsonif it ships JSX).- Add
lintandcheck-typesscripts that mirror an existing package. - In any consumer's
package.json, add"@syntaxkit/your-package": "workspace:*". - Run
pnpm installonce. Turbo's task graph picks the new package up automatically.
Adding a new dashboard page
Create the route under apps/web/app/dashboard/<your-feature>/page.tsx. The dashboard shell already prefetches per-org data, hydrates TanStack Query, and reads the locale from the cookie, so you don't reimplement any of that. For a typed query, server-side prefetch with orpc.<namespace>.<procedure>.queryOptions(input) and render a client component that consumes the same key with useSuspenseQuery. See the API page for the canonical RSC-prefetch + hydrate pattern.
Adding a new translation key
Add the key under the right namespace in apps/web/messages/en.json (the canonical shape). Add the same key in apps/web/messages/de.json. Skipping it is a build-time type error because apps/web/global.d.ts declares Messages: typeof messages from en.json. Use the key via useTranslations("Namespace") in client components or getTranslations("Namespace") in server components.
Adding a new oRPC procedure
The API page is the canonical eight-step walkthrough: pick a router file, define Zod schemas, choose a procedure base (base / authorized / withActiveOrganization), declare the route, implement the handler, wire into the root router, consume from React. The Router type updates automatically, and consumer types come from the bundle-isolating @syntaxkit/api/client re-export.
Where To Go Next
Database
The Prisma schema and client every typed procedure reads through.
API
The oRPC router, middleware chain, and the Adding-a-Procedure walkthrough.
Authentication
Better Auth setup, the session shape, and the auth-form pattern that complements the form snippet on this page.
Internationalization
The dual-strategy locale routing that proxy.ts orchestrates.
Setup
The full env-var matrix that the Adding-a-new-env-var workflow plugs into.
