Build

CRUD

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 three packages:

packages/db/src/drizzle-schema.ts        # 1. Table
packages/api/src/post/post-schema.ts     # 2. Zod inputs
packages/api/src/post/post-router.ts     # 3. Procedures
packages/api/src/root-router.ts          # 4. 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(
  "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)],
).enableRLS();

export const postRelations = relations(post, ({ one }) => ({
  organization: one(organization, {
    fields: [post.organizationId],
    references: [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, .enableRLS() 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/api/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 - organizationProcedure (next step) takes the schema and reads the slug off it.

4. Write the Router

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

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

export const postRouter = {
  list: organizationProcedure(organizationInput).handler(async ({ context }) => {
    const posts = await context.db.query.post.findMany({
      where: (postTable, { eq }) => eq(postTable.organizationId, context.organization.id),
      orderBy: (postTable, { desc }) => desc(postTable.createdAt),
    });

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

    return { post: createdPost };
  }),
  delete: organizationProcedure(deletePostInput).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 };
  }),
};

organizationProcedure (in packages/api/src/orpc.ts) takes the procedure's input schema, resolves the org named by input.slug, and proves membership before any handler runs - so handlers just read context.organization.id. list needs nothing but the slug, so it passes organizationInput itself. Every mutation still filters by organizationId in the where clause, so a valid member of org A can never touch org B's rows.

5. Mount the Router

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

orpc.post.* now autocompletes in every app.

6. Build the Page

Server component: check the session, prefetch the query, hydrate.

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

import { PageHeader } from "@/components/header";
import { getSession } from "@/lib/auth-server";
import { HydrateClient, orpc, prefetch } from "@/orpc/server";
import { PostList } from "./_components/post-list";

const Page = async (props: { params: Promise<{ slug: string }> }) => {
  const { slug } = await props.params;

  const session = await getSession();
  if (!session) {
    return redirect(`/auth/login?nextPath=/dashboard/${slug}/posts`);
  }

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

  return (
    <HydrateClient>
      <main className="flex flex-1 flex-col px-5 pb-5">
        <PageHeader>Posts</PageHeader>
        <PostList slug={slug} />
      </main>
    </HydrateClient>
  );
};

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

Routers test without a database. createCallerFactory binds the router once per file; the mock context comes from the same helper module:

// packages/api/src/post/post-router.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";

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

const createCaller = createCallerFactory(postRouter);

test("throws UNAUTHORIZED when user is not a member", async () => {
  const ctx = createMockContext();
  ctx.db.query.organization.findFirst.mock.mockImplementation(() =>
    Promise.resolve({ id: "org-1", slug: "acme" }),
  );
  ctx.db.query.member.findFirst.mock.mockImplementation(() => Promise.resolve(undefined));

  const caller = createCaller(ctx);
  await assert.rejects(caller.list({ slug: "acme" }));
});
pnpm -F @repo/api test

packages/api/src/todo/todo-router.test.ts covers the full matrix: happy paths, missing org, non-member, not-found rows.

Ship It Everywhere

Nothing else to do. The mobile app (apps/mobile/src/utils/api.ts) consumes the same AppRouter type, 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 + .enableRLS()
  • pnpm db:push ran
  • Zod schema extends organizationInput and validates the feature's own fields
  • Router is built with organizationProcedure 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