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

  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