tRPC v11 with Next.js 15: End-to-End Type Safety, Server Actions & TanStack Query v5

Table of Contents(14 sections)
tRPC v11, coupled with Next.js 15's App Router and Server Actions, offers a robust framework for building full-stack TypeScript applications. This guide details an architectural approach leveraging tRPC for type-safe API interactions, integrating Server Actions for specific mutation patterns, and orchestrating data fetching with TanStack Query v5. The objective is to achieve compile-time end-to-end type safety without the overhead of schema generation tools like Protobuf or GraphQL.
Architectural Overview
The proposed architecture combines tRPC's RPC-style API with Next.js Server Actions. tRPC handles complex data fetching, real-time updates (via subscriptions, though not covered extensively here), and general-purpose mutations. Server Actions are utilized for optimistic UI updates on simple mutations, form submissions, and scenarios where direct server-side data manipulation without a full API roundtrip is beneficial. TanStack Query v5 manages client-side caching, revalidation, and synchronization.
Core Components
- tRPC Server: Defined within Next.js API routes (e.g.,
app/api/trpc/[trpc]/route.ts), exposing type-safe procedures. - tRPC Client: Configured for React components, integrated with TanStack Query.
- Next.js Server Actions: Functions marked
use serverfor direct server-side execution from client components. - TanStack Query v5: Client-side data fetching and caching layer.
- Zod: Schema validation for tRPC inputs and Server Action payloads.
Architectural Comparison: tRPC vs. Server Actions
| Feature | tRPC Procedures | Next.js Server Actions |
|---|---|---|
| Invocation | HTTP (POST/GET), RPC-style | Direct function call (RPC-like) |
| Type Safety | End-to-end, compile-time | End-to-end, compile-time |
| Batching | Automatic (HTTP) | No native batching |
| Caching | TanStack Query | React Cache, revalidatePath, revalidateTag |
| Error Handling | Standard HTTP errors, tRPC transformers | try/catch on client, useFormStatus |
| Middleware | Built-in (auth, rate-limit) | Custom wrappers, next/cache |
| Use Case | Complex queries, mutations, subscriptions, general API | Form submissions, optimistic updates, simple mutations |
| Network | HTTP Request/Response | Serialized function call over HTTP |
tRPC Server Setup
The tRPC server is defined in app/api/trpc/[trpc]/route.ts. This file acts as the entry point for all tRPC requests.
// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
import superjson from 'superjson';
import { type NextRequest } from 'next/server';
interface CreateContextOptions {
headers: Headers;
// Add any other context properties you need, e.g., session
userId?: string;
}
/**
* This is the actual context you'll use in your router. It will be used to process every request
* that goes through your tRPC router.
*/
export const createTRPCContext = async (opts: CreateContextOptions) => {
// Simulate authentication or session retrieval
const userId = opts.headers.get('x-user-id') || undefined; // Example: get from header
return {
headers: opts.headers,
userId,
};
};
/**
* Initializer for tRPC.
* @see https://trpc.io/docs/v11/server/initialization
*/
const t = initTRPC.context<typeof createTRPCContext>().transformer(superjson).create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
/**
* Reusable middleware that enforces users are logged in.
*/
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
// Infers a new type for the context with `userId` now being non-nullable
userId: ctx.userId,
},
});
});
/**
* Export reusable router and procedure helpers
*/
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
// src/server/routers/_app.ts
import { z } from 'zod';
import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc';
// Example: Simulate a database
const posts: { id: string; title: string; content: string; authorId: string }[] = [];
export const appRouter = createTRPCRouter({
post: createTRPCRouter({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const post = posts.find(p => p.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), content: z.string().min(1) }))
.mutation(({ input, ctx }) => {
const newPost = {
id: Math.random().toString(36).substring(2, 9),
title: input.title,
content: input.content,
authorId: ctx.userId, // userId is guaranteed to be present by protectedProcedure
};
posts.push(newPost);
return newPost;
}),
getAll: publicProcedure
.query(() => {
return posts;
}),
}),
user: createTRPCRouter({
getProfile: protectedProcedure
.query(({ ctx }) => {
// In a real app, fetch user profile from DB using ctx.userId
return { id: ctx.userId, name: `User ${ctx.userId}` };
}),
}),
});
// Export type definition of API
export type AppRouter = typeof appRouter;
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { createTRPCContext } from '@/server/trpc';
import { type NextRequest } from 'next/server';
/**
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
* handling a HTTP request (e.g., when you make requests from the client).
*/
const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
});
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createContext(req),
onError({ error, path }) {
console.error(`❌ tRPC failed on ${path ?? '<no-path>'}: ${error.message}`);
},
});
export { handler as GET, handler as POST };
tRPC Client Setup with TanStack Query v5
The client setup involves creating a tRPC client instance and integrating it with TanStack Query's QueryClientProvider. Hydration is crucial for server-side rendered (SSR) or static site generated (SSG) pages.
// src/trpc/react.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import { useState } from 'react';
import superjson from 'superjson';
import { type AppRouter } from '@/server/routers/_app';
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching on first mount.
staleTime: 60 * 1000,
},
},
});
let clientQueryClient: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === 'undefined') {
// Server: always make a new query client
return createQueryClient();
} else {
// Browser: make a new query client if we don't already have one
// This is to make sure we use the same query client across the entire browser session
return (clientQueryClient ??= createQueryClient());
}
};
export const api = createTRPCReact<AppRouter>();
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === 'development' ||
(op.direction === 'down' && op.result instanceof Error),
}),
httpBatchLink({
url: '/api/trpc', // Relative path for Next.js API routes
transformer: superjson,
}),
],
}),
);
return (
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
);
}
// src/app/layout.tsx
import { TRPCReactProvider } from '@/trpc/react';
import './globals.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<TRPCReactProvider>{children}</TRPCReactProvider>
</body>
</html>
);
}
Server Actions Integration
Server Actions can be used for mutations that benefit from direct server interaction, especially for forms or optimistic updates.
// src/app/actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
// Simulate a database for Server Actions
const serverActionPosts: { id: string; title: string; content: string }[] = [];
const createPostSchema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(1, 'Content is required'),
});
export async function createPostAction(prevState: any, formData: FormData) {
const rawFormData = {
title: formData.get('title'),
content: formData.get('content'),
};
const validatedFields = createPostSchema.safeParse(rawFormData);
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Failed to create post.',
};
}
const { title, content } = validatedFields.data;
// Simulate DB operation
const newPost = {
id: Math.random().toString(36).substring(2, 9),
title,
content,
};
serverActionPosts.push(newPost);
// Revalidate paths to show new data
revalidatePath('/server-actions-posts');
redirect('/server-actions-posts'); // Redirect after successful creation
return { message: 'Post created successfully.' };
}
export async function getPostsAction() {
// Simulate fetching posts
return serverActionPosts;
}
Hybrid Data Fetching & Mutation
Combining tRPC and Server Actions.
// src/app/page.tsx
import { api } from '@/trpc/react';
import { createPostAction, getPostsAction } from './actions';
import { PostForm } from './post-form'; // A client component for the form
export default async function HomePage() {
// Fetch posts using tRPC on the server (SSR)
const trpcPosts = await api.ssr.post.getAll.fetch();
// Fetch posts using Server Action on the server (SSR)
const serverActionPosts = await getPostsAction();
return (
<main className="p-4">
<h1 className="text-2xl font-bold mb-4">Full-Stack Type Safety with tRPC & Server Actions</h1>
<section className="mb-8">
<h2 className="text-xl font-semibold mb-2">tRPC Posts (SSR)</h2>
<ul className="list-disc pl-5">
{trpcPosts.map((post) => (
<li key={post.id}>
<strong>{post.title}</strong> by {post.authorId}
<p className="text-sm text-gray-600">{post.content}</p>
</li>
))}
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold mb-2">Server Action Posts (SSR)</h2>
<ul className="list-disc pl-5">
{serverActionPosts.map((post) => (
<li key={post.id}>
<strong>{post.title}</strong>
<p className="text-sm text-gray-600">{post.content}</p>
</li>
))}
</ul>
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold mb-2">Create Post (tRPC Mutation - Client)</h2>
<CreateTRPCPostForm />
</section>
<section>
<h2 className="text-xl font-semibold mb-2">Create Post (Server Action - Client)</h2>
<PostForm /> {/* This component uses `useFormState` and `createPostAction` */}
</section>
</main>
);
}
// Client component for tRPC mutation
function CreateTRPCPostForm() {
const createPost = api.post.create.useMutation({
onSuccess: () => {
// Invalidate cache to refetch all posts after successful creation
api.post.getAll.invalidate();
alert('tRPC Post created!');
},
onError: (error) => {
alert(`Error creating tRPC post: ${error.message}`);
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const title = formData.get('title') as string;
const content = formData.get('content') as string;
createPost.mutate({ title, content });
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="trpc-title" className="block text-sm font-medium text-gray-700">Title</label>
<input type="text" id="trpc-title" name="title" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
</div>
<div>
<label htmlFor="trpc-content" className="block text-sm font-medium text-gray-700">Content</label>
<textarea id="trpc-content" name="content" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"></textarea>
</div>
<button type="submit" disabled={createPost.isLoading} className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{createPost.isLoading ? 'Creating...' : 'Create tRPC Post'}
</button>
</form>
);
}
// src/app/post-form.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPostAction } from './actions';
const initialState = {
message: '',
errors: undefined,
};
export function PostForm() {
const [state, formAction] = useFormState(createPostAction, initialState);
const { pending } = useFormStatus();
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="sa-title" className="block text-sm font-medium text-gray-700">Title</label>
<input type="text" id="sa-title" name="title" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" />
{state.errors?.title && (
<p className="text-red-500 text-xs mt-1">{state.errors.title.join(', ')}</p>
)}
</div>
<div>
<label htmlFor="sa-content" className="block text-sm font-medium text-gray-700">Content</label>
<textarea id="sa-content" name="content" required className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2"></textarea>
{state.errors?.content && (
<p className="text-red-500 text-xs mt-1">{state.errors.content.join(', ')}</p>
)}
</div>
<button type="submit" disabled={pending} className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50">
{pending ? 'Creating...' : 'Create Server Action Post'}
</button>
{state.message && <p className="mt-2 text-sm text-green-600">{state.message}</p>}
</form>
);
}
Production Gotchas & Troubleshooting
-
Serialization Errors with
superjson:- Problem: You might encounter errors like "Cannot stringify arbitrary non-POJOs" or "Cannot deserialize value" when passing complex objects (e.g.,
Date,Map,Set) between client and server. - Cause: tRPC uses
superjsonby default, but if you forget to configure it on both client and server, or if a Server Action attempts to pass a non-serializable object directly, issues arise. - Fix: Ensure
superjsonis configured ininitTRPCon the server andhttpBatchLinkon the client. For Server Actions, explicitly serialize/deserialize complex types or ensure they are plain JavaScript objects.
- Problem: You might encounter errors like "Cannot stringify arbitrary non-POJOs" or "Cannot deserialize value" when passing complex objects (e.g.,
-
TRPCClientErroron Server-Side (SSR/RSC):- Problem: When fetching tRPC data in a Server Component (e.g.,
await api.ssr.post.getAll.fetch()), you might seeTRPCClientErroror network-related errors. - Cause: The tRPC client configured for SSR needs to know the full URL if it's making an external request, or it needs to be configured to use a relative path if it's an internal API route.
- Fix: For internal API routes, ensure the
httpBatchLinkURL is/api/trpc. If deploying to a Vercel-like environment, theVERCEL_URLenvironment variable can be used to construct a full URL for external calls, but for internal calls within Next.js, relative paths are preferred. ThecreateTRPCReactsetup handles this by providing a separategetQueryClientfor server and client.
- Problem: When fetching tRPC data in a Server Component (e.g.,
-
Next.js Cache Invalidation with Server Actions:
- Problem: After a Server Action mutates data, client-side components might not reflect the changes immediately, or
revalidatePath/revalidateTagdon't seem to work. - Cause:
revalidatePathandrevalidateTagonly invalidate the Next.js Data Cache for Server Components. They do not automatically invalidate TanStack Query's client-side cache. - Fix: If a Server Action modifies data that is also fetched by tRPC/TanStack Query, you must manually invalidate the relevant TanStack Query keys on the client after the Server Action completes. This can be done by calling
queryClient.invalidateQueries()in a client-sideuseEffectthat monitors the Server Action's state, or by performing a tRPC mutation that then invalidates.
- Problem: After a Server Action mutates data, client-side components might not reflect the changes immediately, or
-
Authentication Context in tRPC Middleware:
- Problem:
ctx.userIdisundefinedinprotectedProceduredespite the user being logged in. - Cause: The
createTRPCContextfunction isn't correctly extracting authentication information from the incoming request. - Fix: Ensure
createTRPCContextcorrectly reads authentication tokens (e.g., fromAuthorizationheader, cookies) and populates the context. For Next.js App Router, cookies can be accessed viacookies().get('token')fromnext/headersin the server context.
- Problem:
-
Zod Validation Errors Not Propagating:
- Problem: Client-side forms using Server Actions or tRPC don't display detailed validation errors.
- Cause: The error formatter in tRPC or the error handling in Server Actions isn't structured to return detailed Zod errors.
- Fix: For tRPC, ensure the
errorFormatterininitTRPCextractsZodErrordetails. For Server Actions,safeParseand returningvalidatedFields.error.flatten().fieldErrorsis the correct approach, as demonstrated. The client component then needs to render these errors.
Frequently Asked Questions
1. Why use tRPC when Next.js Server Actions provide type safety?
tRPC and Server Actions address different needs, though with some overlap. tRPC provides a full-fledged RPC layer with features like automatic batching, subscriptions, and a robust middleware system for authentication, logging, and rate limiting. It's ideal for complex data fetching, real-time updates, and general API interactions. Server Actions are excellent for form submissions, optimistic UI updates, and direct server-side mutations where a full API layer might be overkill. The type safety is a shared benefit, but tRPC's ecosystem (TanStack Query integration, transformers) is more mature for complex data management.
2. How do I handle authentication and authorization with this setup?
For tRPC, implement authentication in createTRPCContext by parsing headers or cookies to identify the user. Then, use tRPC middleware (e.g., enforceUserIsAuthed) to protect procedures. For Server Actions, authentication must be handled within the action itself, typically by reading cookies or session data using next/headers utilities. Authorization (checking if a user has permission to perform an action) should be done within both tRPC procedures and Server Actions, after authentication.
3. Can I use tRPC queries in Server Components?
Yes, as demonstrated in app/page.tsx. You can use await api.ssr.yourProcedure.fetch() directly in a Server Component. This performs a direct function call on the server, bypassing HTTP for internal calls, and provides full type safety. This is a powerful feature for SSR/SSG.
4. What's the best way to manage shared types between tRPC and Server Actions?
Define your Zod schemas and TypeScript types in a shared directory (e.g., src/schemas or src/types). Both tRPC input validators and Server Action payload validators can then import and use these schemas, ensuring consistency and type safety across your application.
5. How do I implement optimistic updates with Server Actions and TanStack Query?
While Server Actions don't directly integrate with TanStack Query's optimistic updates, you can combine them. When a Server Action is triggered, you can manually update the TanStack Query cache using queryClient.setQueryData before the Server Action completes. If the Server Action fails, you can then revert the cache. After a successful Server Action, you'd typically call queryClient.invalidateQueries() to refetch the data and ensure consistency. This requires careful orchestration on the client side.
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

TanStack Query v5 with Next.js 15: Optimistic Updates, Cache Sync & Server Actions
Comprehensive guide covering tanstack query v5 with next.js 15: optimistic updates, cache sync & server actions with production-grade architecture and code examples.
Read more
React 19 Actions in Practice: useActionState, useOptimistic & Server Action Resiliency
Comprehensive guide covering react 19 actions in practice: useactionstate, useoptimistic & server action resiliency with production-grade architecture and code examples.
Read more
Next.js 15 Partial Prerendering (PPR): Combining Static Shells with Dynamic Streaming
Comprehensive guide covering next.js 15 partial prerendering (ppr): combining static shells with dynamic streaming with production-grade architecture and code examples.
Read more