tRPC: type safety end-to-end senza cerimonie
Come tRPC elimina il problema del contratto API, funziona con Next.js App Router, gestisce middleware auth, upload di file e subscription. Un setup tRPC reale da zero.
Conosci la storia. Cambi il nome di un campo nella risposta API. Aggiorni il tipo nel backend. Fai deploy. Poi il frontend si rompe in produzione perché qualcuno ha dimenticato di aggiornare la chiamata fetch alla riga 247 di Dashboard.tsx. Il campo ora è undefined, il componente renderizza vuoto, e il tuo error tracking si illumina alle 2 di notte.
Questo è il problema del contratto API. Non è un problema tecnologico. È un problema di coordinamento. E nessuna quantità di documentazione Swagger o schema GraphQL risolverà il fatto che i tipi del frontend e del backend possono divergere silenziosamente.
tRPC risolve questo rifiutando di lasciarli divergere. Non c'è un file di schema. Nessun passaggio di generazione del codice. Nessun contratto separato da mantenere. Scrivi una funzione TypeScript sul server, e il client ne conosce i tipi esatti di input e output a compile time. Se rinomini un campo, il frontend non compilerà finché non lo correggi.
Questa è la promessa. Lascia che ti mostri come funziona davvero, dove brilla e dove assolutamente non dovresti usarlo.
Il problema, più precisamente#
Vediamo come la maggior parte dei team costruisce le API oggi.
REST + OpenAPI: Scrivi i tuoi endpoint. Forse aggiungi le annotazioni Swagger. Forse generi un client SDK dalla specifica OpenAPI. Ma la specifica è un artefatto separato. Può diventare obsoleta. Il passaggio di generazione è un'altra cosa nella tua pipeline CI che può rompersi o essere dimenticata. E i tipi generati sono spesso brutti — mostri annidati tipo paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"].
GraphQL: Migliore type safety, ma enorme cerimonia. Scrivi uno schema in SDL. Scrivi i resolver. Generi i tipi dallo schema. Scrivi le query sul client. Generi i tipi dalle query. Sono almeno due passaggi di generazione del codice, un file di schema e un build step che tutti devono ricordarsi di eseguire. Per un team che controlla già sia frontend che backend, è un sacco di infrastruttura per un problema che ha una soluzione più semplice.
Chiamate fetch manuali: L'approccio più comune e il più pericoloso. Scrivi fetch("/api/users"), fai il cast del risultato a User[] e speri per il meglio. Zero safety a compile time. L'asserzione di tipo è una bugia che racconti a TypeScript.
// La bugia che ogni sviluppatore frontend ha raccontato
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // 🙏 sperando che sia giustotRPC adotta un approccio completamente diverso. Invece di descrivere la tua API in un formato separato e generare i tipi, scrivi semplici funzioni TypeScript sul server e importi i loro tipi direttamente nel client. Nessun passaggio di generazione. Nessun file di schema. Nessuna divergenza.
Concetti fondamentali#
Prima di configurare qualsiasi cosa, capiamo il modello mentale.
Router#
Un router tRPC è una collezione di procedure raggruppate insieme. Pensalo come un controller in MVC, tranne che è semplicemente un oggetto plain con l'inferenza dei tipi integrata.
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;Quell'export del tipo AppRouter è l'intero trucco magico. Il client importa questo tipo — non il codice runtime, solo il tipo — e ottiene autocompletamento completo e type checking per ogni procedura.
Procedura#
Una procedura è un singolo endpoint. Ci sono tre tipi:
- Query: Operazioni di lettura. Si mappa alla semantica HTTP GET. Cachata da TanStack Query.
- Mutation: Operazioni di scrittura. Si mappa a HTTP POST. Non cachata.
- Subscription: Stream in tempo reale. Usa WebSocket.
Context#
Il context sono i dati request-scoped disponibili a ogni procedura. Connessioni al database, l'utente autenticato, gli header della richiesta — qualsiasi cosa metteresti nell'oggetto req di Express va qui.
Middleware#
I middleware trasformano il context o controllano l'accesso. Il pattern più comune è un middleware di auth che verifica la validità della sessione e aggiunge ctx.user.
La catena di inferenza dei tipi#
Questo è il modello mentale critico. Quando definisci una procedura così:
t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
// input è tipizzato come { id: string }
return db.user.findUnique({ where: { id: input.id } });
});Il tipo di ritorno fluisce fino al client. Se db.user.findUnique restituisce User | null, l'hook useQuery del client avrà data tipizzato come User | null. Nessuna tipizzazione manuale. Nessun cast. È inferito end-to-end.
Setup con Next.js App Router#
Costruiamo tutto da zero. Assumo che tu abbia un progetto Next.js 14+ con l'App Router.
Installa le dipendenze#
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodStep 1: Inizializza tRPC sul server#
Crea la tua istanza tRPC e definisci il tipo del context.
// 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;Alcune cose da notare:
- Transformer
superjson: tRPC serializza i dati come JSON di default, il che significa che oggettiDate,Map,Sete altri tipi non-JSON vanno persi. superjson li preserva. - Error formatter: Alleghiamo gli errori di validazione Zod alla risposta così il client può mostrare errori a livello di campo.
createTRPCContext: Questa funzione viene eseguita ad ogni richiesta. È dove analizzi la sessione, configuri la connessione al database e costruisci l'oggetto context.
Step 2: Definisci il tuo 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;Step 3: Esponi tramite Route Handler di Next.js#
Nell'App Router, tRPC gira come un Route Handler standard. Nessun server custom, nessun plugin Next.js speciale.
// 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 };Tutto qui. Sia GET che POST sono gestiti. Le query passano per GET (con input URL-encoded), le mutation passano per POST.
Step 4: Configura il client#
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();Nota: importiamo AppRouter solo come tipo. Nessun codice server finisce nel bundle del client.
Step 5: Setup del provider#
// 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 (parte rilevante)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}Step 6: Usa nei componenti#
// 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>Bentornato, {user.name}</h1>
<p>Membro dal {user.createdAt.toLocaleDateString()}</p>
</div>
);
}Quel user.name è completamente tipizzato. Se lo scrivi male come user.nme, TypeScript lo intercetta immediatamente. Se cambi il server per restituire displayName invece di name, ogni utilizzo lato client mostrerà un errore a compile time. Nessuna sorpresa a runtime.
Context e middleware#
Context e middleware sono dove tRPC passa da "trucchetto di tipi carino" a "framework pronto per la produzione."
Creare il context#
La funzione context viene eseguita ad ogni richiesta. Ecco una versione reale:
// 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>>;Middleware di autenticazione#
Il pattern middleware più comune è separare le procedure pubbliche da quelle protette:
// 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: {
// Override del tipo del context — user non è più nullable
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);Dopo l'esecuzione di questo middleware, ctx.user in qualsiasi protectedProcedure è garantito non-null. Il sistema di tipi lo impone. Non puoi accidentalmente accedere a ctx.user.id in una procedura pubblica senza che TypeScript si lamenti.
Middleware basato sui ruoli#
Puoi comporre middleware per un controllo d'accesso più granulare:
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);Middleware di logging#
I middleware non sono solo per l'auth. Ecco un middleware di logging delle performance:
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;
});
// Applica a tutte le procedure
export const publicProcedure = t.procedure.use(loggerMiddleware);Middleware di rate limiting#
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();
});Validazione dell'input con Zod#
tRPC usa Zod per la validazione dell'input. Non è decorazione opzionale — è il meccanismo che garantisce che gli input siano sicuri sia lato client che server.
Validazione base#
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 è completamente tipizzato:
// {
// title: string;
// content: string;
// categoryId: string;
// tags: string[];
// published: boolean;
// }
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});Il trucco della doppia validazione#
Ecco qualcosa di sottile: la validazione Zod viene eseguita su entrambi i lati. Sul client, tRPC valida l'input prima di inviare la richiesta. Se l'input non è valido, la richiesta non lascia mai il browser. Sul server, lo stesso schema valida di nuovo come misura di sicurezza.
Questo significa che ottieni la validazione client-side istantanea 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) => {
// Gli errori Zod arrivano strutturati
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 ? "Creazione..." : "Crea Post"}
</button>
</form>
);
}Pattern di input complessi#
// Discriminated union
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(),
}),
]);
// Input di paginazione riusabile tra le procedure
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,
};
}),
});Gestione degli errori#
La gestione degli errori di tRPC è strutturata, type-safe e si integra in modo pulito sia con la semantica HTTP che con la UI lato client.
Lanciare errori sul server#
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 };
}),
});I codici di errore tRPC si mappano ai codici di stato HTTP:
| Codice tRPC | Stato HTTP | Quando usarlo |
|---|---|---|
BAD_REQUEST | 400 | Input non valido oltre la validazione Zod |
UNAUTHORIZED | 401 | Non loggato |
FORBIDDEN | 403 | Loggato ma permessi insufficienti |
NOT_FOUND | 404 | La risorsa non esiste |
CONFLICT | 409 | Risorsa duplicata |
TOO_MANY_REQUESTS | 429 | Rate limit superato |
INTERNAL_SERVER_ERROR | 500 | Errore server inatteso |
Gestione degli errori lato client#
"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("Post eliminato");
utils.post.list.invalidate();
},
onError: (error) => {
switch (error.data?.code) {
case "FORBIDDEN":
toast.error("Non hai i permessi per eliminare questo post");
break;
case "NOT_FOUND":
toast.error("Questo post non esiste più");
utils.post.list.invalidate();
break;
default:
toast.error("Qualcosa è andato storto. Riprova.");
}
},
});
return (
<button
onClick={() => deletePost.mutate({ id: postId })}
disabled={deletePost.isPending}
>
{deletePost.isPending ? "Eliminazione..." : "Elimina"}
</button>
);
}Gestione globale degli errori#
Puoi configurare un gestore globale degli errori che intercetta tutti gli errori tRPC non gestiti:
// Nel tuo TRPCProvider
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Fallback globale per errori di mutation non gestiti
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") {
// Redirect al login
window.location.href = "/login";
return;
}
toast.error(error.message);
}
},
},
},
})
);Mutation e optimistic update#
Le mutation sono dove tRPC si integra davvero bene con TanStack Query. Vediamo un pattern reale: un pulsante like con optimistic update.
Mutation base#
// 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 };
}),
});Optimistic update#
L'utente clicca "Like." Non vuoi aspettare 200ms per la risposta del server per aggiornare la UI. Gli optimistic update risolvono questo: aggiorna la UI immediatamente, poi fai rollback se il server rifiuta.
"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 }) => {
// Cancella i refetch in corso per non sovrascrivere l'update ottimistico
await utils.post.byId.cancel({ id: postId });
// Snapshot del valore precedente
const previousPost = utils.post.byId.getData({ id: postId });
// Aggiorna ottimisticamente la cache
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,
};
});
// Restituisci lo snapshot per il rollback
return { previousPost };
},
onError: (_error, { postId }, context) => {
// Rollback al valore precedente in caso di errore
if (context?.previousPost) {
utils.post.byId.setData({ id: postId }, context.previousPost);
}
},
onSettled: (_data, _error, { postId }) => {
// Sempre refetch dopo errore o successo per garantire lo stato del server
utils.post.byId.invalidate({ id: postId });
},
});
return (
<button
onClick={() => toggleLike.mutate({ postId })}
className={initialLiked ? "text-red-500" : "text-gray-400"}
>
♥ {initialCount}
</button>
);
}Il pattern è sempre lo stesso:
onMutate: Cancella le query, snapshot dei dati correnti, applica l'update ottimistico, restituisci lo snapshot.onError: Rollback usando lo snapshot.onSettled: Invalida la query per refetchare dal server, indipendentemente dal successo o dall'errore.
Questa danza in tre passi garantisce che la UI sia sempre reattiva e alla fine consistente con il server.
Batching e subscription#
HTTP batching#
Di default, tRPC con httpBatchLink combina multiple richieste simultanee in una singola chiamata HTTP. Se un componente renderizza e lancia tre query:
function Dashboard() {
const user = trpc.user.me.useQuery();
const posts = trpc.post.list.useQuery({ limit: 10 });
const stats = trpc.stats.overview.useQuery();
// ...
}Queste tre query vengono automaticamente raggruppate in una singola richiesta HTTP: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...
Il server le elabora tutte e tre, restituisce tutti e tre i risultati in una singola risposta, e TanStack Query distribuisce i risultati a ciascun hook. Nessuna configurazione necessaria.
Subscription WebSocket#
Per le funzionalità in tempo reale, tRPC supporta le subscription tramite WebSocket. Questo richiede un server WebSocket separato (Next.js non supporta nativamente i WebSocket nei Route Handler).
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
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);
};
});
}),
});Upload di file type-safe#
tRPC non gestisce nativamente l'upload di file. È un protocollo JSON-RPC — i dati binari non sono il suo ambito. Ma puoi costruire un flusso di upload type-safe combinando tRPC con presigned URL.
Il pattern:
- Il client chiede a tRPC un presigned URL per l'upload.
- tRPC valida la richiesta, verifica i permessi, genera l'URL.
- Il client carica direttamente su S3 usando il presigned URL.
- Il client notifica tRPC che l'upload è completato.
// 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 minuti
});
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,
};
}),
});Chiamate server-side e React Server Component#
Con Next.js App Router, spesso vuoi fetchare dati nei Server Component. tRPC supporta questo tramite server-side caller:
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
// Utilizzo in un 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>
{/* Componenti client per le parti interattive */}
<LikeButton postId={post.id} initialLiked={post.liked} />
<CommentSection postId={post.id} />
</article>
);
}Questo ti dà il meglio di entrambi i mondi: dati iniziali renderizzati lato server con piena type safety, e interattività lato client per mutation e funzionalità in tempo reale.
Testare le procedure tRPC#
Il testing è semplice perché le procedure sono solo funzioni. Non hai bisogno di avviare un server HTTP.
// 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");
});
});Niente mock di livelli HTTP, niente supertest, niente route matching. Chiama la funzione e asserisci il risultato. Questo è uno dei vantaggi sottovalutati di tRPC: il testing è banalmente semplice perché il livello di trasporto è un dettaglio implementativo.
Quando NON usare tRPC#
tRPC non è una soluzione universale. Ecco dove crolla:
API pubbliche#
Se stai costruendo un'API che sviluppatori esterni consumeranno, tRPC è la scelta sbagliata. I consumatori esterni non hanno accesso ai tuoi tipi TypeScript. Hanno bisogno di un contratto documentato e stabile — OpenAPI/Swagger per REST, o uno schema GraphQL.
App mobile (a meno che non usi TypeScript)#
Se la tua app mobile è scritta in Swift, Kotlin o Dart, tRPC non offre nulla. I tipi non attraversano i confini dei linguaggi.
Microservizi#
tRPC assume un unico codebase TypeScript. Se il tuo backend è diviso tra più servizi in linguaggi diversi, tRPC non può aiutare con la comunicazione inter-servizio. Usa gRPC, REST o code di messaggi per quello.
Grandi team con repo frontend/backend separati#
Se frontend e backend vivono in repository separati con pipeline di deploy separate, perdi il vantaggio core di tRPC. La condivisione dei tipi richiede un monorepo o un pacchetto condiviso.
Il framework decisionale#
| Scenario | Raccomandazione |
|---|---|
| App fullstack TypeScript nello stesso repo | tRPC — massimo beneficio, minimo overhead |
| Tool interni / dashboard admin | tRPC — la velocità di sviluppo è la priorità |
| API pubblica per sviluppatori terzi | REST + OpenAPI — i consumatori hanno bisogno di doc, non di tipi |
| Client mobile + web (mobile non-TS) | REST o GraphQL — servono contratti language-agnostic |
| Real-time pesante (chat, gaming) | tRPC subscription o WebSocket raw a seconda della complessità |
| Team frontend/backend separati | GraphQL — lo schema è il contratto tra i team |
Consigli pratici dalla produzione#
Alcune cose che ho imparato usando tRPC in produzione che non sono nella documentazione:
Mantieni i router piccoli. Un singolo file router non dovrebbe superare le 200 righe. Dividi per dominio: userRouter, postRouter, billingRouter. Ognuno nel suo file.
Usa createCallerFactory per le chiamate server-side. Non ricorrere a fetch quando chiami la tua stessa API da un Server Component. Il caller factory ti dà la stessa type safety con zero overhead HTTP.
Non over-ottimizzare il batching. L'httpBatchLink di default è quasi sempre sufficiente. Ho visto team passare giorni a configurare splitLink per guadagni marginali. Prima profila.
Imposta staleTime nel QueryClient. Il staleTime predefinito di 0 significa che ogni evento di focus scatena un refetch. Impostalo a qualcosa di ragionevole (30 secondi a 5 minuti) in base ai tuoi requisiti di freschezza dei dati.
Usa superjson dal primo giorno. Aggiungerlo dopo significa migrare ogni client e server simultaneamente. È una configurazione di una riga che ti salva dai bug di serializzazione delle Date.
Gli error boundary sono tuoi amici. Avvolgi le sezioni della pagina ricche di tRPC con error boundary React. Una singola query fallita non dovrebbe abbattere l'intera pagina.
"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">Qualcosa è andato storto</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"
>
Riprova
</button>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RecentPosts />
</ErrorBoundary>
</div>
);
}Conclusione#
tRPC non è un sostituto di REST o GraphQL. È uno strumento diverso per una situazione specifica: quando controlli sia il client che il server, entrambi sono TypeScript, e vuoi il percorso più breve possibile da "ho cambiato il backend" a "il frontend lo sa."
In quella situazione, nient'altro si avvicina. Nessuna generazione del codice, nessun file di schema, nessuna divergenza. Solo TypeScript che fa ciò che TypeScript sa fare meglio: intercettare gli errori prima che raggiungano la produzione.
Il compromesso è chiaro: rinunci all'interoperabilità a livello di protocollo (nessun client non-TypeScript) in cambio di velocità di sviluppo e safety a compile time difficile da ottenere in qualsiasi altro modo.
Per la maggior parte delle applicazioni fullstack TypeScript, è un compromesso che vale la pena fare.