Skip to content
Docs just relaunched - explore the new sidebar, OG images, and AI-ready content.
Build With SyntaxKit

Working With The Codebase

Conventions, patterns, and how to extend the starter.

Last updated on

6 min read

SyntaxKit 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.

How Type Safety Flows

The same Zod schema validates a form, types the oRPC procedure, lines up with the Prisma client, and feeds the TanStack Query hook all the way back to the React component.

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

TopicConventionExample
File nameskebab-caselogin-form.tsx, user-locale-form.tsx
Component namesPascalCase exportsLoginForm, UserLocaleForm
Unit tests*.test.ts(x) co-locatedstorage.test.ts next to storage.ts
Integration tests*.integration.test.tsbootstrap-admin.integration.test.ts
Live tests*.live.test.ts (or .live.integration.test.ts)webhook.live.test.ts
oRPC proceduresOne file per namespacepackages/api/src/router/user.ts exports getUserSession, updateUserName, etc.
Generated filesauth.generated.prisma, packages/database/generated/Regenerated via pnpm auth:generate / pnpm db:generate
Path aliases@/* for app root, @syntaxkit/ui/* for UI sourceimport { foo } from "@/lib/orpc"
Type importsconsistent-type-imports (warn)import type { Router } from "@syntaxkit/api/client"
Form libraryReact Hook Form + Zod resolveruseForm({ 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:

PatternWhat it buys you
dependsOn: ["^db:generate"] on build, dev, integration / live / e2ePrisma 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, e2eLive work runs fresh every time. build and test:run still cache aggressively.
Per-task env allowlistA 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.

LayerCommandFile patternWhen to use
Unitpnpm test:run*.test.ts(x) co-locatedPure logic, mocked dependencies
Integrationpnpm test:integration*.integration.test.tsReal Prisma client + a test database
Stripe livepnpm test:stripe (sets RUN_STRIPE_LIVE=1)*.live.test.ts, *.live.integration.test.tsReal Stripe test-mode API calls
End-to-endpnpm test:e2eapps/web/e2e/*.spec.tsFull 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.

proxy.tsSecurity headers, CORS, auth gates, and locale routing. Edit here to auth-gate a route, allow-list CORS, or exclude a path from i18n. Next.js 16 auto-loads it as the renamed middleware entry point.
instrumentation.tsServer OpenTelemetry logger provider, plus seeding globalThis.$client for in-process oRPC on the Node runtime. register() runs once on cold start.
instrumentation-client.tsPostHog browser init, gated on isAnalyticsEnabled. Skipped entirely when PostHog env vars are unset, so a fresh clone ships zero analytics to the browser.
setupThe validated env catalog and its read seams: getServerEnv()/requireServerEnv() for server code, @syntaxkit/shared/client (publicEnv, isAnalyticsEnabled) for the browser.

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
  1. Add it to apps/web/.env.example with a descriptive comment.
  2. Extend SetupEnv (and the relevant SetupCapabilities block) in packages/shared/src/setup.ts so the kit's capability table knows about it.
  3. If apps/web reads it server-side, expose a typed accessor in apps/web/lib/env.server.ts. If the browser reads it (only NEXT_PUBLIC_*), expose it in apps/web/lib/env.ts.
  4. Add it to the relevant Turbo task's env allowlist in turbo.json so caches invalidate correctly when the value changes.
  5. Run pnpm setup:doctor to confirm the doctor reports it.
Adding a new workspace package
  1. Mirror the smallest existing package; packages/i18n is a clean reference. Set name: "@syntaxkit/your-package", private: true, type: "module", and define the exports map.
  2. tsconfig.json extends @syntaxkit/typescript-config/base.json (or react-library.json if it ships JSX).
  3. Add lint and check-types scripts that mirror an existing package.
  4. In any consumer's package.json, add "@syntaxkit/your-package": "workspace:*".
  5. Run pnpm install once. 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

Was this page helpful?

On this page