Membuat Aplikasi Task Sederhana dengan Next.js 15, Drizzle ORM, Supabase, dan tRPC
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 tasktRPC → Supaya client dan server bisa ngobrol langsung tanpa ribetDrizzle ORM → Biar ngoding database jadi lebih santai, typesafe, dan rapiSupabase → 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 modernSupabase — sebagai database utama dan sistem authDrizzle ORM — supaya query ke database jadi lebih TypeScript-friendlytRPC — 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: Push schema ke supabase Pastikan file .env kamu sudah berisi DATABASE_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. Supabase schema 🔌 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: tRPC folder 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 taskTambah task via formUpdate 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: Tampilaan buat task 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! 🚀