Skip to content
Back to Build Tutorials

Fullstack Wiring

Connect the frontend, API, and database so a button click round-trips to the DB and back.

Why

The 'fullstack wiring' is the moment a product becomes real — a user clicks, data flows to the DB, and a result appears on screen. Before this, you have a pretty UI mockup and a DB schema, but no product. The wiring is where most bugs live: the frontend sends the wrong field name, the API doesn't validate, the ORM silently drops a column, and the user sees a generic error. A disciplined wiring uses end-to-end type safety (frontend types generated from the API schema, API types from the DB schema), so a rename is a compile error across the whole stack. Without it, the three layers drift and every integration is a debugging session.

When

After the UI and DB schema exist, before the first user feature. Takes 2-4 h. Skip only for pure static sites.

How

1. Generate API types from the server and import them in the client
   - tRPC: types flow automatically from server router to client
   - REST: openapi-typescript generates types from the OpenAPI spec
   - Never hand-write client types that duplicate the server's

2. Use an ORM with typed queries (Prisma, Drizzle) so DB results are typed
   - prisma.user.findUnique returns a typed User, not any
   - Schema changes → prisma generate → type errors in every broken query

3. Wire a single feature end-to-end first as the template
   - Button → fetch /api/items → prisma.item.create → return typed Item → render
   - This becomes the copy-paste pattern for every future feature

4. Add a server action or route handler that validates input with zod
   - const schema = z.object({ name: z.string().min(1) })
   - Parse before touching the DB; return typed errors the UI can show

5. Test the round-trip: a Playwright test that clicks the button and asserts the DB row exists
   - This is the only test that catches wiring bugs across all three layers

Example

// Server: app/api/items/route.ts (Next.js + Prisma + zod)
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const schema = z.object({ name: z.string().min(1), qty: z.coerce.number().int().positive() });
export async function POST(req: Request) {
  const input = schema.parse(await req.json());  // throws on invalid
  const item = await prisma.item.create({ data: input });  // typed Item
  return Response.json(item);  // typed response
}

// Client: types flow from the server
import type { Item } from "@prisma/client";  // single source of truth
async function createItem(input: { name: string; qty: number }): Promise<Item> {
  const res = await fetch("/api/items", { method: "POST", body: JSON.stringify(input) });
  return res.json();
}

Common Problems

  • ⚠Hand-writing client types that drift from the server, so a field rename breaks the client silently.
  • ⚠Skipping input validation on the server because 'the form validates it', so a direct API call bypasses all checks.
  • ⚠Using prisma.$queryRaw with template strings, losing type safety and opening SQL injection.
  • ⚠No end-to-end test, so a wiring bug (wrong field name, missing column) only surfaces in manual QA.

Best Practices

  • ✓Use tRPC or a generated client so types flow from server to client with zero manual sync.
  • ✓Validate on the server with zod, and infer the client type from the zod schema (z.infer<typeof schema>).
  • ✓Keep one feature end-to-end as the 'golden path' that every new feature copies — consistency over novelty.
  • ✓Write the Playwright e2e test before the feature is 'done' so wiring bugs surface immediately.