Conventions
How to name files, flow types from a Zod schema to a React component, write tests, and extend the starter. Quick reference above the subsystem guides.
Last updated on
6 min readOn This Page
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.
Forms follow the same shape end to end: a Zod schema in apps/web/lib/schemas/ or @syntaxkit/shared, React Hook Form + the Zod resolver, then either an oRPC mutation or the Better Auth client. The marketing contact form is the reference for the oRPC path (contactClientSchema → orpc.contact.submit.mutationOptions). Sign-in and sign-up use Better Auth (signIn.email, signUp.email) because it owns cookies and redirects; the form library is still RHF + Zod. See Authentication.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
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());
return (
<form onSubmit={form.handleSubmit((data) => mutation.mutate(data))}>
{/* fields */}
</form>
);
}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 | route.live.test.ts next to the Stripe webhook route |
| 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, so index access (arr[0], obj[key], regex match[1]) is typed T | undefined.
pnpm install does not auto-run prisma generate. After a fresh clone or after editing any *.prisma file, run pnpm db:generate manually. dev, build, and the integration / live / e2e pipelines declare a Turbo dependency on ^db:generate, so they regenerate when those tasks run.
Build pipeline
Turborepo orchestrates scripts that cross package boundaries. Three patterns matter day to day:
| Pattern | What it buys you |
|---|---|
dependsOn: ["^db:generate"] on build, dev, integration / live / e2e | Prisma client stays up to date before typed code runs. |
cache: false on dev, every db:*, integration, live, e2e | Live work runs fresh. build and test:run still cache. |
Per-task env allowlist | A Stripe key change invalidates only the live-test cache. |
Root scripts you will use most: pnpm dev, pnpm docs:dev, pnpm test:run, pnpm test:integration, pnpm test:stripe, pnpm test:e2e, plus the pnpm db:* family.
Cross-cutting files
A few files wire global behavior. Most work never touches them.
Read env through those seams instead of process.env; a lint rule enforces it for validated server vars. See Environment Variables.
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 | RUN_STRIPE_LIVE=1 pnpm test:stripe | *.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 |
pnpm test:stripe runs the live Turbo pipeline against .env.test; suites skip unless RUN_STRIPE_LIVE=1 and real Stripe test keys are set. Live and e2e disable Upstash abuse throttling (DISABLE_ABUSE_PROTECTION=true) so runs are deterministic. That flag is hard-blocked at boot in production. Better Auth's per-route rate limits widen automatically under NODE_ENV=test.
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. Mirror that pattern for any new oRPC procedure test that crosses workspace packages.
Common Workflows
Five recipes every developer hits in the 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 capability table knows about it. - If
apps/webneeds an app-level accessor, add it inapps/web/lib/env.server.ts(server) or re-export fromapps/web/lib/env.tsforNEXT_PUBLIC_*via@syntaxkit/shared/client. PrefergetServerEnv()/requireServerEnv()from@syntaxkit/sharedfor validated server reads. - Add it to the relevant Turbo task's
envallowlist inturbo.jsonso caches invalidate 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. For a typed query, prefetch with orpc.<namespace>.<procedure>.queryOptions(input) on the server and consume the same key with useSuspenseQuery in a client component. See the API page for the 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 from en.json. Use useTranslations("Namespace") in client components or getTranslations("Namespace") in server components.
Adding a new oRPC procedure
The API page is the canonical 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; consumer types come from @syntaxkit/api/client.
Where To Go Next
API
The oRPC router, middleware chain, and the Adding-a-Procedure walkthrough.
Database
The Prisma schema and client every typed procedure reads through.
Also useful: Authentication, Internationalization, and Setup.
Project Structure
How the apps and packages in the SyntaxKit pnpm + Turborepo monorepo are organized. apps/ holds deployable surfaces; packages/ holds the shared product modules. Use this page as the visual map; deeper subsystem guides live under Build With SyntaxKit.
Database
PostgreSQL via Prisma in packages/database. Point DATABASE_URL at a hosted provider or Docker; the client picks the driver adapter. Schema layout, migrations, seeding, and how the models connect.
