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 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 unik
  • title β†’ Judul task
  • description β†’ (opsional) isi atau penjelasan task
  • isDone β†’ apakah task sudah selesai atau belum
  • userIdβ†’ terrhubung ke user id
  • createdAt β†’ 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
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
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?

  1. Typesafe end-to-end Kalau kamu salah kirim tipe data dari frontend, error-nya langsung kelihatan di VSCode β€” bahkan sebelum dijalankan!
  2. Gak Perlu Bikin API Layer Berulang Gak ada lagi tuh nulis GET, POST, PUT, DELETE di banyak file route.
  3. 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
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:

  1. Tampilkan list task
  2. Tambah task via form
  3. 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:

Tampilaan buat task
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! πŸš€