Skip to content
Build With SyntaxKit

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 read

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

A Zod schema feeds form validation, the oRPC API contract, and the Prisma data model, then closes the loop through React and TanStack Query.

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 (contactClientSchemaorpc.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

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.tsroute.live.test.ts next to the Stripe webhook route
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, 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:

PatternWhat it buys you
dependsOn: ["^db:generate"] on build, dev, integration / live / e2ePrisma client stays up to date before typed code runs.
cache: false on dev, every db:*, integration, live, e2eLive work runs fresh. build and test:run still cache.
Per-task env allowlistA 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.

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 loads it as the renamed middleware entry point.
instrumentation.tsServer OpenTelemetry logger provider (when analytics is enabled). register() also imports lib/orpc.server.ts, which seeds globalThis.$client for in-process oRPC on the Node runtime.
instrumentation-client.tsPostHog browser init, gated on isAnalyticsEnabled. Skipped 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.

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 liveRUN_STRIPE_LIVE=1 pnpm test:stripe*.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

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
  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 capability table knows about it.
  3. If apps/web needs an app-level accessor, add it in apps/web/lib/env.server.ts (server) or re-export from apps/web/lib/env.ts for NEXT_PUBLIC_* via @syntaxkit/shared/client. Prefer getServerEnv() / requireServerEnv() from @syntaxkit/shared for validated server reads.
  4. Add it to the relevant Turbo task's env allowlist in turbo.json so caches invalidate 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. 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

Also useful: Authentication, Internationalization, and Setup.

Was this page helpful?

On this page