Aller au contenu principal

Type-Safe tRPC Procedure

This example shows how to create professional tRPC procedures with Zod validation and end-to-end type inference.

Command used

/dev:dev-trpc "Create a router for tasks with CRUD and filters"

Generated structure

src/
├── server/
│ ├── trpc.ts # tRPC configuration
│ ├── context.ts # Request context
│ └── routers/
│ ├── index.ts # Main router
│ ├── task.ts # Tasks router
│ └── user.ts # Users router
├── utils/
│ └── trpc.ts # tRPC client (React)
└── pages/api/
└── trpc/
└── [trpc].ts # Next.js handler

Router Code

server/trpc.ts

import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';
import type { Context } from './context';

const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten(): null,
},
};
},
});

// Logging middleware
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;

console.log(`[${type}] ${path} - ${duration}ms`);

return result;
});

// Authentication middleware
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in',
});
}

return next({
ctx: {
...ctx,
session: ctx.session,
user: ctx.session.user,
},
});
});

// Admin middleware
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (!ctx.session?.user || ctx.session.user.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Access restricted to administrators',
});
}

return next({
ctx: {
...ctx,
session: ctx.session,
user: ctx.session.user,
},
});
});

export const router = t.router;
export const publicProcedure = t.procedure.use(loggerMiddleware);
export const protectedProcedure = t.procedure.use(loggerMiddleware).use(isAuthed);
export const adminProcedure = t.procedure.use(loggerMiddleware).use(isAdmin);

server/context.ts

import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getServerSession } from 'next-auth';
import { authOptions } from '../pages/api/auth/[...nextauth]';
import { prisma } from '../lib/prisma';

export async function createContext({ req, res }: CreateNextContextOptions) {
const session = await getServerSession(req, res, authOptions);

return {
session,
prisma,
req,
res,
};
}

export type Context = Awaited<ReturnType<typeof createContext>>;

server/routers/task.ts

import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { router, publicProcedure, protectedProcedure } from '../trpc';

// Validation schemas
const taskSchema = z.object({
id: z.string().cuid(),
title: z.string().min(1).max(200),
description: z.string().max(2000).nullable(),
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']),
priority: z.enum(['low', 'medium', 'high', 'urgent']),
dueDate: z.date().nullable(),
assigneeId: z.string().cuid().nullable(),
createdAt: z.date(),
updatedAt: z.date(),
});

const createTaskSchema = taskSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
});

const updateTaskSchema = createTaskSchema.partial();

const listTasksSchema = z.object({
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']).optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).optional(),
assigneeId: z.string().cuid().optional(),
search: z.string().optional(),
cursor: z.string().cuid().optional(),
limit: z.number().int().min(1).max(100).default(20),
sortBy: z.enum(['createdAt', 'dueDate', 'priority']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});

export const taskRouter = router({
/**
* List tasks with cursor-based pagination
*/
list: protectedProcedure
.input(listTasksSchema)
.query(async ({ ctx, input }) => {
const { cursor, limit, status, priority, assigneeId, search, sortBy, sortOrder } = input;

const where = {
// Filter by the user's project/team
project: {
members: {
some: { userId: ctx.user.id },
},
},
...(status && { status }),
...(priority && { priority }),
...(assigneeId && { assigneeId }),
...(search && {
OR: [
{ title: { contains: search, mode: 'insensitive' as const } },
{ description: { contains: search, mode: 'insensitive' as const } },
],
}),
};

const tasks = await ctx.prisma.task.findMany({
where,
take: limit + 1,
cursor: cursor ? { id: cursor }: undefined,
orderBy: { [sortBy]: sortOrder },
include: {
assignee: {
select: { id: true, name: true, image: true },
},
},
});

let nextCursor: string | undefined;
if (tasks.length > limit) {
const nextItem = tasks.pop();
nextCursor = nextItem?.id;
}

return {
tasks,
nextCursor,
};
}),

/**
* Get a task by ID
*/
byId: protectedProcedure
.input(z.object({ id: z.string().cuid() }))
.query(async ({ ctx, input }) => {
const task = await ctx.prisma.task.findUnique({
where: { id: input.id },
include: {
assignee: {
select: { id: true, name: true, image: true },
},
comments: {
orderBy: { createdAt: 'desc' },
include: {
author: {
select: { id: true, name: true, image: true },
},
},
},
},
});

if (!task) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Task not found',
});
}

return task;
}),

/**
* Create a new task
*/
create: protectedProcedure
.input(createTaskSchema.extend({
projectId: z.string().cuid(),
}))
.mutation(async ({ ctx, input }) => {
const { projectId, ...data } = input;

// Check that the user has access to the project
const project = await ctx.prisma.project.findFirst({
where: {
id: projectId,
members: {
some: { userId: ctx.user.id },
},
},
});

if (!project) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Access to project denied',
});
}

return ctx.prisma.task.create({
data: {
...data,
projectId,
creatorId: ctx.user.id,
},
include: {
assignee: {
select: { id: true, name: true, image: true },
},
},
});
}),

/**
* Update a task
*/
update: protectedProcedure
.input(z.object({
id: z.string().cuid(),
data: updateTaskSchema,
}))
.mutation(async ({ ctx, input }) => {
const { id, data } = input;

// Check that the task exists and that the user has access to it
const existing = await ctx.prisma.task.findFirst({
where: {
id,
project: {
members: {
some: { userId: ctx.user.id },
},
},
},
});

if (!existing) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Task not found or access denied',
});
}

return ctx.prisma.task.update({
where: { id },
data,
include: {
assignee: {
select: { id: true, name: true, image: true },
},
},
});
}),

/**
* Change a task's status (shortcut)
*/
updateStatus: protectedProcedure
.input(z.object({
id: z.string().cuid(),
status: z.enum(['todo', 'in_progress', 'done', 'cancelled']),
}))
.mutation(async ({ ctx, input }) => {
const { id, status } = input;

return ctx.prisma.task.update({
where: { id },
data: { status },
});
}),

/**
* Delete a task
*/
delete: protectedProcedure
.input(z.object({ id: z.string().cuid() }))
.mutation(async ({ ctx, input }) => {
const { id } = input;

// Check permissions (creator or project admin)
const task = await ctx.prisma.task.findFirst({
where: {
id,
OR: [
{ creatorId: ctx.user.id },
{
project: {
members: {
some: {
userId: ctx.user.id,
role: 'admin',
},
},
},
},
],
},
});

if (!task) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Not authorized to delete this task',
});
}

await ctx.prisma.task.delete({ where: { id } });

return { success: true };
}),

/**
* Task statistics
*/
stats: protectedProcedure
.input(z.object({ projectId: z.string().cuid().optional() }))
.query(async ({ ctx, input }) => {
const where = {
project: {
members: {
some: { userId: ctx.user.id },
},
},
...(input.projectId && { projectId: input.projectId }),
};

const [total, byStatus, byPriority, overdue] = await Promise.all([
ctx.prisma.task.count({ where }),
ctx.prisma.task.groupBy({
by: ['status'],
where,
_count: true,
}),
ctx.prisma.task.groupBy({
by: ['priority'],
where,
_count: true,
}),
ctx.prisma.task.count({
where: {
...where,
status: { not: 'done' },
dueDate: { lt: new Date() },
},
}),
]);

return {
total,
byStatus: Object.fromEntries(
byStatus.map((s) => [s.status, s._count])
),
byPriority: Object.fromEntries(
byPriority.map((p) => [p.priority, p._count])
),
overdue,
};
}),
});

server/routers/index.ts

import { router } from '../trpc';
import { taskRouter } from './task';
import { userRouter } from './user';

export const appRouter = router({
task: taskRouter,
user: userRouter,
});

export type AppRouter = typeof appRouter;

utils/trpc.ts (React Client)

import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import superjson from 'superjson';
import type { AppRouter } from '../server/routers';

function getBaseUrl() {
if (typeof window !== 'undefined') return '';
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}

export const trpc = createTRPCNext<AppRouter>({
config() {
return {
transformer: superjson,
links: [
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === 'development' ||
(opts.direction === 'down' && opts.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
};
},
ssr: false,
});

Client-side usage

import { trpc } from '../utils/trpc';

function TaskList() {
// Query with filters
const { data, fetchNextPage, hasNextPage, isLoading } = trpc.task.list.useInfiniteQuery(
{ limit: 20, status: 'todo' },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);

// Mutation with optimistic update
const utils = trpc.useUtils();
const updateStatus = trpc.task.updateStatus.useMutation({
onMutate: async ({ id, status }) => {
await utils.task.list.cancel();
const previous = utils.task.list.getData();

utils.task.list.setData({ limit: 20 }, (old) => ({
...old!,
tasks: old!.tasks.map((t) =>
t.id === id ? { ...t, status }: t
),
}));

return { previous };
},
onError: (err, vars, context) => {
if (context?.previous) {
utils.task.list.setData({ limit: 20 }, context.previous);
}
},
onSettled: () => {
utils.task.list.invalidate();
},
});

if (isLoading) return <Spinner />;

return (
<ul>
{data?.pages.flatMap((page) =>
page.tasks.map((task) => (
<li key={task.id}>
{task.title}
<button
onClick={() =>
updateStatus.mutate({ id: task.id, status: 'done' })
}
>
Complete
</button>
</li>
))
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Load more</button>
)}
</ul>
);
}

Key points

AspectImplementation
Type-safetyEnd-to-end inferred types
ValidationZod with formatted errors
AuthReusable middlewares
PaginationCursor-based with useInfiniteQuery
Optimistic UIMutation with rollback
  • /dev:dev-test - Generate procedure tests
  • /dev:dev-prisma - Associated Prisma schema
  • /qa:qa-security - Security audit

React Query DevTools

Enable the DevTools to debug queries:

import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

<ReactQueryDevtools initialIsOpen={false} />