Build

CRUD

Add a complete feature, from database table to oRPC router to UI, following the todo reference implementation

Add a complete feature to Init: database table → API router → UI. The todo feature is the reference implementation - this guide builds a post feature the same way. Copy the pattern, rename the nouns.

The full path touches four packages:

packages/db/src/drizzle-schema.ts             # 1. Table
packages/contract/src/post/post-schema.ts     # 2. Zod inputs
packages/contract/src/post/post-contract.ts   # 3. Procedure contract
packages/contract/src/index.ts                #    Register
packages/service/src/post/post-router.ts          # 4. Implementation
packages/service/src/root-router.ts               #    Mount
apps/web/src/app/.../posts/page.tsx           # 5. Server page
apps/web/src/app/.../posts/_components/       # 6. Client UI

1. Define the Table

// packages/db/src/drizzle-schema.ts
export const post = pgTable.withRLS(
  "post",
  (t) => ({
    id: t.uuid().notNull().primaryKey().defaultRandom(),
    organizationId: t
      .text()
      .notNull()
      .references(() => organization.id, { onDelete: "cascade" }),
    title: t.text().notNull(),
    content: t.text(),
    createdAt: t.timestamp({ withTimezone: true }).notNull().defaultNow(),
    updatedAt: t.timestamp({ withTimezone: true }).notNull().defaultNow(),
  }),
  (table) => [index("post_organization_id_idx").on(table.organizationId)],
);

// packages/db/src/drizzle-relations.ts
export const relations = defineRelations({ ...schemaAuth, ...schema }, (r) => ({
  // ...
  post: {
    organization: r.one.organization({ from: r.post.organizationId, to: r.organization.id }),
  },
}));

Every rule here matters: organizationId scopes the data to a tenant, the index makes the list query fast, cascade cleans up when an org is deleted, pgTable.withRLS keeps PostgREST locked out. See Data Modelling for why.

2. Push the Schema

pnpm db:push

No migration files, no codegen. Types flow from the schema definition immediately.

3. Validate Inputs

// packages/contract/src/post/post-schema.ts
import { z } from "zod";

import { organizationInput } from "../organization/organization-schema";

export const createPostInput = organizationInput.extend({
  title: z.string().trim().min(1, "Title is required").max(255),
  content: z.string().optional(),
});

export const deletePostInput = organizationInput.extend({
  id: z.uuid(),
});

Org-scoped schemas extend organizationInput, so the slug is part of each procedure's declared contract - requireOrganization (step 5) reads the slug off the validated input.

4. Declare the Contract

The contract is what clients type against: input, output, error codes, and OpenAPI metadata. No handlers, no server imports.

// packages/contract/src/post/post-contract.ts
import { post } from "@repo/db/drizzle-schema";
import { openapi } from "@orpc/openapi";
import { createSelectSchema } from "drizzle-orm/zod";
import { z } from "zod";

import { organizationBase } from "../base";
import { organizationInput } from "../organization/organization-schema";
import { createPostInput, deletePostInput } from "./post-schema";

const postRow = createSelectSchema(post);
const postOutput = z.object({ post: postRow });

export const postContract = {
  list: organizationBase
    .meta(openapi({ summary: "List posts", description: "...", tags: ["Post"] }))
    .input(organizationInput)
    .output(z.object({ posts: z.array(postRow) })),
  create: organizationBase
    .meta(openapi({ summary: "Create a post", description: "...", tags: ["Post"] }))
    .input(createPostInput)
    .output(postOutput),
  delete: organizationBase
    .meta(openapi({ summary: "Delete a post", description: "...", tags: ["Post"] }))
    .input(deletePostInput)
    .output(postOutput),
};

Register it in packages/contract/src/index.ts:

export const contract = {
  organization: organizationContract,
  post: postContract, // Add here
  todo: todoContract,
  waitlist: waitlistContract,
};

organizationBase declares NOT_FOUND and UNAUTHORIZED and marks the operation as session-authenticated in /openapi.json. list needs nothing but the slug, so it takes organizationInput itself. If a client form needs the Zod schema at runtime, add "./post/post-schema" to exports in packages/contract/package.json (see waitlist-schema).

5. Implement the Router

// packages/service/src/post/post-router.ts
import { post } from "@repo/db/drizzle-schema";
import { ORPCError } from "@orpc/server";
import { and, eq } from "drizzle-orm";

import { os, requireOrganization, requireSession } from "../orpc";

const authed = os.post.use(requireSession);

export const postRouter = {
  list: authed.list.use(requireOrganization).handler(async ({ context }) => {
    const posts = await context.db.query.post.findMany({
      where: { organizationId: context.organization.id },
      orderBy: { createdAt: "desc" },
    });

    return { posts };
  }),
  create: authed.create.use(requireOrganization).handler(async ({ context, input }) => {
    const [createdPost] = await context.db
      .insert(post)
      .values({
        organizationId: context.organization.id,
        title: input.title,
        content: input.content,
      })
      .returning();

    if (!createdPost) {
      throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Post was not created" });
    }

    return { post: createdPost };
  }),
  delete: authed.delete.use(requireOrganization).handler(async ({ context, input }) => {
    const [deletedPost] = await context.db
      .delete(post)
      .where(and(eq(post.id, input.id), eq(post.organizationId, context.organization.id)))
      .returning();

    if (!deletedPost) {
      throw new ORPCError("NOT_FOUND", { message: "Post not found" });
    }

    return { post: deletedPost };
  }),
};

requireSession goes on the implementer, so it runs before input validation and anonymous callers get UNAUTHORIZED. requireOrganization (in packages/service/src/orpc.ts) then resolves the org named by the validated input.slug and proves membership before any handler runs - so handlers just read context.organization.id. The router is a plain object: os.post.router({...}) would re-apply requireSession, running it twice. Every mutation still filters by organizationId in the where clause, so a valid member of org A can never touch org B's rows.

Mount it in the root router:

// packages/service/src/root-router.ts
export const appRouter = os.router({
  organization: organizationRouter,
  post: postRouter, // Add here
  todo: todoRouter,
  waitlist: waitlistRouter,
});

os.router fails to compile until every contract procedure is implemented with matching types. orpc.post.* now autocompletes in every app.

6. Build the Page

Server component: check the session, prefetch the query, hydrate. The session and params are request data, so they're read under <Suspense> (see Rendering and Prefetching).

// apps/web/src/app/(dashboard)/dashboard/[slug]/posts/page.tsx
import { Suspense } from "react";

import { PageHeader } from "@/components/header";
import { HydrateClient, orpc, prefetch } from "@/orpc/server";
import { resolveOrganizationPage } from "../_components/organization-page";
import { PostList } from "./_components/post-list";

interface PageProps {
  params: Promise<{ slug: string }>;
}

const Posts = async ({ params }: PageProps) => {
  // Redirects a signed-out visitor to login, back to this page afterwards
  const { slug } = await resolveOrganizationPage(params, "/posts");

  prefetch(orpc.post.list.queryOptions({ input: { slug } }));

  return (
    <HydrateClient>
      <PostList slug={slug} />
    </HydrateClient>
  );
};

const Page = ({ params }: PageProps) => (
  <main className="flex flex-1 flex-col px-5 pb-5">
    <PageHeader>Posts</PageHeader>
    <Suspense fallback={<p className="text-muted-foreground text-sm">Loading posts…</p>}>
      <Posts params={params} />
    </Suspense>
  </main>
);

export default Page;

7. Build the Client Component

// apps/web/src/app/(dashboard)/dashboard/[slug]/posts/_components/post-list.tsx
"use client";

import { toast } from "@repo/ui/components/sonner";
import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query";

import { orpc } from "@/orpc/react";

export const PostList = ({ slug }: { slug: string }) => {
  const queryClient = useQueryClient();

  // Resolves instantly from the server prefetch - no loading spinner
  const { data } = useSuspenseQuery(orpc.post.list.queryOptions({ input: { slug } }));

  // Refetch this org's list after a write - each mutation invalidates only
  // what it touched; there's no global invalidate-everything net behind it
  const invalidatePosts = () =>
    queryClient.invalidateQueries({ queryKey: orpc.post.list.key({ input: { slug } }) });

  const createPost = useMutation(
    orpc.post.create.mutationOptions({
      onError: (error) => toast.error(error.message),
      onSuccess: invalidatePosts,
    }),
  );

  return (
    <ul>
      {data.posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
};

The full reference - inline editing, delete confirmation, per-row pending states - is apps/web/src/app/(dashboard)/dashboard/[slug]/(home)/_components/todo-list.tsx.

8. Test It

Run the real router and middleware with a stubbed Postgres connection:

// packages/service/src/post/post-router.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { createRouterClient } from "@orpc/server";

import { createMockContext } from "../test-utils";
import { postRouter } from "./post-router";

test("rejects unauthenticated callers before querying", async () => {
  const context = createMockContext(null);
  const caller = createRouterClient(postRouter, { context });
  await assert.rejects(caller.list({ slug: "acme" }), { code: "UNAUTHORIZED" });
  assert.equal(context.query.mock.callCount(), 0);
});
pnpm -F @repo/service test

todo-router.test.ts checks tenant-scoped SQL, updates and missing rows. auth/orpc-middleware.test.ts covers missing organizations and non-members. Keep a live UI check for database constraints and persistence.

Ship It Everywhere

Nothing else to do. The mobile app (apps/mobile/src/utils/api.ts) types its client with the same ContractClient, so orpc.post.list works there too. The extension and desktop shells open the web app itself, so they get the feature for free.

Checklist

  • Table has organizationId + index + pgTable.withRLS
  • pnpm db:push ran
  • Zod schema extends organizationInput and validates the feature's own fields
  • Contract built on organizationBase with .input() and .output(), registered in packages/contract/src/index.ts
  • Router built from os.post.use(requireSession); each procedure .use(requireOrganization) and filters mutations by organizationId
  • Router mounted in root-router.ts
  • Page prefetches; client uses useSuspenseQuery
  • Tests cover the unauthorized paths

Next Steps

  1. Data fetching - Queries in depth
  2. Data mutations - Mutations in depth
  3. API reference - oRPC architecture

On this page