
Kalau kamu udah ngikutin artikel sebelumnya, kita udah bahas gimana caranya bangun fondasi aplikasi fullstack modern pakai Next.js 15, Shadcn UI, Supabase, dan Better Auth.
Project-nya udah clean, login system udah jalan, dan UI-nya pun udah lumayan rapi.
Nah, sekarang saatnya kita naik level π₯
Bukan cuma bikin struktur, tapi bikin sesuatu yang bisa langsung dipakai β sebuah aplikasi task sederhana, alias to-do list modern yang bisa nambah, edit, tandai selesai, bahkan hapus tugas.
Ibaratnya, kita pindah dari "ngerancang rumah" ke "ngisi furnitur dan mulai tinggal di dalamnya". Seru, kan?
Di artikel ini, kita bakal bareng-bareng bikin aplikasi task dengan fokus ke:
β Apa yang bakal kita pelajari?
- CRUD Task β Bisa nambah, lihat, edit, dan hapus task
- tRPC β Supaya client dan server bisa ngobrol langsung tanpa ribet
- Drizzle ORM β Biar ngoding database jadi lebih santai, typesafe, dan rapi
- Supabase β Sebagai rumah untuk semua data task yang kita bikin
Gak cuma itu, kita juga bakal lihat gimana semua teknologi ini kerja bareng dalam satu alur yang mulus. Mulai dari user ngetik task, dikirim ke server, masuk ke database, dan langsung muncul lagi di layar.
Persiapan Proyek
Oke, sebelum kita masuk ke bagian koding task-nya, kita mulai dulu dari setup dasar.
Karena ini adalah lanjutan dari artikel sebelumnya, aku anggap kamu udah familiar dengan Next.js 15, Shadcn UI, dan Better Auth. Tapi biar kamu nggak mulai dari nol, kamu bisa langsung clone project base-nya dari repo GitHub berikut:
π Repo Starter: https://github.com/cakfan/bwa-auth
git clone <https://github.com/cakfan/bwa-auth>
cd bwa-auth
bun install # atau npm install / pnpm install, sesuaikan dengan package manager-mu
Catatan: project ini udah dilengkapi setup auth Supabase, Drizzle ORM better-auth, dan Shadcn UI, jadi kita bisa langsung fokus ke fitur utama: Task App.
Ubah file env.example
menjadi .env
dan sesuaikan konfigurasinya dengan supabase kalian:
# Connect to Supabase via connection pooling with Supavisor.
DATABASE_URL="postgres://postgres.[DB_PROJECT_ID]:[DB_PASSWORD]@aws-0-ap-southeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true"
# Direct connection to the database. Used for migrations.
DIRECT_URL="postgres://postgres.[DB_PROJECT_ID]:[DB_PASSWORD]@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres"
DB_SCHEMA_NAME="taskify"
# Better Auth
BETTER_AUTH_SECRET="better_secret"
NEXT_PUBLIC_BASE_URL="<http://localhost:3000>"
Jalan server dengan perintah ini dan buka browser http://localhost:3000
:
bun run dev
β Cek Tools yang Akan Kita Pakai:
- Next.js 15 β dengan App Router, file-based routing modern
- Supabase β sebagai database utama dan sistem auth
- Drizzle ORM β supaya query ke database jadi lebih TypeScript-friendly
- tRPC β jembatan frontend β backend tanpa bikin file API manual
Kalau udah clone dan install dependensi, kita lanjut ke bagian bikin model datanya ya. Kita bakal mulai dari bikin tabel tasks
di Drizzle + Supabase! π οΈ
ποΈ 3. Bikin Schema & Table: tasks
Sekarang saatnya kita bikin struktur data buat task-nya. Karena kita pakai Drizzle ORM, proses ini bakal terasa lebih TypeScript banget dan nggak ribet kayak SQL mentah.
Kita akan bikin tabel tasks
dengan kolom:
id
β ID uniktitle
β Judul taskdescription
β (opsional) isi atau penjelasan taskisDone
β apakah task sudah selesai atau belumuserId
β terrhubung ke user idcreatedAt
β waktu task dibuat
βοΈ Tambahkan Skema di Drizzle
Baut file src/db/schema/task.ts
dan tambahkan kode berikut:
import { boolean, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { dbSchema, user } from ".";
import { createInsertSchema } from "drizzle-zod";
export const task = dbSchema.table("tasks", {
id: uuid("id").primaryKey().defaultRandom(),
title: text("title").notNull(),
description: text("description"),
isDone: boolean("is_done").default(false).notNull(),
userId: text("user_id").references(() => user.id),
createdAt: timestamp("created_at").defaultNow(),
});
export type TaskType = typeof task.$inferSelect;
export const TaskInsertSchema = createInsertSchema(task);
Kemudian buka file src/db/schema/index.ts
dan tambahkan kode berikut diakhir line:
export * from "./task";
Jika sudah jalankan perintah ini:
bun run db:push
Jika tidak ada error maka akan seperti berikut:

Pastikan file
.env
kamu sudah berisiDATABASE_URL
dari Supabase ya. Kalau belum, kamu bisa dapatkannya dari dashboard Supabase β Project Settings β Database β Connection String (pilih format URI).
β Cek di Supabase
Kalau semuanya lancar, kamu bisa cek di Supabase dan harusnya tabel tasks
udah muncul otomatis π
Ini artinya database kamu siap dipakai untuk CRUD task yang akan kita bangun.

π 4. Setup tRPC Router untuk Task
Oke, kita udah punya tabel tasks
di database. Sekarang gimana caranya biar data itu bisa kita ambil dari frontend? Nah, di sinilah tRPC masuk!
Sebelum langsung ke koding, yuk kita kenalan dulu:
π¦ Apa itu tRPC?
tRPC adalah singkatan dari TypeScript Remote Procedure Call.
Intinya, tRPC memungkinkan kita bikin API tanpa harus nulis file API satu-satu kayak di app/api/task/route.ts
.
Kita cukup bikin satu router, lalu panggil fungsi-fungsi itu langsung dari frontend seolah-olah kita manggil function biasa.
Dan yang paling keren:
β TypeScript-nya otomatis sinkron antara client dan server.
β Gak perlu bikin API schema manual (gak usah pakai REST atau GraphQL).
β Cepat dan fleksibel, apalagi kalau project kamu growing.
Ibaratnya, tRPC tuh kayak ngobrol langsung antara frontend dan backend pakai walkie-talkie, tanpa ribet translator (alias REST/GraphQL).
π Kenapa kita pakai tRPC?
- Typesafe end-to-end Kalau kamu salah kirim tipe data dari frontend, error-nya langsung kelihatan di VSCode β bahkan sebelum dijalankan!
- Gak Perlu Bikin API Layer Berulang Gak ada lagi tuh nulis
GET
,POST
,PUT
,DELETE
di banyak file route. - Terintegrasi banget sama Next.js App Router Apalagi kalau pakai server actions atau route handlers.
π Installasi tRPC di Proyek Ini
Pertama install dulu library yang dibutuhkan dengan perintah berikut:
bun add @tanstack/react-query @tanstack/react-table @trpc/client @trpc/react-query @trpc/server superjson
Struktur folder akan seperti ini:

Kita mulai buat file src/trpc/init.ts
dan tambahkan kode ini:
// src/trpc/init.ts
import { getMe, getUser } from "@/actions/user";
import { initTRPC, TRPCError } from "@trpc/server";
import { cache } from "react";
import superjson from "superjson";
export const createTRPCContext = cache(async () => {
/**
* @see: <https://trpc.io/docs/server/context>
*/
const user = await getMe();
return { userId: user?.id };
});
export type Context = Awaited<ReturnType<typeof createTRPCContext>>;
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.context<Context>().create({
/**
* @see <https://trpc.io/docs/server/data-transformers>
*/
transformer: superjson,
});
// Base router and procedure helpers
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
export const baseProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(async function isAuthed(
opts
) {
const { ctx } = opts;
if (!ctx.userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
// console.log("TRPC_ERROR:", "UNAUTHORIZED");
}
const user = await getUser({ id: ctx.userId });
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return opts.next({
ctx: {
...ctx,
user,
},
});
});
Pasti ada yang error kan? π
Tenang⦠sekarang buat file src/actions/user/get-user.ts
dan tambahkan kode ini:
// src/actions/user/get-user.ts
"use server";
import { db } from "@/db";
import { user } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function getUser({ id }: { id: string }) {
const [data] = await db.select().from(user).where(eq(user.id, id));
return data ?? null;
}
Kemudian buat file src/actions/user/index.ts
dan tambahkan kode ini:
// src/actions/user/index.ts
export * from "./me";
export * from "./get-user";
Harusnya udah gak error lagi..
Sekarang buat file src/trpc/server.tsx
dan tambahkan kode ini:
// src/trpc/server.tsx
import "server-only"; // <-- ensure this file cannot be imported from the client
import { createHydrationHelpers } from "@trpc/react-query/rsc";
import { cache } from "react";
import { createCallerFactory, createTRPCContext } from "./init";
import { makeQueryClient } from "./query-client";
import { appRouter } from "./routers/_app";
// IMPORTANT: Create a stable getter for the query client that
// will return the same client during the same request.
export const getQueryClient = cache(makeQueryClient);
const caller = createCallerFactory(appRouter)(createTRPCContext);
export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>(
caller,
getQueryClient
);
Buat file src/trpc/query-client.ts
dan tambahkan kode ini:
// src/trpc/query-client.ts
import {
defaultShouldDehydrateQuery,
QueryClient,
} from "@tanstack/react-query";
import superjson from "superjson";
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
dehydrate: {
serializeData: superjson.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
},
hydrate: {
deserializeData: superjson.deserialize,
},
},
});
}
Fungsi makeQueryClient
ini digunakan untuk membuat custom QueryClient
untuk @tanstack/react-query
, dengan konfigurasi khusus untuk serialisasi dan deserialisasi data saat server-side rendering (SSR) atau static site generation (SSG) β termasuk pengaturan staleTime
.
Selanjutnya kitta buat file src/trpc/client.tsx
dan tambahkan kode ini:
// src/trpc/client.tsx
"use client";
// ^-- to make sure we can mount the Provider from a server component
import type { QueryClient } from "@tanstack/react-query";
import superjson from "superjson";
import { QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { useState } from "react";
import { makeQueryClient } from "./query-client";
import type { AppRouter } from "./routers/_app";
export const trpc = createTRPCReact<AppRouter>();
let clientQueryClientSingleton: QueryClient;
function getQueryClient() {
if (typeof window === "undefined") {
// Server: always make a new query client
return makeQueryClient();
}
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= makeQueryClient());
}
function getUrl() {
const base = (() => {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return "<http://localhost:3000>";
})();
return `${base}/api/trpc`;
}
export function TRPCProvider(
props: Readonly<{
children: React.ReactNode;
}>
) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
transformer: superjson,
url: getUrl(),
async headers() {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</trpc.Provider>
);
}
Kemudian kita buat file API nya src/app/api/trpc/[trpc]/route.ts
dan tambahkan kode ini:
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { createTRPCContext } from "@/trpc/init";
import { appRouter } from "@/trpc/routers/_app";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: createTRPCContext,
});
export { handler as GET, handler as POST };
Buat file src/trpc/routers/task.ts
dan tambahkan kode ini:
// src/trpc/routers/task.ts
import { db } from "@/db";
import { and, desc, eq, lt, or } from "drizzle-orm";
import { task, TaskInsertSchema, TaskUpdateSchema } from "@/db/schema";
import { createTRPCRouter, protectedProcedure } from "@/trpc/init";
import { TRPCError } from "@trpc/server";
import z from "zod";
export const taskRouters = createTRPCRouter({
getMyTask: protectedProcedure
.input(
z.object({
cursor: z
.object({
id: z.string().uuid(),
createdAt: z.date(),
})
.nullish(),
limit: z.number().min(1).max(100),
})
)
.query(async ({ ctx, input }) => {
const { cursor, limit } = input;
const data = await db
.select()
.from(task)
.where(
cursor
? or(
lt(task.createdAt, cursor.createdAt),
and(
eq(task.userId, ctx.userId!),
eq(task.createdAt, cursor.createdAt),
lt(task.id, cursor.id)
)
)
: undefined
)
.orderBy(desc(task.createdAt), desc(task.id))
.limit(limit + 1);
const hasMore = data.length > limit;
// Remove the last item if there is more data
const items = hasMore ? data.slice(0, -1) : data;
// Set the next cursor to the last item if there is more data
const lastItem = items[items.length - 1];
const nextCursor = hasMore
? { id: lastItem.id, createdAt: lastItem.createdAt! }
: null;
return { items, nextCursor };
}),
create: protectedProcedure
.input(
z.object({
title: z.string().min(4, { message: "Title is required" }),
description: z.string().min(10, { message: "Description is required" }),
isDone: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const parsedData = TaskInsertSchema.parse(input);
if (!parsedData) {
throw new TRPCError({ code: "BAD_REQUEST" });
}
const [taskCreated] = await db
.insert(task)
.values({ ...input, userId: ctx.userId })
.returning();
return taskCreated;
}),
update: protectedProcedure
.input(
z.object({
id: z.string().min(1, { message: "ID is required" }),
isDone: z.boolean(),
})
)
.mutation(async ({ ctx, input }) => {
const parsedData = TaskUpdateSchema.parse(input);
if (!parsedData) {
throw new TRPCError({ code: "BAD_REQUEST" });
}
const isTaskExist = await db
.select()
.from(task)
.where(and(eq(task.id, input.id), eq(task.userId, ctx.userId!)));
if (!isTaskExist) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const [taskUpdated] = await db
.update(task)
.set(input)
.where(eq(task.id, input.id))
.returning();
return taskUpdated;
}),
});
Kemudian buat file src/trpc/routers/_app.ts
dan tambahkan kode ini:
// src/trpc/routers/_app.ts
import { createTRPCRouter } from "../init";
import { taskRouters } from "./task";
export const appRouter = createTRPCRouter({
task: taskRouters,
});
// export type definition of API
export type AppRouter = typeof appRouter;
π§βπ» 5. Buat UI: List & Form Task
Oke, sekarang kita masuk ke bagian yang paling sering dilihat user: UI-nya.
Karena kita udah setup Shadcn UI dari awal, kita bisa langsung pakai komponen-komponen kece kayak Card
, Input
, Button
, dll.
π Flow-nya:
- Tampilkan list task
- Tambah task via form
- Update status done atau bahkan edit task (opsional, tapi asik)
Install library yang dibutuhkan terlebih dahulu dengan printah berikut:
bun add react-error-boundary
bunx [email protected] add card switch
Sekarang buka file src/app/dashboard/page.tsx
dan ubah jadi seperti ini:
// src/app/dashboard/page.tsx
import type { Metadata } from "next";
import { HydrateClient, trpc } from "@/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import AddTaskForm from "./components/add-task-form";
import { TaskList } from "./components/task-list";
export const metadata: Metadata = {
title: "Dashboard",
};
export default async function DashboardPage() {
void trpc.task.getMyTask.prefetchInfinite({
limit: 10,
});
return (
<HydrateClient>
<div className="space-y-6 p-6">
<AddTaskForm />
<Card>
<CardHeader>
<CardTitle>Task List</CardTitle>
</CardHeader>
<CardContent>
<TaskList />
</CardContent>
</Card>
</div>
</HydrateClient>
);
}
Selanjutnya buat file src/app/dsahboard/components/add-task-form.tsx
dan tambbahkan kode ini:
// src/app/dashboard/components/add-task-form.tsx
"use client";
import { trpc } from "@/trpc/client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
const TaskSchema = z.object({
title: z.string().min(4, { message: "Title is required" }),
description: z.string().min(10, { message: "Description is required" }),
isDone: z.boolean(),
});
type TaskValues = z.infer<typeof TaskSchema>;
const AddTaskForm = () => {
const utils = trpc.useUtils();
const form = useForm<TaskValues>({
resolver: zodResolver(TaskSchema),
defaultValues: {
title: "",
description: "",
isDone: false,
},
});
const createTask = trpc.task.create.useMutation({
onSuccess: () => {
utils.task.getMyTask.invalidate();
form.reset();
},
});
const addTask = (data: TaskValues) => {
const parsed = TaskSchema.safeParse(data);
if (!parsed.success) return;
createTask.mutate(data);
};
return (
<Card className="w-full border-none">
<CardHeader>
<CardTitle className="text-4xl leading-1">Buat Task</CardTitle>
</CardHeader>
<CardContent className="mt-10 space-y-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(addTask)}
className="grid grid-cols-4 items-center gap-4 gap-y-5"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Tulis judul"
disabled={createTask.isPending}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Deskripsi</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Tulis deskripsi"
disabled={createTask.isPending}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isDone"
render={({ field }) => (
<FormItem className="flex flex-col gap-4">
<div className="space-y-0.5">
<FormLabel>Is Done?</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="submit"
className="ml-auto w-fit"
disabled={createTask.isPending}
>
{createTask.isPending ? "Menambahkan..." : "Tambah Task"}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
};
export default AddTaskForm;
Kemudian buat file src/app/dashboard/components/task-list.tsx
lalu tambahkan kode ini:
// src/app/dashboard/compnents/task-list.tsx
"use client";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { trpc } from "@/trpc/client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useForm } from "react-hook-form";
import z from "zod";
const TaskSchema = z.object({
id: z.string().min(1, { message: "ID is required" }),
// title: z.string().min(4, { message: "Title is required" }),
// description: z.string().min(10, { message: "Description is required" }),
isDone: z.boolean(),
});
type TaskValues = z.infer<typeof TaskSchema>;
const SwitchIsDone = ({ id, isDone }: { id: string; isDone: boolean }) => {
const utils = trpc.useUtils();
const form = useForm<TaskValues>({
resolver: zodResolver(TaskSchema),
defaultValues: {
id,
// title: "",
// description: "",
isDone,
},
});
const updateTask = trpc.task.update.useMutation({
onSuccess: () => {
utils.task.getMyTask.invalidate();
},
});
const onUpdate = (data: TaskValues) => {
const parsed = TaskSchema.safeParse(data);
if (!parsed.success) return;
updateTask.mutate(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onUpdate)}>
<FormField
control={form.control}
name="isDone"
render={({ field }) => (
<FormItem className="flex flex-col gap-4">
<div className="space-y-0.5">
<FormLabel>Is Done?</FormLabel>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value); // update form state
form.handleSubmit(onUpdate)(); // langsung submit form
}}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
);
};
const TaskItemSuspense = () => {
const [tasks] = trpc.task.getMyTask.useSuspenseInfiniteQuery(
{
limit: 10,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
return (
<div className="flex flex-col gap-2">
{tasks.pages
.flatMap((page) => page.items)
.map((task) => (
<div key={task.id} className={"flex flex-col gap-1"}>
<h2 className="text-3xl font-bold">{task.title}</h2>
<p className="text-pretty">{task.description}</p>
<SwitchIsDone id={task.id} isDone={task.isDone} />
</div>
))}
</div>
);
};
export const TaskList = () => {
return (
<Suspense fallback={<p>Loading...</p>}>
<ErrorBoundary fallback={<p>Error</p>}>
<TaskItemSuspense />
</ErrorBoundary>
</Suspense>
);
};
Jalankan server dan buka halaman http://localhost:3000/dashboard
maka tampilan akan sepertit ini:

Seelesai juga nih⦠Fitur yang udah direapkan yaitu menampilkan daftar task, tambah task dan edit task.
π§ 6. Penutup β Simpel Tapi Powerful
Yesss, kita udah berhasil bikin aplikasi Task (to-do list) sederhana dengan:
β Next.js 15 sebagai fondasi fullstack
β tRPC buat komunikasi frontend β backend tanpa repot
β Drizzle ORM yang simpel dan aman buat query DB
β Supabase sebagai database modern
β Dan tentunya Shadcn UI biar tampilannya tetap clean dan minimalis
Walaupun sederhana, aplikasi ini udah cukup nunjukin alur kerja modern: mulai dari nge-query data, handle user input, sampai update state secara realtime. Semua tanpa harus repot bikin API berlapis-lapis.
π― Yang Bisa Kamu Lakuin Selanjutnya
Kalau kamu pengen lanjut eksplorasi, ini beberapa ide:
- βοΈ Tambahkan fitur edit task
- ποΈ Filter task berdasarkan status (done / undone)
- π Tambahkan field due date atau kategori
- π± Buat versi responsive / mobile first
Terima kasih udah ngoding bareng.
Sampai jumpa di artikel berikutnya! π