General
tRPC
Define Endpoint
Learn how to create new tRPC endpoints.
This guide shows you how to create new tRPC endpoints in your application. We'll create a complete CRUD example for a posts feature.
Creating a Router
Create a new router file in trpc/routers/:
trpc/routers/posts.ts
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { prisma } from '@/lib/db';
export const postsRouter = createTRPCRouter({
// Endpoints will go here
});List Posts (Query)
Create a query to list posts:
trpc/routers/posts.ts
list: protectedProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0),
})
)
.query(async ({ input }) => {
const posts = await prisma.post.findMany({
take: input.limit,
skip: input.offset,
orderBy: { createdAt: 'desc' },
});
return posts;
}),Create Post (Mutation)
Create a mutation to create a new post:
trpc/routers/posts.ts
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(255),
content: z.string().min(1),
})
)
.mutation(async ({ input, ctx }) => {
const post = await prisma.post.create({
data: {
title: input.title,
content: input.content,
authorId: ctx.user.id,
},
});
return post;
}),Get Post by ID (Query)
Create a query to get a single post:
trpc/routers/posts.ts
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const post = await prisma.post.findUnique({
where: { id: input.id },
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
return post;
}),Update Post (Mutation)
Create a mutation to update a post:
trpc/routers/posts.ts
update: protectedProcedure
.input(
z.object({
id: z.string(),
title: z.string().min(1).max(255).optional(),
content: z.string().min(1).optional(),
})
)
.mutation(async ({ input, ctx }) => {
// Verify post exists and user is author
const existingPost = await prisma.post.findUnique({
where: { id: input.id },
});
if (!existingPost) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
});
}
if (existingPost.authorId !== ctx.session.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not the author of this post',
});
}
const updatedPost = await prisma.post.update({
where: { id: input.id },
data: {
title: input.title,
content: input.content,
},
});
return updatedPost;
}),Delete Post (Mutation)
Create a mutation to delete a post:
trpc/routers/posts.ts
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
// Verify post exists and user is author
const existingPost = await prisma.post.findUnique({
where: { id: input.id },
});
if (!existingPost) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
if (existingPost.authorId !== ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not the author of this post",
});
}
await prisma.post.delete({
where: { id: input.id },
});
return { success: true };
}),Complete Router Example
Here's the complete router:
trpc/routers/posts.ts
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { prisma } from '@/lib/db';
export const postsRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0)
})
)
.query(async ({ input }) => {
return await prisma.post.findMany({
take: input.limit,
skip: input.offset,
orderBy: { createdAt: 'desc' }
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const post = await prisma.post.findUnique({
where: { id: input.id }
});
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found'
});
}
return post;
}),
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(255),
content: z.string().min(1)
})
)
.mutation(async ({ input, ctx }) => {
const post = await prisma.post.create({
data: {
title: input.title,
content: input.content,
authorId: ctx.session.user.id
}
});
return post;
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
title: z.string().min(1).max(255).optional(),
content: z.string().min(1).optional()
})
)
.mutation(async ({ input, ctx }) => {
const existingPost = await prisma.post.findUnique({
where: { id: input.id }
});
if (!existingPost) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
if (existingPost.authorId !== ctx.session.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
const updatedPost = await prisma.post.update({
where: { id: input.id },
data: {
title: input.title,
content: input.content
}
});
return updatedPost;
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const existingPost = await prisma.post.findUnique({
where: { id: input.id }
});
if (!existingPost) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
if (existingPost.authorId !== ctx.session.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
await prisma.post.delete({
where: { id: input.id }
});
return { success: true };
})
});Adding Router to App Router
Add your new router to the main app router:
trpc/routers/app.ts
import { createTRPCRouter } from '@/trpc/init';
import { lazy } from '@trpc/server';
export const appRouter = createTRPCRouter({
admin: lazy(() => import('./admin')),
organization: lazy(() => import('./organization')),
user: lazy(() => import('./user')),
posts: lazy(() => import('./posts')) // Add your new router
// ... other routers
});
export type AppRouter = typeof appRouter;Using the Endpoint
Client-Side
components/posts-list.tsx
'use client';
import { trpc } from '@/trpc/client';
export function PostsList() {
const { data: posts, isLoading } = trpc.posts.list.useQuery({
limit: 10,
offset: 0
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
{posts?.map((post) => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>{post.content}</p>
</div>
))}
</div>
);
}Server-Side
app/(saas)/dashboard/posts/page.tsx
import { HydrateClient, trpc } from '@/trpc/server';
export default async function PostsPage() {
await trpc.posts.list.prefetch({ limit: 10, offset: 0 });
return (
<HydrateClient>
<PostsList />
</HydrateClient>
);
}Best Practices
- Use appropriate procedures - Choose
publicProcedure,protectedProcedure, orprotectedOrganizationProcedure - Validate inputs - Always use Zod schemas for input validation
- Handle errors - Use
TRPCErrorwith appropriate error codes - Check permissions - Verify user has access before operations
- Return created/updated records - Prisma automatically returns the created/updated record by default
- Type safety - Let TypeScript infer types from your procedures