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 { protectedProcedure, router } from '@/trpc/init';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '@/lib/db';
import { postsTable } from '@/lib/db/schema';
export const postsRouter = router({
// 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, ctx }) => {
const posts = await db.query.postsTable.findMany({
limit: input.limit,
offset: input.offset,
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
});
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 db
.insert(postsTable)
.values({
title: input.title,
content: input.content,
authorId: ctx.session.user.id,
})
.returning();
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 db.query.postsTable.findFirst({
where: eq(postsTable.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 db.query.postsTable.findFirst({
where: eq(postsTable.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 db
.update(postsTable)
.set({
title: input.title,
content: input.content,
updatedAt: new Date(),
})
.where(eq(postsTable.id, input.id))
.returning();
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 db.query.postsTable.findFirst({
where: eq(postsTable.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",
});
}
await db.delete(postsTable).where(eq(postsTable.id, input.id));
return { success: true };
}),Complete Router Example
Here's the complete router:
trpc/routers/posts.ts
import { protectedProcedure, router } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { desc, eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '@/lib/db';
import { postsTable } from '@/lib/db/schema';
export const postsRouter = router({
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 db.query.postsTable.findMany({
limit: input.limit,
offset: input.offset,
orderBy: [desc(postsTable.createdAt)]
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const post = await db.query.postsTable.findFirst({
where: eq(postsTable.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 db
.insert(postsTable)
.values({
title: input.title,
content: input.content,
authorId: ctx.session.user.id
})
.returning();
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 db.query.postsTable.findFirst({
where: eq(postsTable.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 db
.update(postsTable)
.set({
title: input.title,
content: input.content,
updatedAt: new Date()
})
.where(eq(postsTable.id, input.id))
.returning();
return updatedPost;
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const existingPost = await db.query.postsTable.findFirst({
where: eq(postsTable.id, input.id)
});
if (!existingPost) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
if (existingPost.authorId !== ctx.session.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
await db.delete(postsTable).where(eq(postsTable.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
- Use returning() - For Drizzle, use
.returning()to get the created/updated record - Type safety - Let TypeScript infer types from your procedures