Implementasi tRPC di Next.js: Type-Safe API tanpa Schema Code Generation
Memahami Masalah Type Safety pada REST API Tradisional
REST API memaksa kita mendefinisikan kontrak dua kali. Kita menulis handler di server, lalu menulis ulang tipe respons di client. Kedua definisi ini tidak terhubung secara otomatis.
Masalah muncul ketika server berubah tetapi client tidak ikut diperbarui. Field authorName diubah menjadi author di server, tetapi tipe di client masih mengacu ke nama lama. TypeScript tidak memberi peringatan karena tipe client adalah salinan manual. Error baru muncul saat runtime.
Pendekatan OpenAPI dan GraphQL codegen mencoba menutup celah ini dengan schema perantara. Kita menulis spesifikasi YAML atau GraphQL SDL, lalu menjalankan generator untuk menghasilkan tipe client. Alur ini bekerja, tetapi menambah langkah build dan file hasil generate yang harus disinkronkan. Setiap perubahan schema menuntut regenerasi.
tRPC menghilangkan schema perantara. Router didefinisikan langsung dalam TypeScript, dan tipe AppRouter diimpor oleh client melalui package yang sama. Autocomplete di editor membaca tipe server secara langsung. Ketika prosedur post.byId berubah, pemanggil yang memakai nama lama langsung error saat kompilasi.
Keuntungan ini terasa dalam refactoring harian. Kita mengganti nama field atau menambah parameter wajib, lalu menjalankan tsc --noEmit. Compiler menandai setiap pemanggil yang kedaluwarsa. Tidak ada dokumentasi yang tertinggal karena dokumentasi adalah kode itu sendiri.
Menyiapkan Proyek Next.js dengan tRPC dan Zod
Kita memakai App Router karena route handler /api/trpc/[trpc] terintegrasi rapi dengan fetch API. Struktur folder yang stabil adalah src/server/trpc.ts untuk inisialisasi, src/server/routers/ untuk setiap domain, dan src/app/api/trpc/[trpc]/route.ts sebagai entry HTTP.
Instalasi membutuhkan lima paket inti. Paket @trpc/server menyediakan pembangun router. Paket @trpc/client dan @trpc/react-query menyediakan hook React. Paket @tanstack/react-query mengelola cache. Paket zod menangani validasi input. Paket superjson bersifat opsional untuk serialisasi Date dan Map.
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson
npm install -D @types/node// src/server/trpc.ts
import { initTRPC } from '@trpc/server';
import SuperJSON from 'superjson';
export const t = initTRPC.create({
transformer: SuperJSON,
});
export const router = t.router;
export const publicProcedure = t.procedure;File trpc.ts di atas adalah fondasi seluruh API. Fungsi initTRPC.create menghasilkan pembangun yang konsisten untuk semua router. Transformer SuperJSON memastikan tipe seperti Date tidak berubah menjadi string saat melewati HTTP. Ekspor router dan publicProcedure memberi titik awal yang seragam untuk setiap modul.
Provider React dipasang di root layout. Komponen TRPCReactProvider membungkus QueryClientProvider agar hook useQuery dan useMutation bisa diakses dari Client Component mana pun. Kita buat client dengan httpBatchLink agar beberapa query dalam satu render digabung menjadi satu request HTTP.

Gambar: Model HTTP client-server pada aplikasi Node.js dengan framework Express — Sumber: Wikimedia Commons
Mendefinisikan Router dan Prosedur dengan Validasi Zod
Fullstack Web Development With Next.js
A practical, beginner-friendly, and project-based introduction to full-stack web...
Prosedur adalah unit terkecil dalam tRPC. Setiap prosedur memiliki tiga bagian: jenis operasi, skema input, dan handler. Jenis operasi hanya ada dua, yaitu query untuk membaca data dan mutation untuk mengubah data.
Validasi input memakai z.object() dari zod. Skema ini berjalan di runtime, bukan hanya di level tipe. Request dengan limit: 500 langsung ditolak sebelum handler dieksekusi. Error validasi dikirim ke client dalam format TRPCError yang terstruktur.
// src/server/routers/post.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
const posts = [
{ id: '1', title: 'Belajar tRPC', content: 'Konten pertama', published: true },
{ id: '2', title: 'Belajar Zod', content: 'Konten kedua', published: false },
];
export const postRouter = router({
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(50).default(10),
publishedOnly: z.boolean().default(false),
}))
.query(({ input }) => {
const filtered = input.publishedOnly
? posts.filter((p) => p.published)
: posts;
return filtered.slice(0, input.limit);
}),
byId: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.query(({ input }) => {
const post = posts.find((p) => p.id === input.id);
if (!post) {
throw new Error('Post tidak ditemukan');
}
return post;
}),
create: publicProcedure
.input(z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
}))
.mutation(({ input }) => {
const post = {
id: String(posts.length + 1),
...input,
published: false,
};
posts.push(post);
return post;
}),
});Logika di atas berpusat pada alur validasi lalu eksekusi. Input list selalu aman karena limit dibatasi 1 sampai 50 dengan default 10. Prosedur byId menunjukkan pola lookup yang gagal secara eksplisit ketika data tidak ada. Prosedur create menunjukkan pola mutation yang menerima payload tervalidasi lalu mengembalikan entitas baru.
Router domain digabung menjadi satu appRouter di src/server/routers/_app.ts. Kita impor postRouter lalu daftarkan sebagai post: postRouter. Tipe AppRouter diekspor dengan export type AppRouter = typeof appRouter. Tipe inilah yang diimpor client, sehingga tidak ada satu pun definisi yang diduplikasi.
Menghubungkan Client React dengan Type Inference End-to-End
Client tRPC terdiri dari dua lapis. Lapis pertama adalah instance createTRPCReact<AppRouter> yang menghasilkan hook dengan tipe penuh. Lapis kedua adalah QueryClient dari TanStack yang mengatur cache, retry, dan deduplikasi request.
Konfigurasi link memakai httpBatchLink dengan URL /api/trpc. Opsi ini menggabungkan beberapa useQuery dalam satu tick render menjadi satu POST batch. Transformer harus sama dengan server, yaitu SuperJSON, agar serialisasi simetris.
'use client';
import { trpc } from '@/lib/trpc-client';
export function PostList() {
const listQuery = trpc.post.list.useQuery(
{ limit: 10, publishedOnly: true },
{ staleTime: 30_000 }
);
const utils = trpc.useUtils();
const createMutation = trpc.post.create.useMutation({
onSuccess: () => {
utils.post.list.invalidate();
},
});
if (listQuery.isLoading) {
return <p>Memuat daftar post...</p>;
}
if (listQuery.isError) {
return <p>Gagal memuat: {listQuery.error.message}</p>;
}
return (
<div>
<ul>
{listQuery.data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button
onClick={() => createMutation.mutate({
title: 'Post baru dari client',
content: 'Isi konten yang cukup panjang untuk validasi',
})}
disabled={createMutation.isPending}
>
{createMutation.isPending ? 'Menyimpan...' : 'Buat post'}
</button>
</div>
);
}Workflow komponen di atas mudah diikuti. Hook useQuery mengambil daftar dengan parameter yang sudah tervalidasi tipenya. Jika kita salah mengetik publishedOnly menjadi published, TypeScript langsung menandai error. Mutasi create memicu invalidate agar daftar di-fetch ulang setelah data baru tersimpan. State isLoading, isError, dan isPending memberi umpan balik UI tanpa kode tambahan.
Perhatikan direktif 'use client' di baris pertama. Hook tRPC hanya berjalan di Client Component karena membutuhkan QueryClient di browser. Server Component tetap bisa memanggil router langsung melalui createCaller tanpa melalui HTTP, sehingga tidak ada overhead jaringan untuk render awal.
Menangani Context Autentikasi dan Error Handling Terpusat
API tanpa autentikasi hanya cocok untuk data publik. Kita menambahkan context yang dibaca pada setiap request. Context berisi userId dari session atau header authorization. Prosedur yang membutuhkan login memakai protectedProcedure, bukan publicProcedure.
Middleware memeriksa context sebelum handler berjalan. Jika userId kosong, middleware melempar TRPCError dengan kode UNAUTHORIZED. Kode ini dipetakan otomatis menjadi status HTTP 401 oleh adapter fetch. Client menerima pesan yang konsisten tanpa perlu parsing manual.
// src/server/trpc.ts (perluasan dengan auth)
import { initTRPC, TRPCError } from '@trpc/server';
const t = initTRPC.context<{ userId: string | null }>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Login diperlukan' });
}
return next({ ctx: { userId: ctx.userId } });
});
export const protectedProcedure = t.procedure.use(isAuthed);// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
function createContext(req: Request) {
const userId = req.headers.get('x-user-id');
return { userId };
}
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createContext(req),
onError: ({ error, path }) => {
console.error(`tRPC error pada ${path}: ${error.message}`);
},
});
export { handler as GET, handler as POST };Alur request sekarang memiliki tiga lapis perlindungan. Lapis pertama adalah createContext yang mengekstrak identitas dari header. Lapis kedua adalah middleware isAuthed yang menolak request anonim. Lapis ketiga adalah onError yang mencatat setiap kegagalan dengan nama prosedur untuk kemudahan debugging. Respons error yang diterima client selalu memuat code, message, dan data.path.
Praktik Terbaik untuk Produksi dan Kapan Tetap Memilih REST
tRPC paling kuat di monorepo TypeScript penuh. Frontend Next.js dan backend tRPC berbagi satu repository dan satu versi tipe. Perubahan prosedur langsung terlihat di seluruh pemanggil. Pola ini memangkas waktu integrasi secara signifikan dibanding sinkronisasi OpenAPI manual.
Ada batas yang perlu kita hormati. Client mobile native atau integrasi pihak ketiga tidak bisa mengimpor tipe TypeScript. Public API yang dikonsumsi partner tetap lebih tepat memakai REST dengan OpenAPI atau GraphQL. Kita bisa menjalankan keduanya berdampingan, dengan tRPC untuk dashboard internal dan REST untuk API publik.

Gambar: Arsitektur client-server 3-tier, lapisan presentasi, aplikasi, dan data dipisahkan — Sumber: Wikimedia Commons
Untuk skala production, pecah appRouter per domain seperti post, user, dan billing. Setiap router disimpan di file terpisah agar code review tetap fokus. Atur staleTime React Query sekitar 30 detik untuk data yang jarang berubah dan 0 untuk data realtime. Tambahkan rate limiting di middleware edge sebelum request mencapai handler tRPC. Validasi environment dengan z.object() saat boot agar konfigurasi yang hilang gagal cepat dengan pesan jelas.
Versioning juga lebih sederhana dibanding REST. Kita menambah prosedur post.listV2 tanpa menghapus post.list lama. Client lama tetap berjalan, client baru memakai prosedur baru. Setelah migrasi selesai, prosedur lama dihapus dan compiler menunjukkan sisa pemanggil yang belum diperbarui.
Ingin menguasai pola fullstack TypeScript seperti ini secara terstruktur? Pelajari kurikulum Next.js dan backend modern di Rumah Coding dan bangun portofolio API yang siap production.
Kursus Terkait
Fullstack Web Development With Next.js
A practical, beginner-friendly, and project-based introduction to full-stack web development. Students will learn to build, secure, and deploy modern web applications from scratch using Next.js (App Router), React, Tailwind CSS, and a relational database. By the end of the course, students will have a fully functional, production-ready application to showcase in their portfolio.
TechConnect - Modern IT Job Portal
- Public Job Board: A responsive homepage displaying available job listings with dynamic routing for individual job detail pages.
- Search & Filter: Basic functionality allowing users to find jobs based on keywords or categories.
- User Authentication: Secure Sign Up, Log In, and Log Out workflows.