profile

Néstor's Blog

tRPC with Next.js App Router

trpc nextjs typescript react

Setup tRPC for App Router

Install Dependencies

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

Create tRPC Router

// server/trpc.ts
import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

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

Define API Routes

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

export const appRouter = router({
  getPosts: publicProcedure.query(async () => {
    return await db.posts.findMany();
  }),

  getPost: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return await db.posts.findUnique({
        where: { id: input.id }
      });
    }),

  createPost: publicProcedure
    .input(z.object({
      title: z.string(),
      content: z.string()
    }))
    .mutation(async ({ input }) => {
      return await db.posts.create({
        data: input
      });
    }),
});

export type AppRouter = typeof appRouter;

Route Handler (API Endpoint)

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

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

export { handler as GET, handler as POST };

Client Setup

Create tRPC Client

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

export const trpc = createTRPCReact<AppRouter>();

Provider Component

// components/TRPCProvider.tsx
'use client';

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

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

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

Wrap App

// app/layout.tsx
import { TRPCProvider } from '@/components/TRPCProvider';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <TRPCProvider>{children}</TRPCProvider>
      </body>
    </html>
  );
}

Usage in Client Components

'use client';

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

export function PostList() {
  const { data, isLoading } = trpc.getPosts.useQuery();

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {data?.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
}

Usage in Server Components

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

export const serverClient = appRouter.createCaller({});
// app/posts/page.tsx
import { serverClient } from '@/lib/trpc/server';

export default async function PostsPage() {
  const posts = await serverClient.getPosts();

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

Mutations in Client Components

'use client';

import { trpc } from '@/lib/trpc/client';
import { useRouter } from 'next/navigation';

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

  const createPost = trpc.createPost.useMutation({
    onSuccess: () => {
      utils.getPosts.invalidate();
      router.refresh();
    },
  });

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

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

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" />
      <textarea name="content" />
      <button type="submit">Create</button>
    </form>
  );
}

Type Safety

Everything is fully typed:

// ✅ TypeScript knows the exact return type
const posts = await serverClient.getPosts();
//    ^? Post[]

// ✅ Input validation with Zod
const post = await serverClient.getPost({ id: '123' });
//    ^? Post | null

// ❌ TypeScript error: missing required field
createPost.mutate({ title: 'Hello' });
//                  ^^^^^^^^^^^^^^^^
// Error: Property 'content' is missing