tRPC: End-to-end-typsäkerhet utan ceremonin
Hur tRPC eliminerar API-kontraktproblemet, fungerar med Next.js App Router, hanterar auth-middleware, filuppladdningar och subscriptions. En verklig tRPC-setup från grunden.
Du vet hur det går till. Du ändrar ett fältnamn i ditt API-svar. Du uppdaterar backend-typen. Du deployar. Sedan går frontenden sönder i produktion för att någon glömde uppdatera fetch-anropet på rad 247 i Dashboard.tsx. Fältet är nu undefined, komponenten renderar tomt, och din felspårning lyser upp klockan 2 på natten.
Det här är API-kontraktproblemet. Det är inte ett teknologiproblem. Det är ett koordineringsproblem. Och ingen mängd Swagger-dokumentation eller GraphQL-scheman kan lösa faktumet att din frontends och backends typer kan glida isär tyst.
tRPC löser detta genom att vägra låta dem glida isär. Det finns ingen schemafil. Inget kodgenereringssteg. Inget separat kontrakt att underhålla. Du skriver en TypeScript-funktion på servern, och klienten känner till dess exakta in- och utdatatyper vid kompileringstid. Om du byter namn på ett fält kompileras inte frontenden förrän du fixar det.
Det är löftet. Låt mig visa dig hur det faktiskt fungerar, var det lyser, och var du absolut inte bör använda det.
Problemet, mer precist#
Låt oss titta på hur de flesta team bygger API:er idag.
REST + OpenAPI: Du skriver dina endpoints. Kanske lägger du till Swagger-annotationer. Kanske genererar du ett klient-SDK från OpenAPI-specen. Men specen är en separat artefakt. Den kan bli inaktuell. Genereringssteget är ytterligare en sak i din CI-pipeline som kan gå sönder eller glömmas bort. Och de genererade typerna är ofta fula — djupt nästlade paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"]-monster.
GraphQL: Bättre typsäkerhet, men enorm ceremoni. Du skriver ett schema i SDL. Du skriver resolvers. Du genererar typer från schemat. Du skriver queries på klienten. Du genererar typer från queries. Det är minst två kodgenereringssteg, en schemafil och ett byggsteg som alla måste komma ihåg att köra. För ett team som redan kontrollerar både frontend och backend är detta mycket infrastruktur för ett problem som har en enklare lösning.
Manuella fetch-anrop: Det vanligaste tillvägagångssättet och det farligaste. Du skriver fetch("/api/users"), castar resultatet till User[] och hoppas på det bästa. Det finns noll kompileringstidssäkerhet. Typcastningen är en lögn du berättar för TypeScript.
// Lögnen som varje frontendutvecklare har berättat
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // 🙏 hoppas detta stämmertRPC tar ett helt annat tillvägagångssätt. Istället för att beskriva ditt API i ett separat format och generera typer skriver du vanliga TypeScript-funktioner på servern och importerar deras typer direkt till klienten. Inget genereringssteg. Ingen schemafil. Ingen drift.
Grundläggande koncept#
Innan vi sätter upp något, låt oss förstå den mentala modellen.
Router#
En tRPC-router är en samling procedurer grupperade tillsammans. Tänk på det som en controller i MVC, förutom att det bara är ett vanligt objekt med inbyggd typinferens.
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const appRouter = t.router({
user: t.router({
list: t.procedure.query(/* ... */),
byId: t.procedure.input(/* ... */).query(/* ... */),
create: t.procedure.input(/* ... */).mutation(/* ... */),
}),
post: t.router({
list: t.procedure.query(/* ... */),
publish: t.procedure.input(/* ... */).mutation(/* ... */),
}),
});
export type AppRouter = typeof appRouter;Den där AppRouter-typexporten är hela magin. Klienten importerar denna typ — inte körkoden, bara typen — och får full autokomplettering och typkontroll för varje procedur.
Procedur#
En procedur är en enskild endpoint. Det finns tre typer:
- Query: Läsoperationer. Mappar till HTTP GET-semantik. Cachas av TanStack Query.
- Mutation: Skrivoperationer. Mappar till HTTP POST. Cachas inte.
- Subscription: Realtidsströmmar. Använder WebSockets.
Kontext#
Kontext är den förfrågansscopade datan som är tillgänglig för varje procedur. Databasanslutningar, den autentiserade användaren, förfrågningshuvuden — allt du skulle lägga i Express req-objekt hamnar här.
Middleware#
Middleware transformerar kontexten eller begränsar åtkomst. Det vanligaste mönstret är en auth-middleware som kontrollerar en giltig session och lägger till ctx.user.
Typinferenskedjan#
Detta är den kritiska mentala modellen. När du definierar en procedur så här:
t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
// input är typad som { id: string }
return db.user.findUnique({ where: { id: input.id } });
});Returtypen flödar hela vägen till klienten. Om db.user.findUnique returnerar User | null kommer klientens useQuery-hook att ha data typad som User | null. Ingen manuell typning. Ingen casting. Det infereras end-to-end.
Setup med Next.js App Router#
Låt oss bygga detta från grunden. Jag antar att du har ett Next.js 14+-projekt med App Router.
Installera beroenden#
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodSteg 1: Initiera tRPC på servern#
Skapa din tRPC-instans och definiera kontexttypen.
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import superjson from "superjson";
import { ZodError } from "zod";
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
const session = await getSession(opts.req);
return {
session,
db: prisma,
req: opts.req,
};
}
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createCallerFactory = t.createCallerFactory;
export const router = t.router;
export const publicProcedure = t.procedure;Några saker att notera:
superjson-transformer: tRPC serialiserar data som JSON som standard, vilket innebär attDate-objekt,Map,Setoch andra icke-JSON-typer går förlorade. superjson bevarar dem.- Felformaterare: Vi bifogar Zod-valideringsfel till svaret så klienten kan visa fältnivåfel.
createTRPCContext: Denna funktion körs vid varje förfrågan. Det är här du tolkar sessionen, sätter upp databasanslutningen och bygger kontextobjektet.
Steg 2: Definiera din router#
// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
export const userRouter = router({
me: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: ctx.user.id },
select: {
id: true,
name: true,
email: true,
image: true,
createdAt: true,
},
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const updated = await ctx.db.user.update({
where: { id: ctx.user.id },
data: {
name: input.name,
bio: input.bio,
},
});
return updated;
}),
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUnique({
where: { id: input.id },
select: {
id: true,
name: true,
image: true,
bio: true,
},
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
return user;
}),
});// src/server/routers/_app.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";
import { notificationRouter } from "./notification";
export const appRouter = router({
user: userRouter,
post: postRouter,
notification: notificationRouter,
});
export type AppRouter = typeof appRouter;Steg 3: Exponera via Next.js Route Handler#
I App Router körs tRPC som en standard Route Handler. Ingen anpassad server, inget speciellt Next.js-plugin.
// 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";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: createTRPCContext,
onError:
process.env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
);
}
: undefined,
});
export { handler as GET, handler as POST };Det är allt. Både GET och POST hanteras. Queries går via GET (med URL-kodad indata), mutationer via POST.
Steg 4: Sätt upp klienten#
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();Observera: vi importerar AppRouter som enbart en typ. Ingen serverkod läcker in i klientbundlen.
Steg 5: Provider-setup#
// src/components/providers/TRPCProvider.tsx
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import superjson from "superjson";
function getBaseUrl() {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
})
);
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}// src/app/layout.tsx (relevant del)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}Steg 6: Använd i komponenter#
// src/app/dashboard/page.tsx
"use client";
import { trpc } from "@/lib/trpc";
export default function DashboardPage() {
const { data: user, isLoading, error } = trpc.user.me.useQuery();
if (isLoading) return <DashboardSkeleton />;
if (error) return <ErrorDisplay message={error.message} />;
return (
<div>
<h1>Välkommen tillbaka, {user.name}</h1>
<p>Medlem sedan {user.createdAt.toLocaleDateString()}</p>
</div>
);
}Den där user.name är fullt typad. Om du stavar fel som user.nme fångar TypeScript det omedelbart. Om du ändrar servern att returnera displayName istället för name kommer varje klientanvändning att visa ett kompileringsfel. Inga överraskningar vid körning.
Kontext och middleware#
Kontext och middleware är där tRPC går från "fiffigt typtrick" till "produktionsklart ramverk."
Skapa kontext#
Kontextfunktionen körs vid varje förfrågan. Här är en version från verkligheten:
// src/server/trpc.ts
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function createTRPCContext(opts: FetchCreateContextFnOptions) {
const session = await getServerSession(authOptions);
return {
session,
user: session?.user ?? null,
db: prisma,
headers: Object.fromEntries(opts.req.headers),
};
}
type Context = Awaited<ReturnType<typeof createTRPCContext>>;Auth-middleware#
Det vanligaste middleware-mönstret är att separera publika från skyddade procedurer:
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to perform this action",
});
}
return next({
ctx: {
// Överskrid kontexttypen — user är inte längre nullable
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);Efter att denna middleware har körts är ctx.user i alla protectedProcedure garanterat icke-null. Typsystemet upprätthåller detta. Du kan inte av misstag komma åt ctx.user.id i en publik procedur utan att TypeScript klagar.
Rollbaserad middleware#
Du kan komponera middleware för mer detaljerad åtkomstkontroll:
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (ctx.user.role !== "ADMIN") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
});
}
return next({
ctx: {
user: ctx.user,
},
});
});
export const adminProcedure = t.procedure.use(isAdmin);Loggnings-middleware#
Middleware är inte bara för autentisering. Här är en prestandaloggnings-middleware:
const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(`⚠️ Slow ${type} ${path}: ${duration}ms`);
}
return result;
});
// Tillämpa på alla procedurer
export const publicProcedure = t.procedure.use(loggerMiddleware);Hastighetsbegränsande middleware#
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, "10 s"),
});
const rateLimitMiddleware = t.middleware(async ({ ctx, next }) => {
const ip = ctx.headers["x-forwarded-for"] ?? "unknown";
const { success, remaining } = await ratelimit.limit(ip);
if (!success) {
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `Rate limit exceeded. Try again later.`,
});
}
return next();
});Indatavalidering med Zod#
tRPC använder Zod för indatavalidering. Detta är inte valfri dekoration — det är mekanismen som säkerställer att indata är säker på både klient och server.
Grundläggande validering#
const postRouter = router({
create: protectedProcedure
.input(
z.object({
title: z.string().min(1, "Title is required").max(200, "Title too long"),
content: z.string().min(10, "Content must be at least 10 characters"),
categoryId: z.string().uuid("Invalid category ID"),
tags: z.array(z.string()).max(5, "Maximum 5 tags").default([]),
published: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// input är fullt typad:
// {
// title: string;
// content: string;
// categoryId: string;
// tags: string[];
// published: boolean;
// }
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});Tricket med dubbel validering#
Här är något subtilt: Zod-validering körs på båda sidor. På klienten validerar tRPC indata innan förfrågan skickas. Om indata är ogiltig lämnar förfrågan aldrig webbläsaren. På servern valideras samma schema igen som en säkerhetsåtgärd.
Det innebär att du får omedelbar klientsidesvalidering gratis:
"use client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
export function CreatePostForm() {
const [title, setTitle] = useState("");
const utils = trpc.useUtils();
const createPost = trpc.post.create.useMutation({
onSuccess: () => {
utils.post.list.invalidate();
},
onError: (error) => {
// Zod-fel anländer strukturerade
if (error.data?.zodError) {
const fieldErrors = error.data.zodError.fieldErrors;
// fieldErrors.title?: string[]
// fieldErrors.content?: string[]
console.log(fieldErrors);
}
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
createPost.mutate({
title,
content: "...",
categoryId: "...",
});
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
{createPost.error?.data?.zodError?.fieldErrors.title && (
<span className="text-red-500">
{createPost.error.data.zodError.fieldErrors.title[0]}
</span>
)}
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? "Skapar..." : "Skapa inlägg"}
</button>
</form>
);
}Komplexa indatamönster#
// Diskriminerade unioner
const searchInput = z.discriminatedUnion("type", [
z.object({
type: z.literal("user"),
query: z.string(),
includeInactive: z.boolean().default(false),
}),
z.object({
type: z.literal("post"),
query: z.string(),
category: z.string().optional(),
}),
]);
// Pagineringsdata återanvänd över procedurer
const paginationInput = z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).max(100).default(20),
});
const postRouter = router({
infiniteList: publicProcedure
.input(
z.object({
...paginationInput.shape,
category: z.string().optional(),
sortBy: z.enum(["newest", "popular", "trending"]).default("newest"),
})
)
.query(async ({ ctx, input }) => {
const { cursor, limit, category, sortBy } = input;
const posts = await ctx.db.post.findMany({
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
where: category ? { categoryId: category } : undefined,
orderBy:
sortBy === "newest"
? { createdAt: "desc" }
: sortBy === "popular"
? { likes: "desc" }
: { score: "desc" },
});
let nextCursor: string | undefined;
if (posts.length > limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return {
posts,
nextCursor,
};
}),
});Valfri indata#
Inte varje procedur behöver indata. Queries gör det ofta inte:
const statsRouter = router({
// Ingen indata behövs
overview: publicProcedure.query(async ({ ctx }) => {
const [userCount, postCount, commentCount] = await Promise.all([
ctx.db.user.count(),
ctx.db.post.count(),
ctx.db.comment.count(),
]);
return { userCount, postCount, commentCount };
}),
// Valfria filter
detailed: publicProcedure
.input(
z
.object({
from: z.date().optional(),
to: z.date().optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const where = {
...(input?.from && { createdAt: { gte: input.from } }),
...(input?.to && { createdAt: { lte: input.to } }),
};
return ctx.db.post.groupBy({
by: ["categoryId"],
where,
_count: true,
});
}),
});Felhantering#
tRPC:s felhantering är strukturerad, typsäker och integreras smidigt med både HTTP-semantik och klientsidans UI.
Kasta fel på servern#
import { TRPCError } from "@trpc/server";
const postRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.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",
});
}
await ctx.db.post.delete({ where: { id: input.id } });
return { success: true };
}),
});tRPC-felkoder mappar till HTTP-statuskoder:
| tRPC-kod | HTTP-status | När den ska användas |
|---|---|---|
BAD_REQUEST | 400 | Ogiltig indata utöver Zod-validering |
UNAUTHORIZED | 401 | Inte inloggad |
FORBIDDEN | 403 | Inloggad men otillräckliga behörigheter |
NOT_FOUND | 404 | Resursen finns inte |
CONFLICT | 409 | Dubblettresurs |
TOO_MANY_REQUESTS | 429 | Hastighetsgräns överskriden |
INTERNAL_SERVER_ERROR | 500 | Oväntat serverfel |
Anpassad felformatering#
Minns du felformateraren från vår setup? Så här fungerar den i praktiken:
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
// Lägg till anpassade fält
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
},
};
},
});Klientsidens felhantering#
"use client";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
export function DeletePostButton({ postId }: { postId: string }) {
const utils = trpc.useUtils();
const deletePost = trpc.post.delete.useMutation({
onSuccess: () => {
toast.success("Inlägg raderat");
utils.post.list.invalidate();
},
onError: (error) => {
switch (error.data?.code) {
case "FORBIDDEN":
toast.error("Du har inte behörighet att radera detta inlägg");
break;
case "NOT_FOUND":
toast.error("Detta inlägg finns inte längre");
utils.post.list.invalidate();
break;
default:
toast.error("Något gick fel. Försök igen.");
}
},
});
return (
<button
onClick={() => deletePost.mutate({ id: postId })}
disabled={deletePost.isPending}
>
{deletePost.isPending ? "Raderar..." : "Radera"}
</button>
);
}Global felhantering#
Du kan sätta upp en global felhanterare som fångar alla ohanterade tRPC-fel:
// I din TRPCProvider
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Globalt reservfall för ohanterade mutationsfel
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") {
// Omdirigera till inloggning
window.location.href = "/login";
return;
}
toast.error(error.message);
}
},
},
},
})
);Mutationer och optimistiska uppdateringar#
Mutationer är där tRPC verkligen integreras väl med TanStack Query. Låt oss titta på ett mönster från verkligheten: en gilla-knapp med optimistiska uppdateringar.
Grundläggande mutation#
// Server
const postRouter = router({
toggleLike: protectedProcedure
.input(z.object({ postId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.like.findUnique({
where: {
userId_postId: {
userId: ctx.user.id,
postId: input.postId,
},
},
});
if (existing) {
await ctx.db.like.delete({
where: { id: existing.id },
});
return { liked: false };
}
await ctx.db.like.create({
data: {
userId: ctx.user.id,
postId: input.postId,
},
});
return { liked: true };
}),
});Optimistiska uppdateringar#
Användaren klickar "Gilla." Du vill inte vänta 200ms på serversvaret för att uppdatera gränssnittet. Optimistiska uppdateringar löser detta: uppdatera gränssnittet omedelbart, rulla sedan tillbaka om servern avvisar.
"use client";
import { trpc } from "@/lib/trpc";
export function LikeButton({ postId, initialLiked, initialCount }: {
postId: string;
initialLiked: boolean;
initialCount: number;
}) {
const utils = trpc.useUtils();
const toggleLike = trpc.post.toggleLike.useMutation({
onMutate: async ({ postId }) => {
// Avbryt utgående omhämtningar så de inte skriver över vår optimistiska uppdatering
await utils.post.byId.cancel({ id: postId });
// Ta en ögonblicksbild av det föregående värdet
const previousPost = utils.post.byId.getData({ id: postId });
// Uppdatera cachen optimistiskt
utils.post.byId.setData({ id: postId }, (old) => {
if (!old) return old;
return {
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
};
});
// Returnera ögonblicksbilden för återställning
return { previousPost };
},
onError: (_error, { postId }, context) => {
// Rulla tillbaka till det föregående värdet vid fel
if (context?.previousPost) {
utils.post.byId.setData({ id: postId }, context.previousPost);
}
},
onSettled: (_data, _error, { postId }) => {
// Omhämta alltid efter fel eller framgång för att säkerställa servertillstånd
utils.post.byId.invalidate({ id: postId });
},
});
return (
<button
onClick={() => toggleLike.mutate({ postId })}
className={initialLiked ? "text-red-500" : "text-gray-400"}
>
♥ {initialCount}
</button>
);
}Mönstret är alltid detsamma:
onMutate: Avbryt queries, ta ögonblicksbild av aktuell data, tillämpa optimistisk uppdatering, returnera ögonblicksbild.onError: Rulla tillbaka med hjälp av ögonblicksbilden.onSettled: Invalidera frågan så den omhämtas från servern, oavsett framgång eller fel.
Denna trestegsdans säkerställer att gränssnittet alltid är responsivt och till slut konsistent med servern.
Invalidera relaterade queries#
Efter en mutation behöver du ofta uppdatera relaterad data. tRPC:s useUtils() gör detta ergonomiskt:
const createComment = trpc.comment.create.useMutation({
onSuccess: (_data, variables) => {
// Invalidera inläggets kommentarlista
utils.comment.listByPost.invalidate({ postId: variables.postId });
// Invalidera själva inlägget (kommentarantal ändrat)
utils.post.byId.invalidate({ id: variables.postId });
// Invalidera ALLA inläggslistor (kommentarantal i listvyer)
utils.post.list.invalidate();
},
});Batching och subscriptions#
HTTP-batching#
Som standard kombinerar tRPC med httpBatchLink flera samtidiga förfrågningar till ett enda HTTP-anrop. Om en komponent renderar och utlöser tre queries:
function Dashboard() {
const user = trpc.user.me.useQuery();
const posts = trpc.post.list.useQuery({ limit: 10 });
const stats = trpc.stats.overview.useQuery();
// ...
}Dessa tre queries batchas automatiskt till en enda HTTP-förfrågan: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...
Servern bearbetar alla tre, returnerar alla tre resultat i ett enda svar, och TanStack Query distribuerar resultaten till varje hook. Ingen konfiguration behövs.
Du kan inaktivera batching för specifika anrop om det behövs:
// I din provider, använd splitLink för att dirigera specifika procedurer annorlunda
import { splitLink, httpBatchLink, httpLink } from "@trpc/client";
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.path === "post.infiniteList",
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
maxURLLength: 2048,
}),
}),
],
})
);WebSocket-subscriptions#
För realtidsfunktioner stöder tRPC subscriptions över WebSockets. Detta kräver en separat WebSocket-server (Next.js stöder inte WebSockets nativt i Route Handlers).
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
// In-memory event emitter (använd Redis pub/sub i produktion)
import { EventEmitter } from "events";
const eventEmitter = new EventEmitter();
export const notificationRouter = router({
onNew: protectedProcedure.subscription(({ ctx }) => {
return observable<Notification>((emit) => {
const handler = (notification: Notification) => {
if (notification.userId === ctx.user.id) {
emit.next(notification);
}
};
eventEmitter.on("notification", handler);
return () => {
eventEmitter.off("notification", handler);
};
});
}),
// Mutation som utlöser en notifikation
markAsRead: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const notification = await ctx.db.notification.update({
where: { id: input.id, userId: ctx.user.id },
data: { readAt: new Date() },
});
return notification;
}),
});På klienten:
"use client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
trpc.notification.onNew.useSubscription(undefined, {
onData: (notification) => {
setNotifications((prev) => [notification, ...prev]);
toast.info(notification.message);
},
onError: (error) => {
console.error("Subscription error:", error);
},
});
return (
<div>
<span className="badge">{notifications.length}</span>
{/* notifikationslista UI */}
</div>
);
}För WebSocket-transporten behöver du en dedikerad serverprocess. Här är en minimal setup med ws-biblioteket:
// ws-server.ts (separat process)
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { appRouter } from "./server/routers/_app";
import { createTRPCContext } from "./server/trpc";
const wss = new WebSocketServer({ port: 3001 });
const handler = applyWSSHandler({
wss,
router: appRouter,
createContext: createTRPCContext,
});
wss.on("connection", (ws) => {
console.log(`Connection opened (${wss.clients.size} total)`);
ws.once("close", () => {
console.log(`Connection closed (${wss.clients.size} total)`);
});
});
process.on("SIGTERM", () => {
handler.broadcastReconnectNotification();
wss.close();
});
console.log("WebSocket server listening on ws://localhost:3001");Och klienten behöver en wsLink för subscriptions:
import { wsLink, createWSClient } from "@trpc/client";
const wsClient = createWSClient({
url: "ws://localhost:3001",
});
// Använd splitLink för att dirigera subscriptions via WebSocket
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
splitLink({
condition: (op) => op.type === "subscription",
true: wsLink({ client: wsClient, transformer: superjson }),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
}),
],
})
);Typsäkra filuppladdningar#
tRPC hanterar inte filuppladdningar nativt. Det är ett JSON-RPC-protokoll — binär data är inte dess område. Men du kan bygga ett typsäkert uppladdningsflöde genom att kombinera tRPC med försignerade URL:er.
Mönstret:
- Klienten ber tRPC om en försignerad uppladdnings-URL.
- tRPC validerar förfrågan, kontrollerar behörigheter, genererar URL:en.
- Klienten laddar upp direkt till S3 med den försignerade URL:en.
- Klienten meddelar tRPC att uppladdningen är klar.
// src/server/routers/upload.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export const uploadRouter = router({
getPresignedUrl: protectedProcedure
.input(
z.object({
filename: z.string().min(1).max(255),
contentType: z.string().regex(/^(image|application)\//),
size: z.number().max(10 * 1024 * 1024, "File must be under 10MB"),
})
)
.mutation(async ({ ctx, input }) => {
const key = `uploads/${ctx.user.id}/${crypto.randomUUID()}-${input.filename}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: input.contentType,
ContentLength: input.size,
});
const presignedUrl = await getSignedUrl(s3, command, {
expiresIn: 300, // 5 minuter
});
// Lagra väntande uppladdning i databasen
const upload = await ctx.db.upload.create({
data: {
key,
userId: ctx.user.id,
filename: input.filename,
contentType: input.contentType,
size: input.size,
status: "PENDING",
},
});
return {
uploadId: upload.id,
presignedUrl,
key,
};
}),
confirmUpload: protectedProcedure
.input(z.object({ uploadId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
const upload = await ctx.db.upload.findUnique({
where: { id: input.uploadId, userId: ctx.user.id },
});
if (!upload) {
throw new TRPCError({ code: "NOT_FOUND" });
}
// Verifiera att filen faktiskt finns i S3
// (valfritt men rekommenderat)
const confirmed = await ctx.db.upload.update({
where: { id: upload.id },
data: { status: "CONFIRMED" },
});
return {
url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${confirmed.key}`,
};
}),
});På klienten:
"use client";
import { trpc } from "@/lib/trpc";
import { useState, useCallback } from "react";
export function FileUpload() {
const [uploading, setUploading] = useState(false);
const getPresignedUrl = trpc.upload.getPresignedUrl.useMutation();
const confirmUpload = trpc.upload.confirmUpload.useMutation();
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
// Steg 1: Hämta försignerad URL från tRPC
const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type,
size: file.size,
});
// Steg 2: Ladda upp direkt till S3
const uploadResponse = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Upload failed");
}
// Steg 3: Bekräfta uppladdning via tRPC
const { url } = await confirmUpload.mutateAsync({ uploadId });
toast.success(`Fil uppladdad: ${url}`);
} catch (error) {
toast.error("Uppladdningen misslyckades. Försök igen.");
} finally {
setUploading(false);
}
},
[getPresignedUrl, confirmUpload]
);
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
accept="image/*"
/>
{uploading && <p>Laddar upp...</p>}
</div>
);
}Hela flödet är typsäkert. Svarstypen för försignerad URL, uppladdnings-ID-typen, bekräftelsesvaret — allt infereras från serverdefinitionerna. Om du lägger till ett nytt fält i svaret för försignerad URL vet klienten om det omedelbart.
Serversidesanrop och React Server Components#
Med Next.js App Router vill du ofta hämta data i Server Components. tRPC stöder detta genom serversideanropare:
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
// Användning i en Server Component
// src/app/posts/[id]/page.tsx
import { createTRPCContext } from "@/server/trpc";
import { createCaller } from "@/server/trpc";
export default async function PostPage({
params,
}: {
params: { id: string };
}) {
const ctx = await createTRPCContext({
req: new Request("http://localhost"),
resHeaders: new Headers(),
});
const caller = createCaller(ctx);
const post = await caller.post.byId({ id: params.id });
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
{/* Klientkomponenter för interaktiva delar */}
<LikeButton postId={post.id} initialLiked={post.liked} />
<CommentSection postId={post.id} />
</article>
);
}Detta ger dig det bästa av två världar: serverrenderad initial data med full typsäkerhet, och klientsidesinteraktivitet för mutationer och realtidsfunktioner.
Testa tRPC-procedurer#
Testning är enkel eftersom procedurer bara är funktioner. Du behöver inte starta en HTTP-server.
// src/server/routers/user.test.ts
import { describe, it, expect, vi } from "vitest";
import { appRouter } from "./routers/_app";
import { createCaller } from "./trpc";
describe("user router", () => {
it("returns the current user profile", async () => {
const caller = createCaller({
user: { id: "user-1", email: "test@example.com", role: "USER" },
db: prismaMock,
session: mockSession,
headers: {},
});
prismaMock.user.findUnique.mockResolvedValue({
id: "user-1",
name: "Test User",
email: "test@example.com",
image: null,
createdAt: new Date("2026-01-01"),
});
const result = await caller.user.me();
expect(result).toEqual({
id: "user-1",
name: "Test User",
email: "test@example.com",
image: null,
createdAt: new Date("2026-01-01"),
});
});
it("throws UNAUTHORIZED for unauthenticated requests", async () => {
const caller = createCaller({
user: null,
db: prismaMock,
session: null,
headers: {},
});
await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
});
it("validates input with Zod", async () => {
const caller = createCaller({
user: { id: "user-1", email: "test@example.com", role: "USER" },
db: prismaMock,
session: mockSession,
headers: {},
});
await expect(
caller.user.updateProfile({
name: "", // min length 1
})
).rejects.toThrow();
});
});Ingen mockande av HTTP-lager, ingen supertest, ingen ruttmatchning. Anropa bara funktionen och kontrollera resultatet. Detta är en av tRPC:s underuppskattade fördelar: testning är trivialt enkelt eftersom transportlagret är en implementationsdetalj.
När du INTE ska använda tRPC#
tRPC är inte en universell lösning. Här är var det faller isär:
Publika API:er#
Om du bygger ett API som externa utvecklare ska konsumera är tRPC fel val. Externa konsumenter har inte tillgång till dina TypeScript-typer. De behöver ett dokumenterat, stabilt kontrakt — OpenAPI/Swagger för REST, eller ett GraphQL-schema. tRPC:s typsäkerhet fungerar bara när både klient och server delar samma TypeScript-kodbas.
Mobilappar (om du inte använder TypeScript)#
Om din mobilapp är skriven i Swift, Kotlin eller Dart erbjuder tRPC inget. Typerna korsar inte språkgränser. Du skulle teoretiskt kunna generera en OpenAPI-spec från tRPC-rutter med trpc-openapi, men då lägger du tillbaka ceremoni. Använd REST från start.
Mikrotjänster#
tRPC förutsätter en enda TypeScript-kodbas. Om din backend är uppdelad i flera tjänster på olika språk kan tRPC inte hjälpa med kommunikation mellan tjänster. Använd gRPC, REST eller meddelandeköer för det.
Stora team med separata frontend-/backend-repos#
Om din frontend och backend lever i separata repon med separata deploy-pipelines förlorar du tRPC:s kärnfördel. Typdelningen kräver en monorepo eller ett delat paket. Du kan publicera AppRouter-typen som ett npm-paket, men nu har du ett versioneringsproblem som REST + OpenAPI hanterar mer naturligt.
När du behöver REST-semantik#
Om du behöver HTTP-cachningshuvuden, content negotiation, ETags eller andra REST-specifika funktioner kommer tRPC:s abstraktion över HTTP att motarbeta dig. tRPC behandlar HTTP som en transportdetalj, inte en funktion.
Beslutsramverket#
Så här bestämmer jag:
| Scenario | Rekommendation |
|---|---|
| Samma-repo fullstack TypeScript-app | tRPC — maximal nytta, minimal overhead |
| Internt verktyg / admin-dashboard | tRPC — utvecklingshastighet är prioriteten |
| Publikt API för tredjepartsutvecklare | REST + OpenAPI — konsumenter behöver docs, inte typer |
| Mobil- + webbklienter (icke-TS mobil) | REST eller GraphQL — behöver språkagnostiska kontrakt |
| Realtidstungt (chatt, spel) | tRPC subscriptions eller rena WebSockets beroende på komplexitet |
| Separata frontend-/backend-team | GraphQL — schemat är kontraktet mellan team |
Praktiska tips från produktion#
Några saker jag lärt mig av att köra tRPC i produktion som inte finns i dokumentationen:
Håll routrar små. En enskild routerfil bör inte överstiga 200 rader. Dela upp efter domän: userRouter, postRouter, billingRouter. Var och en i sin egen fil.
Använd createCallerFactory för serversideanrop. Använd inte fetch när du anropar ditt eget API från en Server Component. Anroparfabriken ger dig samma typsäkerhet med noll HTTP-overhead.
Överoptimera inte batching. Standard httpBatchLink är nästan alltid tillräcklig. Jag har sett team spendera dagar på att sätta upp splitLink-konfigurationer för marginella vinster. Profilera först.
Sätt staleTime i QueryClient. Standard staleTime på 0 innebär att varje focus-händelse utlöser en omhämtning. Sätt det till något rimligt (30 sekunder till 5 minuter) baserat på dina krav på datafärshet.
Använd superjson från dag ett. Att lägga till det senare innebär att migrera varje klient och server samtidigt. Det är en enradskonfiguration som sparar dig från Date-serialiseringsbuggar.
Error boundaries är din vän. Omslut tRPC-tunga sidavsnitt i React error boundaries. En enda misslyckad fråga bör inte ta ner hela sidan.
"use client";
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert" className="p-4 bg-red-50 rounded-lg">
<p className="font-medium text-red-800">Något gick fel</p>
<pre className="text-sm text-red-600 mt-2">{error.message}</pre>
<button
onClick={resetErrorBoundary}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded"
>
Försök igen
</button>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RecentPosts />
</ErrorBoundary>
</div>
);
}Avslutning#
tRPC är inte en ersättning för REST eller GraphQL. Det är ett annat verktyg för en specifik situation: när du kontrollerar både klienten och servern, båda är TypeScript, och du vill ha kortast möjliga väg från "jag ändrade backend" till "frontenden vet om det."
I den situationen kommer inget annat i närheten. Ingen kodgenerering, inga schemafiler, ingen drift. Bara TypeScript som gör vad TypeScript gör bäst: fånga misstag innan de når produktion.
Avvägningen är tydlig: du ger upp protokollnivåinteroperabilitet (inga icke-TypeScript-klienter) i utbyte mot utvecklingshastighet och kompileringstidssäkerhet som är svår att uppnå på annat sätt.
För de flesta fullstack TypeScript-applikationer är det en avvägning värd att göra.