tRPC: Type Safety End-to-End Tanpa Seremoni
Bagaimana tRPC menghilangkan masalah kontrak API, bekerja dengan Next.js App Router, menangani auth middleware, file uploads, dan subscriptions. Setup tRPC dunia nyata dari nol.
Anda tahu ceritanya. Anda mengubah nama field di respons API. Anda memperbarui tipe backend. Anda deploy. Lalu frontend rusak di production karena seseorang lupa memperbarui panggilan fetch di baris 247 Dashboard.tsx. Field-nya sekarang undefined, komponen render kosong, dan error tracking Anda menyala jam 2 pagi.
Ini adalah masalah kontrak API. Ini bukan masalah teknologi. Ini masalah koordinasi. Dan tidak ada jumlah dokumentasi Swagger atau skema GraphQL yang bisa memperbaiki fakta bahwa tipe frontend dan backend Anda bisa saling menyimpang secara diam-diam.
tRPC memperbaiki ini dengan menolak membiarkan mereka menyimpang. Tidak ada file skema. Tidak ada langkah code generation. Tidak ada kontrak terpisah yang harus dipelihara. Anda menulis fungsi TypeScript di server, dan client mengetahui tipe input dan output persis pada waktu kompilasi. Jika Anda mengganti nama field, frontend tidak akan bisa dikompilasi sampai Anda memperbaikinya.
Itulah janjinya. Izinkan saya menunjukkan cara kerjanya sebenarnya, di mana ia bersinar, dan di mana Anda sebaiknya sama sekali tidak menggunakannya.
Masalahnya, Lebih Tepatnya#
Mari kita lihat bagaimana kebanyakan tim membangun API hari ini.
REST + OpenAPI: Anda menulis endpoint. Mungkin Anda menambahkan anotasi Swagger. Mungkin Anda menghasilkan client SDK dari spesifikasi OpenAPI. Tapi spesifikasi itu adalah artefak terpisah. Ia bisa menjadi usang. Langkah generasi itu adalah hal lain di pipeline CI Anda yang bisa rusak atau terlupakan. Dan tipe yang dihasilkan sering jelek โ monster paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"] yang tersarang dalam.
GraphQL: Type safety lebih baik, tapi seremoni yang sangat banyak. Anda menulis skema di SDL. Anda menulis resolver. Anda menghasilkan tipe dari skema. Anda menulis query di client. Anda menghasilkan tipe dari query. Itu setidaknya dua langkah code generation, sebuah file skema, dan langkah build yang harus diingat semua orang. Untuk tim yang sudah mengontrol frontend dan backend, ini banyak sekali infrastruktur untuk masalah yang punya solusi lebih sederhana.
Panggilan fetch manual: Pendekatan paling umum dan paling berbahaya. Anda menulis fetch("/api/users"), meng-cast hasilnya ke User[], dan berharap yang terbaik. Tidak ada keamanan waktu kompilasi sama sekali. Type assertion itu adalah kebohongan yang Anda sampaikan ke TypeScript.
// Kebohongan yang pernah disampaikan setiap developer frontend
const res = await fetch("/api/users");
const users = (await res.json()) as User[]; // ๐ semoga ini benartRPC mengambil pendekatan yang sepenuhnya berbeda. Alih-alih mendeskripsikan API Anda dalam format terpisah dan menghasilkan tipe, Anda menulis fungsi TypeScript biasa di server dan mengimpor tipe mereka langsung ke client. Tanpa langkah generasi. Tanpa file skema. Tanpa penyimpangan.
Konsep Inti#
Sebelum kita menyiapkan apa pun, mari pahami mental model-nya.
Router#
Router tRPC adalah kumpulan procedure yang dikelompokkan bersama. Anggap saja seperti controller di MVC, kecuali ini hanyalah objek biasa dengan type inference yang sudah tertanam.
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;Ekspor tipe AppRouter itu adalah seluruh trik ajaibnya. Client mengimpor tipe ini โ bukan kode runtime, hanya tipe-nya โ dan mendapat autocompletion penuh serta type checking untuk setiap procedure.
Procedure#
Sebuah procedure adalah satu endpoint. Ada tiga jenis:
- Query: Operasi baca. Memetakan ke semantik HTTP GET. Di-cache oleh TanStack Query.
- Mutation: Operasi tulis. Memetakan ke HTTP POST. Tidak di-cache.
- Subscription: Stream real-time. Menggunakan WebSockets.
Context#
Context adalah data berbasis request yang tersedia untuk setiap procedure. Koneksi database, pengguna yang terautentikasi, header request โ apa pun yang akan Anda taruh di objek req Express masuk ke sini.
Middleware#
Middleware mentransformasi context atau mengontrol akses. Pola paling umum adalah middleware auth yang memeriksa sesi yang valid dan menambahkan ctx.user.
Rantai Type Inference#
Ini adalah mental model yang kritis. Ketika Anda mendefinisikan procedure seperti ini:
t.procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
// input bertipe { id: string }
return db.user.findUnique({ where: { id: input.id } });
});Tipe return mengalir sampai ke client. Jika db.user.findUnique mengembalikan User | null, hook useQuery di client akan memiliki data bertipe User | null. Tanpa pengetikan manual. Tanpa casting. Semuanya di-infer end-to-end.
Setup dengan Next.js App Router#
Mari kita bangun ini dari nol. Saya asumsikan Anda punya proyek Next.js 14+ dengan App Router.
Install Dependencies#
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query zodLangkah 1: Inisialisasi tRPC di Server#
Buat instance tRPC Anda dan definisikan tipe 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;Beberapa hal yang perlu diperhatikan:
- Transformer
superjson: tRPC melakukan serialisasi data sebagai JSON secara default, yang berarti objekDate,Map,Set, dan tipe non-JSON lainnya akan hilang. superjson mempertahankannya. - Error formatter: Kita melampirkan error validasi Zod ke respons sehingga client bisa menampilkan error per-field.
createTRPCContext: Fungsi ini berjalan pada setiap request. Di sinilah Anda mem-parse sesi, menyiapkan koneksi database, dan membangun objek context.
Langkah 2: Definisikan Router Anda#
// 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;Langkah 3: Ekspos via Next.js Route Handler#
Di App Router, tRPC berjalan sebagai Route Handler standar. Tanpa server kustom, tanpa plugin Next.js khusus.
// 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 gagal pada ${path ?? "<no-path>"}: ${error.message}`
);
}
: undefined,
});
export { handler as GET, handler as POST };Itu saja. GET dan POST keduanya ditangani. Query melewati GET (dengan input ter-encode di URL), mutation melewati POST.
Langkah 4: Siapkan Client#
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
export const trpc = createTRPCReact<AppRouter>();Catatan: kita mengimpor AppRouter sebagai tipe saja. Tidak ada kode server yang bocor ke bundle client.
Langkah 5: Setup 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 (bagian yang relevan)
import { TRPCProvider } from "@/components/providers/TRPCProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}Langkah 6: Gunakan di Komponen#
// 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>Selamat datang kembali, {user.name}</h1>
<p>Anggota sejak {user.createdAt.toLocaleDateString()}</p>
</div>
);
}user.name itu sepenuhnya bertipe. Jika Anda salah ketik menjadi user.nme, TypeScript langsung menangkapnya. Jika Anda mengubah server untuk mengembalikan displayName alih-alih name, setiap penggunaan di client akan menampilkan error kompilasi. Tanpa kejutan runtime.
Context dan Middleware#
Context dan middleware adalah tempat tRPC berubah dari "trik tipe yang keren" menjadi "framework siap production."
Membuat Context#
Fungsi context berjalan pada setiap request. Berikut versi dunia nyata:
// 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#
Pola middleware paling umum adalah memisahkan procedure publik dari yang dilindungi:
// 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 tipe context โ user tidak lagi nullable
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);Setelah middleware ini berjalan, ctx.user di setiap protectedProcedure dijamin non-null. Sistem tipe menegakkan ini. Anda tidak bisa secara tidak sengaja mengakses ctx.user.id di procedure publik tanpa TypeScript mengeluh.
Middleware Berbasis Role#
Anda bisa menyusun middleware untuk kontrol akses yang lebih granular:
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);Logging Middleware#
Middleware bukan hanya untuk auth. Berikut middleware logging performa:
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(`โ ๏ธ ${type} lambat ${path}: ${duration}ms`);
}
return result;
});
// Terapkan ke semua procedure
export const publicProcedure = t.procedure.use(loggerMiddleware);Rate Limiting 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 terlampaui. Coba lagi nanti.`,
});
}
return next();
});Validasi Input dengan Zod#
tRPC menggunakan Zod untuk validasi input. Ini bukan dekorasi opsional โ ini adalah mekanisme yang memastikan input aman baik di client maupun server.
Validasi Dasar#
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 sepenuhnya bertipe:
// {
// title: string;
// content: string;
// categoryId: string;
// tags: string[];
// published: boolean;
// }
return ctx.db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
});Trik Validasi Ganda#
Ini sesuatu yang halus: validasi Zod berjalan di kedua sisi. Di client, tRPC memvalidasi input sebelum mengirim request. Jika input tidak valid, request tidak pernah meninggalkan browser. Di server, skema yang sama memvalidasi lagi sebagai langkah keamanan.
Ini berarti Anda mendapat validasi client-side instan secara 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) => {
// Error Zod datang terstruktur
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 ? "Membuat..." : "Buat Post"}
</button>
</form>
);
}Pola Input Kompleks#
// Discriminated unions
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 pagination yang digunakan ulang lintas 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,
};
}),
});Input Opsional#
Tidak setiap procedure memerlukan input. Query sering tidak memerlukan:
const statsRouter = router({
// Tidak perlu input
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 };
}),
// Filter opsional
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,
});
}),
});Penanganan Error#
Penanganan error tRPC terstruktur, type-safe, dan terintegrasi dengan baik dengan semantik HTTP dan UI client-side.
Melempar Error di 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 };
}),
});Kode error tRPC memetakan ke kode status HTTP:
| Kode tRPC | Status HTTP | Kapan Digunakan |
|---|---|---|
BAD_REQUEST | 400 | Input tidak valid di luar validasi Zod |
UNAUTHORIZED | 401 | Belum login |
FORBIDDEN | 403 | Sudah login tapi izin tidak cukup |
NOT_FOUND | 404 | Resource tidak ada |
CONFLICT | 409 | Resource duplikat |
TOO_MANY_REQUESTS | 429 | Rate limit terlampaui |
INTERNAL_SERVER_ERROR | 500 | Error server yang tidak terduga |
Format Error Kustom#
Ingat error formatter dari setup kita? Berikut cara kerjanya dalam praktik:
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,
// Tambahkan field kustom
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
},
};
},
});Penanganan Error di Client-Side#
"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 dihapus");
utils.post.list.invalidate();
},
onError: (error) => {
switch (error.data?.code) {
case "FORBIDDEN":
toast.error("Anda tidak punya izin untuk menghapus post ini");
break;
case "NOT_FOUND":
toast.error("Post ini sudah tidak ada");
utils.post.list.invalidate();
break;
default:
toast.error("Terjadi kesalahan. Silakan coba lagi.");
}
},
});
return (
<button
onClick={() => deletePost.mutate({ id: postId })}
disabled={deletePost.isPending}
>
{deletePost.isPending ? "Menghapus..." : "Hapus"}
</button>
);
}Penanganan Error Global#
Anda bisa menyiapkan penangan error global yang menangkap semua error tRPC yang tidak tertangani:
// Di TRPCProvider Anda
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Fallback global untuk error mutation yang tidak tertangani
if (error instanceof TRPCClientError) {
if (error.data?.code === "UNAUTHORIZED") {
// Redirect ke halaman login
window.location.href = "/login";
return;
}
toast.error(error.message);
}
},
},
},
})
);Mutation dan Optimistic Updates#
Mutation adalah tempat tRPC benar-benar terintegrasi dengan baik dengan TanStack Query. Mari kita lihat pola dunia nyata: tombol like dengan optimistic updates.
Mutation Dasar#
// 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 Updates#
Pengguna mengklik "Like." Anda tidak ingin menunggu 200ms untuk respons server sebelum memperbarui UI. Optimistic updates menyelesaikan ini: perbarui UI segera, lalu kembalikan jika server menolaknya.
"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 }) => {
// Batalkan refetch yang sedang berjalan agar tidak menimpa optimistic update kita
await utils.post.byId.cancel({ id: postId });
// Snapshot nilai sebelumnya
const previousPost = utils.post.byId.getData({ id: postId });
// Perbarui cache secara optimistik
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,
};
});
// Kembalikan snapshot untuk rollback
return { previousPost };
},
onError: (_error, { postId }, context) => {
// Kembalikan ke nilai sebelumnya saat error
if (context?.previousPost) {
utils.post.byId.setData({ id: postId }, context.previousPost);
}
},
onSettled: (_data, _error, { postId }) => {
// Selalu refetch setelah error atau sukses untuk memastikan state server
utils.post.byId.invalidate({ id: postId });
},
});
return (
<button
onClick={() => toggleLike.mutate({ postId })}
className={initialLiked ? "text-red-500" : "text-gray-400"}
>
โฅ {initialCount}
</button>
);
}Polanya selalu sama:
onMutate: Batalkan query, snapshot data saat ini, terapkan optimistic update, kembalikan snapshot.onError: Rollback menggunakan snapshot.onSettled: Invalidasi query sehingga refetch dari server, terlepas dari sukses atau error.
Tarian tiga langkah ini memastikan UI selalu responsif dan pada akhirnya konsisten dengan server.
Menginvalidasi Query Terkait#
Setelah mutation, Anda sering perlu me-refresh data terkait. useUtils() tRPC membuat ini ergonomis:
const createComment = trpc.comment.create.useMutation({
onSuccess: (_data, variables) => {
// Invalidasi daftar komentar post
utils.comment.listByPost.invalidate({ postId: variables.postId });
// Invalidasi post itu sendiri (jumlah komentar berubah)
utils.post.byId.invalidate({ id: variables.postId });
// Invalidasi SEMUA daftar post (jumlah komentar di tampilan daftar)
utils.post.list.invalidate();
},
});Batching dan Subscriptions#
HTTP Batching#
Secara default, tRPC dengan httpBatchLink menggabungkan beberapa request simultan menjadi satu panggilan HTTP. Jika sebuah komponen di-render dan menembakkan tiga query:
function Dashboard() {
const user = trpc.user.me.useQuery();
const posts = trpc.post.list.useQuery({ limit: 10 });
const stats = trpc.stats.overview.useQuery();
// ...
}Ketiga query ini secara otomatis di-batch menjadi satu request HTTP: GET /api/trpc/user.me,post.list,stats.overview?batch=1&input=...
Server memproses ketiganya, mengembalikan ketiga hasil dalam satu respons, dan TanStack Query mendistribusikan hasil ke setiap hook. Tanpa konfigurasi yang diperlukan.
Anda bisa menonaktifkan batching untuk panggilan tertentu jika diperlukan:
// Di provider Anda, gunakan splitLink untuk merutekan procedure tertentu secara berbeda
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#
Untuk fitur real-time, tRPC mendukung subscriptions melalui WebSockets. Ini memerlukan server WebSocket terpisah (Next.js tidak mendukung WebSockets secara native di Route Handlers).
// src/server/routers/notification.ts
import { observable } from "@trpc/server/observable";
// Event emitter in-memory (gunakan Redis pub/sub di production)
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 yang memicu notifikasi
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;
}),
});Di client:
"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("Error subscription:", error);
},
});
return (
<div>
<span className="badge">{notifications.length}</span>
{/* UI daftar notifikasi */}
</div>
);
}Untuk transport WebSocket, Anda memerlukan proses server yang terpisah. Berikut setup minimal dengan library ws:
// ws-server.ts (proses terpisah)
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(`Koneksi dibuka (${wss.clients.size} total)`);
ws.once("close", () => {
console.log(`Koneksi ditutup (${wss.clients.size} total)`);
});
});
process.on("SIGTERM", () => {
handler.broadcastReconnectNotification();
wss.close();
});
console.log("Server WebSocket mendengarkan di ws://localhost:3001");Dan client memerlukan wsLink untuk subscriptions:
import { wsLink, createWSClient } from "@trpc/client";
const wsClient = createWSClient({
url: "ws://localhost:3001",
});
// Gunakan splitLink untuk merutekan subscriptions melalui 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,
}),
}),
],
})
);File Upload yang Type-Safe#
tRPC tidak menangani file upload secara native. Ini adalah protokol JSON-RPC โ data biner bukan bidangnya. Tapi Anda bisa membangun alur upload yang type-safe dengan menggabungkan tRPC dengan presigned URLs.
Polanya:
- Client meminta presigned upload URL dari tRPC.
- tRPC memvalidasi request, memeriksa izin, menghasilkan URL.
- Client mengupload langsung ke S3 menggunakan presigned URL.
- Client memberi tahu tRPC bahwa upload selesai.
// 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 menit
});
// Simpan upload yang pending di database
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" });
}
// Verifikasi file benar-benar ada di S3
// (opsional tapi direkomendasikan)
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}`,
};
}),
});Di client:
"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 {
// Langkah 1: Dapatkan presigned URL dari tRPC
const { presignedUrl, uploadId } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type,
size: file.size,
});
// Langkah 2: Upload langsung ke S3
const uploadResponse = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Upload gagal");
}
// Langkah 3: Konfirmasi upload via tRPC
const { url } = await confirmUpload.mutateAsync({ uploadId });
toast.success(`File diupload: ${url}`);
} catch (error) {
toast.error("Upload gagal. Silakan coba lagi.");
} finally {
setUploading(false);
}
},
[getPresignedUrl, confirmUpload]
);
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
accept="image/*"
/>
{uploading && <p>Mengupload...</p>}
</div>
);
}Seluruh alur bersifat type-safe. Tipe respons presigned URL, tipe upload ID, respons konfirmasi โ semuanya di-infer dari definisi server. Jika Anda menambahkan field baru ke respons presigned URL, client langsung mengetahuinya.
Panggilan Server-Side dan React Server Components#
Dengan Next.js App Router, Anda sering ingin mengambil data di Server Components. tRPC mendukung ini melalui server-side callers:
// src/server/trpc.ts
export const createCaller = createCallerFactory(appRouter);
// Penggunaan di 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>
{/* Komponen client untuk bagian interaktif */}
<LikeButton postId={post.id} initialLiked={post.liked} />
<CommentSection postId={post.id} />
</article>
);
}Ini memberi Anda yang terbaik dari kedua dunia: data yang di-render di server dengan type safety penuh, dan interaktivitas client-side untuk mutation dan fitur real-time.
Testing Procedure tRPC#
Testing straightforward karena procedure hanyalah fungsi. Anda tidak perlu memulai 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("mengembalikan profil pengguna saat ini", 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("melempar UNAUTHORIZED untuk request yang tidak terautentikasi", async () => {
const caller = createCaller({
user: null,
db: prismaMock,
session: null,
headers: {},
});
await expect(caller.user.me()).rejects.toThrow("UNAUTHORIZED");
});
it("memvalidasi input dengan 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: "", // panjang minimum 1
})
).rejects.toThrow();
});
});Tanpa mocking layer HTTP, tanpa supertest, tanpa route matching. Cukup panggil fungsinya dan assert hasilnya. Ini adalah salah satu keunggulan tRPC yang kurang dihargai: testing sangat sederhana karena transport layer adalah detail implementasi.
Kapan JANGAN Menggunakan tRPC#
tRPC bukan solusi universal. Berikut di mana ia tidak cocok:
API Publik#
Jika Anda membangun API yang akan dikonsumsi developer eksternal, tRPC adalah pilihan yang salah. Konsumen eksternal tidak punya akses ke tipe TypeScript Anda. Mereka memerlukan kontrak yang terdokumentasi dan stabil โ OpenAPI/Swagger untuk REST, atau skema GraphQL. Type safety tRPC hanya berfungsi ketika client dan server berbagi codebase TypeScript yang sama.
Aplikasi Mobile (Kecuali Anda Menggunakan TypeScript)#
Jika aplikasi mobile Anda ditulis di Swift, Kotlin, atau Dart, tRPC tidak menawarkan apa-apa. Tipe-nya tidak melintas batas bahasa. Secara teori Anda bisa menghasilkan spesifikasi OpenAPI dari route tRPC menggunakan trpc-openapi, tapi pada titik itu Anda menambahkan seremoni kembali. Gunakan saja REST dari awal.
Microservices#
tRPC mengasumsikan satu codebase TypeScript. Jika backend Anda terbagi di beberapa service dalam bahasa yang berbeda, tRPC tidak bisa membantu komunikasi antar-service. Gunakan gRPC, REST, atau message queues untuk itu.
Tim Besar dengan Repo Frontend/Backend Terpisah#
Jika frontend dan backend Anda berada di repository terpisah dengan pipeline deploy terpisah, Anda kehilangan keunggulan inti tRPC. Berbagi tipe memerlukan monorepo atau shared package. Anda bisa mempublikasikan tipe AppRouter sebagai package npm, tapi sekarang Anda punya masalah versioning yang REST + OpenAPI tangani lebih natural.
Ketika Anda Memerlukan Semantik REST#
Jika Anda memerlukan header caching HTTP, content negotiation, ETags, atau fitur khusus REST lainnya, abstraksi tRPC atas HTTP akan melawan Anda. tRPC memperlakukan HTTP sebagai detail transport, bukan fitur.
Kerangka Keputusan#
Berikut cara saya memutuskan:
| Skenario | Rekomendasi |
|---|---|
| Aplikasi fullstack TypeScript satu-repo | tRPC โ manfaat maksimal, overhead minimal |
| Internal tool / admin dashboard | tRPC โ kecepatan development adalah prioritas |
| API publik untuk developer pihak ketiga | REST + OpenAPI โ konsumen butuh docs, bukan types |
| Client mobile + web (mobile non-TS) | REST atau GraphQL โ butuh kontrak language-agnostic |
| Banyak fitur real-time (chat, gaming) | tRPC subscriptions atau raw WebSockets tergantung kompleksitas |
| Tim frontend/backend terpisah | GraphQL โ skema adalah kontrak antar tim |
Tips Praktis dari Production#
Beberapa hal yang saya pelajari dari menjalankan tRPC di production yang tidak ada di dokumentasi:
Buat router tetap kecil. Satu file router tidak boleh melebihi 200 baris. Pisahkan berdasarkan domain: userRouter, postRouter, billingRouter. Masing-masing di file-nya sendiri.
Gunakan createCallerFactory untuk panggilan server-side. Jangan gunakan fetch saat memanggil API Anda sendiri dari Server Component. Caller factory memberi Anda type safety yang sama dengan zero overhead HTTP.
Jangan terlalu mengoptimasi batching. Default httpBatchLink hampir selalu cukup. Saya pernah melihat tim menghabiskan berhari-hari menyiapkan konfigurasi splitLink untuk gain yang marginal. Lakukan profiling terlebih dahulu.
Atur staleTime di QueryClient. Default staleTime 0 berarti setiap event focus memicu refetch. Atur ke sesuatu yang masuk akal (30 detik sampai 5 menit) berdasarkan kebutuhan kesegaran data Anda.
Gunakan superjson dari hari pertama. Menambahkannya nanti berarti migrasi setiap client dan server secara bersamaan. Ini konfigurasi satu baris yang menyelamatkan Anda dari bug serialisasi Date.
Error boundaries adalah sahabat Anda. Bungkus bagian halaman yang banyak menggunakan tRPC dalam React error boundaries. Satu query yang gagal tidak seharusnya menjatuhkan seluruh halaman.
"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">Terjadi kesalahan</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"
>
Coba lagi
</button>
</div>
);
}
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserStats />
</ErrorBoundary>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<RecentPosts />
</ErrorBoundary>
</div>
);
}Penutup#
tRPC bukan pengganti REST atau GraphQL. Ini adalah alat yang berbeda untuk situasi spesifik: ketika Anda mengontrol client dan server, keduanya TypeScript, dan Anda ingin jalur terpendek yang mungkin dari "saya mengubah backend" ke "frontend mengetahuinya."
Dalam situasi itu, tidak ada yang mendekati. Tanpa code generation, tanpa file skema, tanpa penyimpangan. Cukup TypeScript melakukan apa yang paling baik dilakukannya: menangkap kesalahan sebelum sampai ke production.
Trade-off-nya jelas: Anda melepaskan interoperabilitas tingkat protokol (tanpa client non-TypeScript) demi kecepatan development dan keamanan waktu kompilasi yang sulit dicapai dengan cara lain.
Untuk kebanyakan aplikasi fullstack TypeScript, itu adalah trade-off yang layak dibuat.