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 { 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

  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. Return created/updated records - Prisma automatically returns the created/updated record by default
  6. Type safety - Let TypeScript infer types from your procedures