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

  1. Use appropriate procedures - Choose publicProcedure, protectedProcedure, or protectedOrganizationProcedure
  2. Validate inputs - Always use Zod schemas for input validation
  3. Handle errors - Use TRPCError with appropriate error codes
  4. Check permissions - Verify user has access before operations
  5. Use returning() - For Drizzle, use .returning() to get the created/updated record
  6. Type safety - Let TypeScript infer types from your procedures