docs/recipes/new-procedure.md
This recipe adds a new tRPC procedure with input validation and wires it up from the API to the frontend.
Add a new router in apps/api/routers/:
// apps/api/routers/project.ts
import { z } from "zod";
import { schema } from "@repo/db";
import { protectedProcedure, router } from "../lib/trpc.js";
export const projectRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
const projects = await ctx.db.query.project.findMany({
where: (p, { eq }) =>
eq(p.organizationId, ctx.session.activeOrganizationId!),
orderBy: (p, { desc }) => desc(p.createdAt),
});
return { projects };
}),
create: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const [project] = await ctx.db
.insert(schema.project)
.values({
...input,
organizationId: ctx.session.activeOrganizationId!,
})
.returning();
return project;
}),
});
Use protectedProcedure for authenticated endpoints and publicProcedure for unauthenticated ones. Protected procedures guarantee ctx.session and ctx.user are non-null.
Import and add it to the app router in apps/api/lib/app.ts:
import { projectRouter } from "../routers/project.js";
const appRouter = router({
billing: billingRouter,
user: userRouter,
organization: organizationRouter,
project: projectRouter, // [!code ++]
});
The procedure is now callable at /api/trpc/project.list and /api/trpc/project.create.
Create a query options helper in apps/app/lib/queries/:
// apps/app/lib/queries/project.ts
import {
queryOptions,
useQuery,
useSuspenseQuery,
} from "@tanstack/react-query";
import { trpcClient } from "../trpc";
export function projectListOptions() {
return queryOptions({
queryKey: ["projects"],
queryFn: () => trpcClient.project.list.query(),
});
}
export function useProjectList() {
return useQuery(projectListOptions());
}
Use in a component:
import { useProjectList } from "@/lib/queries/project";
function ProjectList() {
const { data, isLoading } = useProjectList();
if (isLoading) return <p>Loading...</p>;
return (
<ul>
{data?.projects.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
import { trpcClient } from "@/lib/trpc";
import { useQueryClient } from "@tanstack/react-query";
function CreateProjectButton() {
const queryClient = useQueryClient();
async function handleCreate() {
await trpcClient.project.create.mutate({
name: "New Project",
});
// Invalidate the list so it refetches
await queryClient.invalidateQueries({ queryKey: ["projects"] });
}
return <button onClick={handleCreate}>Create Project</button>;
}