tRPC: Seremoni Olmadan Uçtan Uca Tip Güvenliği
tRPC'nin API sözleşme sorununu nasıl ortadan kaldırdığı, Next.js App Router ile çalışması, auth middleware, dosya yükleme ve subscription'lar. Sıfırdan gerçek dünya tRPC kurulumu.
Biliyorsun şarkıyı. API yanıtında bir alan adını değiştirirsin. Backend tipini güncellersin. Deploy edersin. Sonra frontend production'da bozulur çünkü birisi Dashboard.tsx'in 247. satırındaki fetch çağrısını güncellemeyi unutmuştur. Alan artık undefined, bileşen boş render olur ve hata takibinden gece 2'de bildirimler gelir.
Bu API sözleşme sorunu. Teknoloji sorunu değil. Koordinasyon sorunu. Ve hiçbir Swagger dokümanı veya GraphQL şeması, frontend ve backend tiplerinin sessizce birbirinden sapabilmesi gerçeğini düzeltmez.
tRPC bunu sapma olasılığını ortadan kaldırarak düzeltir. Şema dosyası yok. Kod üretim adımı yok. Bakım edilecek ayrı bir sözleşme yok. Sunucuda bir TypeScript fonksiyonu yazarsın ve client derleme zamanında tam girdi ve çıktı tiplerini bilir. Bir alanı yeniden adlandırırsan, frontend düzeltene kadar derlenmez.
İşte vaat bu. Gerçekte nasıl çalıştığını, nerede parladığını ve kesinlikle nerede kullanmamanız gerektiğini göstereyim.
Problem, Daha Kesin Olarak#
Çoğu takımın bugün API'ları nasıl kurduğuna bakalım.
REST + Fetch Yaklaşımı#
// Backend — bir Express endpoint
app.get("/api/users/:id", async (req, res) => {
const user = await db.users.findById(req.params.id);
res.json(user);
});
// Frontend — bir fetch çağrısı
const res = await fetch(`/api/users/${userId}`);
const user = await res.json(); // tip: anyuser tipi any. TypeScript bir şey bilmiyor. Otocompletion yok. Hata kontrolü yok. Yanlış alan adı kullanırsan derleme zamanı hatası yok.
OpenAPI / Swagger Yaklaşımı#
# openapi.yaml — elle bakımı yapılan sözleşme
paths:
/api/users/{id}:
get:
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
200:
content:
application/json:
schema:
$ref: '#/components/schemas/User'Daha iyi. Artık bir sözleşmen var. Ama bu sözleşme gerçek koddan ayrı yaşar. Birisi endpoint'i günceller ve YAML dosyasını güncellemeyi unutabilir. Bir kod üretim adımı çalıştırırsın, tipler bir üretilen dosyada yaşar ve her şey senkronize kalmayı "umut eder."
GraphQL Yaklaşımı#
type Query {
user(id: ID!): User
}
type User {
id: ID!
name: String!
email: String!
}Daha da iyi. Şema gerçeğin kaynağı ve codegen araçları tip güvenli client'lar üretebilir. Ama hala bir ayrı şema dili, bir derleme adımı ve backend resolver'ın gerçekte şemanın vaat ettiği şeyle eşleştiğinden emin olmak için bir çalışma zamanı katmanı bakıyorsun.
tRPC Yaklaşımı#
// Backend — bir tRPC procedure
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.users.findById(input.id);
}),
});
// Frontend — tipli client
const user = await trpc.user.getById.query({ id: userId });
// user tam tipli — otocompletion, hata kontrolü, her şey çalışırBackend alanı yeniden adlandırırsa, frontend derlemez. Backend girdi doğrulamasını değiştirirse, client TypeScript hatası alır. Hiçbir şey sessizce sapmaz.
Bu ayrı bir şema olmadan çalışır çünkü tRPC, TypeScript'in kendi tip çıkarımını kablo boyunca taşır. Sunucu fonksiyon — fonksiyonun dönüş tipi var — client bu tipi import eder. Üretilecek bir şey yok çünkü tipler zaten TypeScript'te var.
Sıfırdan Kurulum#
Tam bir tRPC kurulumunu Next.js App Router ile, Adım Adım oluşturalım.
Adım 1: Paketleri Kur#
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjsonPaket rolleri:
@trpc/server: Sunucu tarafı router ve procedure tanımları@trpc/client: İstemci tarafı tip güvenli çağrılar@trpc/react-query: React hook'ları (TanStack Query üzerine kurulu)@tanstack/react-query: TanStack Query (tRPC bunu bağımlılık olarak kullanır)zod: Girdi doğrulama (tRPC bunu şema olarak kullanır)superjson: Date, BigInt, Map, Set vb. serileştirir (JSON'ın yapamadığı)
Adım 2: Sunucu Tarafı Tanımları#
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { ZodError } from "zod";
import superjson from "superjson";
// Her istek için context oluştur
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),
};
}
// tRPC instance'ını başlat
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,
},
};
},
});
// Router ve procedure oluşturucuları export et
export const router = t.router;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;
// Auth middleware
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Bu işlemi gerçekleştirmek için giriş yapmalısınız",
});
}
return next({
ctx: {
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);// src/server/routers/user.ts
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../trpc";
import { TRPCError } from "@trpc/server";
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: "Kullanıcı bulunamadı",
});
}
return user;
}),
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const updated = await ctx.db.user.update({
where: { id: ctx.user.id },
data: input,
});
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: "Kullanıcı bulunamadı",
});
}
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;Adım 3: Next.js Route Handler ile Sunma#
App Router'da tRPC standart bir Route Handler olarak çalışır. Özel sunucu yok, özel Next.js plugin'i yok.
// 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 ${path ?? "<no-path>"} adresinde başarısız oldu: ${error.message}`
);
}
: undefined,
});
export { handler as GET, handler as POST };Bu kadar. Hem GET hem POST karşılanıyor. Query'ler GET üzerinden (URL-kodlanmış girdiyle), mutation'lar POST üzerinden gider.
Adım 4: Client'ı Kur#
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();Not: AppRouter'ı sadece tip olarak import ediyoruz. Hiçbir sunucu kodu client bundle'a sızmaz.
Adım 5: Provider Kurulumu#
// 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 (ilgili kısım)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}Adım 6: Bileşenlerde Kullanma#
// 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>Tekrar hoş geldin, {user.name}</h1>
<p>Üyelik tarihi: {user.createdAt.toLocaleDateString()}</p>
</div>
);
}Bu user.name tam tipli. user.nme diye yanlış yazarsan TypeScript anında yakalar. Sunucuyu name yerine displayName döndürecek şekilde değiştirirsen, her client kullanımı derleme hatası gösterir. Çalışma zamanı sürprizi yok.
Context ve Middleware#
Context ve middleware, tRPC'yi "şık tip hilesi"nden "production'a hazır framework"e taşıyan yerdir.
Context Oluşturma#
Context fonksiyonu her istekte çalışır. İşte gerçek dünya sürümü:
// 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#
En yaygın middleware kalıbı public ve protected procedure'leri ayırmak:
// src/server/trpc.ts
const isAuthed = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Bu işlemi gerçekleştirmek için giriş yapmalısınız",
});
}
return next({
ctx: {
// Context tipini override et — user artık nullable değil
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);Bu middleware çalıştıktan sonra, herhangi bir protectedProcedure'da ctx.user'ın non-null olması garanti. Tip sistemi bunu zorunlu kılar. TypeScript şikayet etmeden public bir procedure'da yanlışlıkla ctx.user.id'ye erişemezsin.
Rol Tabanlı Middleware#
Daha granüler erişim kontrolü için middleware birleştirebilirsin:
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 erişimi gerekli",
});
}
return next({
ctx: {
user: ctx.user,
},
});
});
export const adminProcedure = t.procedure.use(isAdmin);Loglama Middleware'i#
Middleware sadece auth için değil. İşte performans loglama middleware'i:
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(`Yavaş ${type} ${path}: ${duration}ms`);
}
return result;
});
// Tüm procedure'lara uygula
export const publicProcedure = t.procedure.use(loggerMiddleware);Rate Limiting Middleware'i#
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: `Hız sınırı aşıldı. Daha sonra tekrar deneyin.`,
});
}
return next();
});Zod ile Girdi Doğrulama#
tRPC girdi doğrulaması için Zod kullanır. Bu isteğe bağlı süsleme değil — girdilerin hem client hem sunucuda güvenli olmasını sağlayan mekanizma.
Temel Doğrulama#
const postRouter = router({
create: protectedProcedure
.input(
z.object({
title: z.string().min(1, "Başlık gerekli").max(200, "Başlık çok uzun"),
content: z.string().min(10, "İçerik en az 10 karakter olmalı"),
categoryId: z.string().uuid("Geçersiz kategori ID"),
tags: z.array(z.string()).max(5, "Maksimum 5 etiket").default([]),
published: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
// input tam tipli:
// {
// title: string;
// content: string;
// categoryId: string;
// tags: string[];
// published: boolean;
// }
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});Çift Doğrulama Hilesi#
İnce bir şey: Zod doğrulaması her iki tarafta da çalışır. Client'ta tRPC isteği göndermeden önce girdiyi doğrular. Girdi geçersizse istek tarayıcıyı terk etmez. Sunucuda aynı şema güvenlik önlemi olarak tekrar doğrular.
Bu bedavaya anında client tarafı doğrulama alacağın anlamına gelir:
"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 hataları yapılandırılmış olarak gelir
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 ? "Oluşturuluyor..." : "Yazı Oluştur"}
</button>
</form>
);
}Karmaşık Girdi Kalıpları#
// Discriminated union'lar
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(),
}),
]);
// Procedure'lar arasında yeniden kullanılan sayfalama girdisi
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,
};
}),
});İsteğe Bağlı Girdiler#
Her procedure'ın girdiye ihtiyacı yok. Query'ler genellikle ihtiyaç duymaz:
const statsRouter = router({
// Girdi gerekmez
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 };
}),
// İsteğe bağlı filtreler
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,
});
}),
});Hata İşleme#
tRPC'nin hata işlemesi yapılandırılmış, tip güvenli ve hem HTTP semantiğiyle hem client tarafı arayüzle temiz şekilde entegre.
Sunucuda Hata Fırlatma#
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: "Yazı bulunamadı",
});
}
if (post.authorId !== ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Sadece kendi yazılarınızı silebilirsiniz",
});
}
await ctx.db.post.delete({ where: { id: input.id } });
return { success: true };
}),
});tRPC hata kodları HTTP durum kodlarına eşlenir:
| tRPC Kodu | HTTP Durumu | Ne Zaman Kullanılır |
|---|---|---|
BAD_REQUEST | 400 | Zod doğrulamasının ötesinde geçersiz girdi |
UNAUTHORIZED | 401 | Giriş yapılmamış |
FORBIDDEN | 403 | Giriş yapılmış ama yetersiz izinler |
NOT_FOUND | 404 | Kaynak mevcut değil |
CONFLICT | 409 | Yinelenen kaynak |
TOO_MANY_REQUESTS | 429 | Hız sınırı aşıldı |
INTERNAL_SERVER_ERROR | 500 | Beklenmeyen sunucu hatası |
Özel Hata Biçimlendirme#
Kurulumumuzdaki hata biçimlendiriciyi hatırlıyor musun? İşte pratikte nasıl çalışıyor:
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,
// Özel alanlar ekle
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
},
};
},
});Client Tarafı Hata İşleme#
"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("Yazı silindi");
utils.post.list.invalidate();
},
onError: (error) => {
switch (error.data?.code) {
case "FORBIDDEN":
toast.error("Bu yazıyı silme izniniz yok");
break;
case "NOT_FOUND":
toast.error("Bu yazı artık mevcut değil");
utils.post.list.invalidate();
break;
default:
toast.error("Bir şeyler ters gitti. Lütfen tekrar deneyin.");
}
},
});
return (
<button
onClick={() => deletePost.mutate({ id: postId })}
disabled={deletePost.isPending}
>
{deletePost.isPending ? "Siliniyor..." : "Sil"}
</button>
);
}Global Hata İşleme#
Tüm yakalanmamış tRPC hatalarını yakalayan global bir hata işleyici kurabilirsin:
// TRPCProvider'ında
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Yakalanmamış mutation hataları için global yedek
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") {
// Giriş sayfasına yönlendir
window.location.href = "/login";
return;
}
toast.error(error.message);
}
},
},
},
})
);Mutation'lar ve İyimser Güncellemeler#
Mutation'lar, tRPC'nin TanStack Query ile gerçekten iyi entegre olduğu yer. Gerçek dünya kalıbına bakalım: iyimser güncellemeli bir beğeni butonu.
Temel Mutation#
// Sunucu
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 };
}),
});İyimser Güncellemeler#
Kullanıcı "Beğen"e tıklar. Arayüzü güncellemek için sunucu yanıtını 200ms beklemek istemezsin. İyimser güncellemeler bunu çözer: arayüzü hemen güncelle, sonra sunucu reddederse geri al.
"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 }) => {
// İyimser güncellememizi ezmemeleri için giden refetch'leri iptal et
await utils.post.byId.cancel({ id: postId });
// Önceki değerin anlık görüntüsünü al
const previousPost = utils.post.byId.getData({ id: postId });
// Önbelleği iyimser olarak güncelle
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,
};
});
// Geri alma için anlık görüntüyü döndür
return { previousPost };
},
onError: (_error, { postId }, context) => {
// Hatada önceki değere geri dön
if (context?.previousPost) {
utils.post.byId.setData({ id: postId }, context.previousPost);
}
},
onSettled: (_data, _error, { postId }) => {
// Sunucu durumunu garantilemek için hatadan veya başarıdan sonra her zaman yeniden getir
utils.post.byId.invalidate({ id: postId });
},
});
return (
<button
onClick={() => toggleLike.mutate({ postId })}
className={initialLiked ? "text-red-500" : "text-gray-400"}
>
♥ {initialCount}
</button>
);
}Kalıp her zaman aynı:
onMutate: Query'leri iptal et, mevcut veriyi anlık görüntüle, iyimser güncelleme uygula, anlık görüntüyü döndür.onError: Anlık görüntüyü kullanarak geri al.onSettled: Başarı veya hatadan bağımsız olarak sunucudan yeniden getirmek için query'yi geçersiz kıl.
Bu üç adımlı dans, arayüzün her zaman duyarlı ve nihayetinde sunucuyla tutarlı olmasını sağlar.
İlgili Query'leri Geçersiz Kılma#
Bir mutation'dan sonra genellikle ilgili verileri yenilemen gerekir. tRPC'nin useUtils()'u bunu ergonomik yapar:
const createComment = trpc.comment.create.useMutation({
onSuccess: (_data, variables) => {
// Yazının yorum listesini geçersiz kıl
utils.comment.listByPost.invalidate({ postId: variables.postId });
// Yazının kendisini geçersiz kıl (yorum sayısı değişti)
utils.post.byId.invalidate({ id: variables.postId });
// TÜM yazı listelerini geçersiz kıl (liste görünümlerindeki yorum sayıları)
utils.post.list.invalidate();
},
});Toplu İstek ve Subscription'lar#
HTTP Toplu İstek#
Varsayılan olarak, httpBatchLink ile tRPC eş zamanlı birden fazla isteği tek bir HTTP çağrısında birleştirir. Bir bileşen render olur ve üç query tetiklerse:
function Dashboard() {
const user = trpc.user.me.useQuery();
const posts = trpc.post.list.useQuery({ limit: 10 });
const stats = trpc.stats.overview.useQuery();
// ...
}Bu üç query otomatik olarak tek bir HTTP isteğinde toplanır: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...
Sunucu üçünü de işler, üçünün sonucunu tek bir yanıtta döndürür ve TanStack Query sonuçları her hook'a dağıtır. Yapılandırma gerekmez.
Gerekirse belirli çağrılar için toplu isteği devre dışı bırakabilirsin:
// Provider'ında belirli procedure'ları farklı yönlendirmek için splitLink kullan
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 Subscription'ları#
Gerçek zamanlı özellikler için tRPC, WebSocket'ler üzerinden subscription'ları destekler. Bu ayrı bir WebSocket sunucusu gerektirir (Next.js, Route Handler'larda doğal olarak WebSocket desteklemez).
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
// Bellek içi event emitter (production'da Redis pub/sub kullan)
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);
};
});
}),
// Bildirim tetikleyen mutation
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;
}),
});Client'ta:
"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 hatası:", error);
},
});
return (
<div>
<span className="badge">{notifications.length}</span>
{/* bildirim listesi arayüzü */}
</div>
);
}WebSocket transport'u için adanmış bir sunucu işlemi gerekir. İşte ws kütüphanesiyle minimal bir kurulum:
// ws-server.ts (ayrı işlem)
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(`Bağlantı açıldı (${wss.clients.size} toplam)`);
ws.once("close", () => {
console.log(`Bağlantı kapandı (${wss.clients.size} toplam)`);
});
});
process.on("SIGTERM", () => {
handler.broadcastReconnectNotification();
wss.close();
});
console.log("WebSocket sunucusu ws://localhost:3001 adresinde dinliyor");Ve client'ın subscription'lar için wsLink'e ihtiyacı var:
import { wsLink, createWSClient } from "@trpc/client";
const wsClient = createWSClient({
url: "ws://localhost:3001",
});
// Subscription'ları WebSocket üzerinden yönlendirmek için splitLink kullan
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,
}),
}),
],
})
);Tip Güvenli Dosya Yükleme#
tRPC dosya yüklemelerini doğal olarak işlemez. Bir JSON-RPC protokolü — binary veri onun alanı değil. Ama tRPC'yi presigned URL'lerle birleştirerek tip güvenli bir yükleme akışı oluşturabilirsin.
Kalıp:
- Client tRPC'den presigned yükleme URL'si ister.
- tRPC isteği doğrular, izinleri kontrol eder, URL'yi üretir.
- Client presigned URL kullanarak doğrudan S3'e yükler.
- Client tRPC'ye yüklemenin tamamlandığını bildirir.
// 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, "Dosya 10MB altında olmalı"),
})
)
.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 dakika
});
// Bekleyen yüklemeyi veritabanına kaydet
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" });
}
// Dosyanın S3'te gerçekten var olduğunu doğrula
// (isteğe bağlı ama önerilen)
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}`,
};
}),
});Client'ta:
"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 {
// Adım 1: tRPC'den presigned URL al
const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type,
size: file.size,
});
// Adım 2: Doğrudan S3'e yükle
const uploadResponse = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Yükleme başarısız");
}
// Adım 3: tRPC ile yüklemeyi onayla
const { url } = await confirmUpload.mutateAsync({ uploadId });
toast.success(`Dosya yüklendi: ${url}`);
} catch (error) {
toast.error("Yükleme başarısız. Lütfen tekrar deneyin.");
} finally {
setUploading(false);
}
},
[getPresignedUrl, confirmUpload]
);
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
accept="image/*"
/>
{uploading && <p>Yükleniyor...</p>}
</div>
);
}Tüm akış tip güvenli. Presigned URL yanıt tipi, yükleme ID tipi, onay yanıtı — hepsi sunucu tanımlarından çıkarsanır. Presigned URL yanıtına yeni bir alan eklersen, client bunu anında bilir.
Sunucu Tarafı Çağrılar ve React Server Component'ler#
Next.js App Router ile genellikle Server Component'lerde veri getirmek istersin. tRPC bunu sunucu tarafı çağrıcıları aracılığıyla destekler:
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
// Server Component'te kullanım
// 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>
{/* Etkileşimli kısımlar için client bileşenler */}
<LikeButton postId={post.id} initialLiked={post.liked} />
<CommentSection postId={post.id} />
</article>
);
}Bu sana her iki dünyanın en iyisini verir: tam tip güvenliğiyle sunucu tarafı render edilmiş başlangıç verisi ve mutation'lar ve gerçek zamanlı özellikler için client tarafı etkileşim.
tRPC Procedure'larını Test Etme#
Test etmek basit çünkü procedure'lar sadece fonksiyon. HTTP sunucusu ayağa kaldırmana gerek yok.
// 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("mevcut kullanıcı profilini döndürür", 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 Kullanıcı",
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 Kullanıcı",
email: "test@example.com",
image: null,
createdAt: new Date("2026-01-01"),
});
});
it("kimliği doğrulanmamış isteklerde UNAUTHORIZED fırlatır", async () => {
const caller = createCaller({
user: null,
db: prismaMock,
session: null,
headers: {},
});
await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
});
it("girdiyi Zod ile doğrular", 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 uzunluk 1
})
).rejects.toThrow();
});
});HTTP katmanları mock'lamak yok, supertest yok, route eşleme yok. Sadece fonksiyonu çağır ve sonucu doğrula. Bu tRPC'nin yeterince takdir edilmeyen avantajlarından biri: test etmek önemsiz derecede basit çünkü transport katmanı bir uygulama detayı.
tRPC'yi Ne Zaman KULLANMAMALI#
tRPC evrensel bir çözüm değil. İşte nerede işe yaramaz:
Genel API'lar#
Harici geliştiricilerin tüketeceği bir API inşa ediyorsan, tRPC yanlış seçim. Harici tüketicilerin TypeScript tiplerine erişimi yok. Belgelenmiş, kararlı bir sözleşmeye ihtiyaçları var — REST için OpenAPI/Swagger veya GraphQL şeması. tRPC'nin tip güvenliği yalnızca hem client hem sunucu aynı TypeScript code base'ini paylaştığında çalışır.
Mobil Uygulamalar (TypeScript Kullanmadıkça)#
Mobil uygulamanız Swift, Kotlin veya Dart ile yazılmışsa tRPC bir şey sunmaz. Tipler dil sınırlarını geçmez. Teorik olarak trpc-openapi kullanarak tRPC route'larından OpenAPI spec üretebilirsin ama o noktada seremoniye geri dönüyorsun. Baştan REST kullan.
Mikroservisler#
tRPC tek bir TypeScript code base'i varsayar. Backend'in farklı dillerde birden fazla servise bölünmüşse, tRPC servisler arası iletişimde yardımcı olamaz. Bunun için gRPC, REST veya mesaj kuyrukları kullan.
Ayrı Frontend/Backend Repo'larına Sahip Büyük Takımlar#
Frontend ve backend'in ayrı repository'lerde ayrı deploy pipeline'larıyla yaşıyorsa, tRPC'nin temel avantajını kaybedersin. Tip paylaşımı monorepo veya paylaşılan bir paket gerektirir. AppRouter tipini npm paketi olarak yayınlayabilirsin ama artık REST + OpenAPI'ın daha doğal ele aldığı bir versiyon sorunun var.
REST Semantiğine İhtiyaç Duyduğunda#
HTTP önbellekleme header'ları, içerik müzakeresi, ETag'ler veya diğer REST'e özgü özellikler gerekiyorsa, tRPC'nin HTTP üzerindeki soyutlaması seninle savaşacak. tRPC, HTTP'yi bir özellik değil, transport detayı olarak ele alır.
Karar Çerçevesi#
İşte nasıl karar veriyorum:
| Senaryo | Öneri |
|---|---|
| Aynı repo'da fullstack TypeScript uygulama | tRPC — maksimum fayda, minimum ek yük |
| İç araç / admin panosu | tRPC — geliştirme hızı öncelik |
| Üçüncü taraf geliştiriciler için genel API | REST + OpenAPI — tüketicilerin tiplere değil belgelere ihtiyacı var |
| Mobil + web client'lar (TypeScript olmayan mobil) | REST veya GraphQL — dil bağımsız sözleşme gerekli |
| Gerçek zamanlı ağırlıklı (chat, oyun) | tRPC subscription'ları veya karmaşıklığa göre raw WebSocket'ler |
| Ayrı frontend/backend takımları | GraphQL — şema takımlar arası sözleşme |
Production'dan Pratik İpuçları#
tRPC'yi production'da çalıştırmaktan öğrendiğim ve dokümanlarda olmayan birkaç şey:
Router'ları küçük tut. Tek bir router dosyası 200 satırı aşmamalı. Alan bazında böl: userRouter, postRouter, billingRouter. Her biri kendi dosyasında.
Sunucu tarafı çağrılar için createCallerFactory kullan. Server Component'ten kendi API'ını çağırırken fetch'e başvurma. Caller factory sıfır HTTP ek yüküyle aynı tip güvenliğini verir.
Toplu istek optimizasyonunu aşırıya kaçırma. Varsayılan httpBatchLink neredeyse her zaman yeterli. Takımların marjinal kazançlar için günlerce splitLink yapılandırmaları kurduğunu gördüm. Önce profil çıkar.
QueryClient'ta staleTime ayarla. Varsayılan staleTime 0, her odaklanma event'inin yeniden getirme tetiklemesi anlamına gelir. Veri tazelik gereksinimlerine göre makul bir şey ayarla (30 saniye ila 5 dakika).
İlk günden superjson kullan. Daha sonra eklemek her client ve sunucuyu aynı anda geçirmek anlamına gelir. Date serileştirme hatalarından kurtaran tek satırlık bir yapılandırma.
Hata sınırları dostun. tRPC yoğun sayfa bölümlerini React hata sınırlarıyla sar. Tek bir başarısız query tüm sayfayı çökertmemeli.
"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">Bir şeyler ters gitti</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"
>
Tekrar dene
</button>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Panel</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RecentPosts />
</ErrorBoundary>
</div>
);
}Sonuç#
tRPC, REST veya GraphQL'in yerini almaz. Belirli bir durum için farklı bir araç: hem client'ı hem sunucuyu kontrol ettiğinde, ikisi de TypeScript olduğunda ve "backend'i değiştirdim"den "frontend bunu biliyor"a en kısa yolu istediğinde.
Bu durumda başka hiçbir şey yakınına bile gelemez. Kod üretimi yok, şema dosyaları yok, sapma yok. Sadece TypeScript'in en iyi yaptığı şeyi yapması: hataları production'a ulaşmadan önce yakalamak.
Takas açık: protokol seviyesi birlikte çalışabilirliği (TypeScript olmayan client yok) geliştirme hızı ve başka türlü elde edilmesi zor derleme zamanı güvenliği karşılığında bırakıyorsun.
Çoğu fullstack TypeScript uygulaması için bu yapılmaya değer bir takas.