1 min read
Web DevelopmenttRPC: End-to-End Type Safety ohne Code-Generierung
tRPC für vollständig typsichere APIs zwischen Frontend und Backend. Keine Schemas, keine Code-Generierung – nur TypeScript.
tRPCType SafetyAPITypeScriptFull-StackEnd-to-End Types

tRPC: End-to-End Type Safety ohne Code-Generierung
Meta-Description: tRPC für vollständig typsichere APIs zwischen Frontend und Backend. Keine Schemas, keine Code-Generierung – nur TypeScript.
Keywords: tRPC, Type Safety, API, TypeScript, Full-Stack, End-to-End Types, React Query, Next.js
Einführung
tRPC eliminiert die Grenze zwischen Frontend und Backend. Ein TypeScript-Typ, der vom Server kommt, ist automatisch im Client verfügbar – ohne REST, ohne GraphQL, ohne Code-Generierung.
Das Problem
// REST: Kein automatischer Type-Share
// Backend
app.get('/api/users/:id', async (req, res) => {
const user = await getUser(req.params.id);
res.json(user);
});
// Frontend - Types müssen manuell synchronisiert werden
const response = await fetch('/api/users/123');
const user: User = await response.json(); // Hoffen dass es stimmt...Die Lösung: tRPC
// Router Definition (Backend)
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const appRouter = t.router({
user: t.router({
getById: t.procedure
.input(z.string())
.query(async ({ input }) => {
return await prisma.user.findUnique({
where: { id: input }
});
})
})
});
export type AppRouter = typeof appRouter;
// Client (Frontend)
import { trpc } from './utils/trpc';
function UserProfile({ userId }: { userId: string }) {
// Vollständig typisiert! 🎉
const { data: user } = trpc.user.getById.useQuery(userId);
// user ist automatisch typisiert als:
// { id: string; name: string; email: string; ... } | null | undefined
}Setup mit Next.js App Router
1. Server-Setup
// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
import superjson from 'superjson';
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;
// Middleware für Auth
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
...ctx,
user: ctx.session.user
}
});
});2. Router Definition
// src/server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
import { postRouter } from './post';
export const appRouter = router({
user: userRouter,
post: postRouter
});
export type AppRouter = typeof appRouter;// src/server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const userRouter = router({
// Public Query
getById: publicProcedure
.input(z.string())
.query(async ({ input }) => {
return prisma.user.findUnique({
where: { id: input },
select: {
id: true,
name: true,
email: true,
image: true
}
});
}),
// Protected Query
getMe: protectedProcedure
.query(async ({ ctx }) => {
return prisma.user.findUnique({
where: { id: ctx.user.id }
});
}),
// Mutation mit Validation
update: protectedProcedure
.input(z.object({
name: z.string().min(2).optional(),
bio: z.string().max(500).optional()
}))
.mutation(async ({ ctx, input }) => {
return prisma.user.update({
where: { id: ctx.user.id },
data: input
});
})
});3. API Route Handler
// src/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 };4. Client Setup
// src/utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();// src/app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/utils/trpc';
import superjson from 'superjson';
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson
})
]
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}Client-Side Usage
Queries
'use client';
import { trpc } from '@/utils/trpc';
function UserProfile({ userId }: { userId: string }) {
// Basic Query
const { data, isLoading, error } = trpc.user.getById.useQuery(userId);
// Mit Options
const { data: user } = trpc.user.getById.useQuery(userId, {
enabled: !!userId,
staleTime: 60 * 1000,
refetchOnWindowFocus: false
});
// Suspense Query
const [user] = trpc.user.getById.useSuspenseQuery(userId);
if (isLoading) return <Skeleton />;
if (error) return <Error message={error.message} />;
return <div>{data?.name}</div>;
}Mutations
function UpdateProfileForm() {
const utils = trpc.useUtils();
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
// Cache invalidieren
utils.user.getMe.invalidate();
},
onError: (error) => {
// Zod Errors sind typisiert
if (error.data?.zodError) {
console.log(error.data.zodError.fieldErrors);
}
}
});
const handleSubmit = (data: FormData) => {
updateUser.mutate({
name: data.get('name') as string
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" />
<button disabled={updateUser.isPending}>
{updateUser.isPending ? 'Speichern...' : 'Speichern'}
</button>
</form>
);
}Optimistic Updates
function TodoList() {
const utils = trpc.useUtils();
const addTodo = trpc.todo.create.useMutation({
// Optimistic Update
onMutate: async (newTodo) => {
await utils.todo.list.cancel();
const previousTodos = utils.todo.list.getData();
utils.todo.list.setData(undefined, (old) => [
...(old ?? []),
{ id: 'temp', ...newTodo, createdAt: new Date() }
]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
utils.todo.list.setData(undefined, context?.previousTodos);
},
onSettled: () => {
utils.todo.list.invalidate();
}
});
return (/* ... */);
}Server-Side Rendering
// src/app/users/[id]/page.tsx
import { createServerSideHelpers } from '@trpc/react-query/server';
import { appRouter } from '@/server/routers/_app';
import superjson from 'superjson';
export default async function UserPage({
params
}: {
params: { id: string }
}) {
const helpers = createServerSideHelpers({
router: appRouter,
ctx: await createContext(),
transformer: superjson
});
// Prefetch on Server
await helpers.user.getById.prefetch(params.id);
return (
<HydrationBoundary state={helpers.dehydrate()}>
<UserProfile userId={params.id} />
</HydrationBoundary>
);
}Subscriptions (WebSocket)
// Server
export const chatRouter = router({
onMessage: publicProcedure
.input(z.object({ roomId: z.string() }))
.subscription(async function* ({ input }) {
// Async Generator für Subscription
for await (const message of messageStream(input.roomId)) {
yield message;
}
})
});
// Client
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
trpc.chat.onMessage.useSubscription(
{ roomId },
{
onData: (message) => {
setMessages(prev => [...prev, message]);
}
}
);
return (/* ... */);
}Error Handling
// Server-Side Error
import { TRPCError } from '@trpc/server';
export const postRouter = router({
delete: protectedProcedure
.input(z.string())
.mutation(async ({ ctx, input }) => {
const post = await prisma.post.findUnique({
where: { id: input }
});
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post nicht gefunden'
});
}
if (post.authorId !== ctx.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Keine Berechtigung'
});
}
return prisma.post.delete({ where: { id: input } });
})
});
// Client-Side Handling
const deletePost = trpc.post.delete.useMutation({
onError: (error) => {
switch (error.data?.code) {
case 'NOT_FOUND':
toast.error('Post existiert nicht');
break;
case 'FORBIDDEN':
toast.error('Keine Berechtigung');
break;
default:
toast.error('Ein Fehler ist aufgetreten');
}
}
});Fazit
tRPC bietet:
- Zero-Config Type Safety: TypeScript-Types automatisch geteilt
- Keine Code-Generierung: Kein GraphQL Codegen, kein OpenAPI
- React Query Integration: Caching, Optimistic Updates built-in
- Zod Validation: Runtime + Compile-Time Safety
Für TypeScript-Monorepos ist tRPC die perfekte API-Lösung.
Bildprompts
- "TypeScript types flowing seamlessly between server and client, connected code blocks"
- "API calls with automatic type completion, developer productivity visualization"
- "Bridge connecting frontend and backend with TypeScript logo, full-stack concept"