Skip to main content
General
tRPC

Define Endpoint

Learn how to create new tRPC endpoints.

Open MarkdownFull AI corpusFeedback

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