Back to list
mgd34msu

trpc

by mgd34msu

Plug in, Receive good vibes.

2🍴 0📅 Jan 25, 2026

SKILL.md


name: trpc description: Builds type-safe APIs with tRPC including routers, procedures, context, and client integration. Use when creating end-to-end type-safe APIs, building full-stack TypeScript applications, or replacing REST/GraphQL with simpler patterns.

tRPC

End-to-end typesafe APIs without code generation or runtime overhead.

Quick Start

Install:

npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod

Project structure:

src/
  server/
    trpc.ts           # tRPC instance
    routers/
      _app.ts         # Root router
      posts.ts        # Posts router
  app/
    api/trpc/[trpc]/
      route.ts        # API handler (Next.js)
  trpc/
    client.ts         # Client setup
    Provider.tsx      # React provider

Server Setup

Initialize tRPC

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';
import type { Context } from './context';

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;

Context

// server/context.ts
import type { inferAsyncReturnType } from '@trpc/server';
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
import { prisma } from '@/lib/prisma';
import { getSession } from '@/lib/auth';

export async function createContext(opts: FetchCreateContextFnOptions) {
  const session = await getSession(opts.req);

  return {
    prisma,
    session,
    user: session?.user ?? null,
  };
}

export type Context = inferAsyncReturnType<typeof createContext>;

Protected Procedure

// server/trpc.ts
const isAuthed = middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' });
  }
  return next({
    ctx: {
      user: ctx.user, // Now non-nullable
    },
  });
});

export const protectedProcedure = t.procedure.use(isAuthed);

Routers

Basic Router

// server/routers/posts.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';

export const postsRouter = router({
  // Query - GET operations
  list: publicProcedure.query(async ({ ctx }) => {
    return ctx.prisma.post.findMany({
      orderBy: { createdAt: 'desc' },
    });
  }),

  // Query with input
  byId: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ ctx, input }) => {
      return ctx.prisma.post.findUnique({
        where: { id: input.id },
      });
    }),

  // Mutation - POST/PUT/DELETE operations
  create: protectedProcedure
    .input(
      z.object({
        title: z.string().min(1).max(100),
        content: z.string().min(1),
      })
    )
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.post.create({
        data: {
          ...input,
          authorId: ctx.user.id,
        },
      });
    }),

  // Mutation with validation
  update: protectedProcedure
    .input(
      z.object({
        id: z.string(),
        title: z.string().min(1).max(100).optional(),
        content: z.string().min(1).optional(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input;
      return ctx.prisma.post.update({
        where: { id },
        data,
      });
    }),

  delete: protectedProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.post.delete({
        where: { id: input.id },
      });
    }),
});

Root Router

// server/routers/_app.ts
import { router } from '../trpc';
import { postsRouter } from './posts';
import { usersRouter } from './users';

export const appRouter = router({
  posts: postsRouter,
  users: usersRouter,
});

export type AppRouter = typeof appRouter;

API Handler (Next.js)

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext,
  });

export { handler as GET, handler as POST };

Client Setup

React Query Client

// trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();

Provider

// trpc/Provider.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import superjson from 'superjson';
import { trpc } from './client';

function getBaseUrl() {
  if (typeof window !== 'undefined') return '';
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
  return 'http://localhost:3000';
}

export function TRPCProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: `${getBaseUrl()}/api/trpc`,
          transformer: superjson,
        }),
      ],
    })
  );

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </trpc.Provider>
  );
}

Using tRPC in Components

Queries

'use client';

import { trpc } from '@/trpc/client';

function PostList() {
  const { data: posts, isLoading, error } = trpc.posts.list.useQuery();

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {posts?.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

// With input
function Post({ id }: { id: string }) {
  const { data: post } = trpc.posts.byId.useQuery({ id });
  return <h1>{post?.title}</h1>;
}

// With options
function RecentPosts() {
  const { data } = trpc.posts.list.useQuery(undefined, {
    staleTime: 5 * 60 * 1000,
    refetchOnWindowFocus: false,
  });
  // ...
}

Mutations

'use client';

import { trpc } from '@/trpc/client';

function CreatePostForm() {
  const utils = trpc.useUtils();

  const mutation = trpc.posts.create.useMutation({
    onSuccess: () => {
      utils.posts.list.invalidate();
    },
  });

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    mutation.mutate({
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" required />
      <textarea name="content" required />
      <button disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create'}
      </button>
      {mutation.error && <p>{mutation.error.message}</p>}
    </form>
  );
}

Optimistic Updates

function TodoItem({ todo }: { todo: Todo }) {
  const utils = trpc.useUtils();

  const toggleMutation = trpc.todos.toggle.useMutation({
    onMutate: async ({ id, completed }) => {
      await utils.todos.list.cancel();

      const previousTodos = utils.todos.list.getData();

      utils.todos.list.setData(undefined, (old) =>
        old?.map((t) => (t.id === id ? { ...t, completed } : t))
      );

      return { previousTodos };
    },
    onError: (err, variables, context) => {
      utils.todos.list.setData(undefined, context?.previousTodos);
    },
    onSettled: () => {
      utils.todos.list.invalidate();
    },
  });

  return (
    <input
      type="checkbox"
      checked={todo.completed}
      onChange={(e) =>
        toggleMutation.mutate({ id: todo.id, completed: e.target.checked })
      }
    />
  );
}

Infinite Queries

function InfinitePosts() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    trpc.posts.infinite.useInfiniteQuery(
      { limit: 10 },
      {
        getNextPageParam: (lastPage) => lastPage.nextCursor,
      }
    );

  return (
    <div>
      {data?.pages.map((page) =>
        page.posts.map((post) => <PostCard key={post.id} post={post} />)
      )}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading...' : 'Load More'}
      </button>
    </div>
  );
}

// Router procedure
infinitePosts: publicProcedure
  .input(
    z.object({
      limit: z.number().min(1).max(100).default(10),
      cursor: z.string().optional(),
    })
  )
  .query(async ({ ctx, input }) => {
    const posts = await ctx.prisma.post.findMany({
      take: input.limit + 1,
      cursor: input.cursor ? { id: input.cursor } : undefined,
      orderBy: { createdAt: 'desc' },
    });

    let nextCursor: string | undefined;
    if (posts.length > input.limit) {
      const nextItem = posts.pop();
      nextCursor = nextItem?.id;
    }

    return { posts, nextCursor };
  }),

Server-Side Calls

Server Components (Next.js)

// lib/trpc-server.ts
import { appRouter } from '@/server/routers/_app';
import { createContext } from '@/server/context';

export const serverClient = appRouter.createCaller(await createContext());

// Usage in Server Component
async function PostsPage() {
  const posts = await serverClient.posts.list();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Server Actions

// app/actions.ts
'use server';

import { serverClient } from '@/lib/trpc-server';

export async function createPost(formData: FormData) {
  const result = await serverClient.posts.create({
    title: formData.get('title') as string,
    content: formData.get('content') as string,
  });

  return result;
}

Error Handling

Throwing Errors

import { TRPCError } from '@trpc/server';

delete: protectedProcedure
  .input(z.object({ id: z.string() }))
  .mutation(async ({ ctx, input }) => {
    const post = await ctx.prisma.post.findUnique({
      where: { id: input.id },
    });

    if (!post) {
      throw new TRPCError({
        code: 'NOT_FOUND',
        message: 'Post not found',
      });
    }

    if (post.authorId !== ctx.user.id) {
      throw new TRPCError({
        code: 'FORBIDDEN',
        message: 'You can only delete your own posts',
      });
    }

    return ctx.prisma.post.delete({ where: { id: input.id } });
  }),

Error Codes

CodeHTTP StatusDescription
PARSE_ERROR400Invalid JSON
BAD_REQUEST400Invalid input
UNAUTHORIZED401Not authenticated
FORBIDDEN403Not authorized
NOT_FOUND404Resource not found
CONFLICT409Resource conflict
TOO_MANY_REQUESTS429Rate limit exceeded
INTERNAL_SERVER_ERROR500Server error

Middleware

Logging Middleware

const loggerMiddleware = middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;

  console.log(`[${type}] ${path} - ${duration}ms`);

  return result;
});

export const loggedProcedure = t.procedure.use(loggerMiddleware);

Rate Limiting

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
});

const rateLimitMiddleware = middleware(async ({ ctx, next }) => {
  const identifier = ctx.user?.id ?? 'anonymous';
  const { success } = await ratelimit.limit(identifier);

  if (!success) {
    throw new TRPCError({ code: 'TOO_MANY_REQUESTS' });
  }

  return next();
});

export const rateLimitedProcedure = t.procedure.use(rateLimitMiddleware);

Best Practices

  1. Organize routers by feature - One router per domain
  2. Use input validation - Always validate with Zod
  3. Create procedure variants - publicProcedure, protectedProcedure
  4. Leverage useUtils - For cache manipulation
  5. Handle errors properly - Use TRPCError with codes

Common Mistakes

MistakeFix
Not typing contextCreate typed context properly
Missing transformerAdd superjson for Date, Map, Set
Inline mutationsUse useMutation for all mutations
Not invalidating cacheCall utils.x.invalidate()
Over-fetchingUse select in queries

Reference Files

Score

Total Score

60/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

+10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

3ヶ月以内に更新がある

0/10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

0/5

Reviews

💬

Reviews coming soon