Tutorial Laravel 12 API Next JS 15 dan Tailwind CSS #16 Page Component

Belajar membangun aplikasi fullstack modern dengan backend Laravel 12 RESTful API, frontend Next.js, dan desain menggunakan Tailwind CSS. Tutorial ini membahas step-by-step mulai dari setup environment, pembuatan API, konsumsi API di Next.js, hingga integrasi UI responsif.

✅ Telah dilihat 347 kali

Rating: 5.00 ⭐

... 13 August 2025, 20:33

Page Component

Pada materi kali ini, kita akan belajar membuat sebuah page component di dalam Next.js. Page component berfungsi sebagai pusat logika yang mengatur hubungan antara berbagai bagian aplikasi, seperti komponenservicehooks, maupun library yang sudah kita buat sebelumnya.

Dengan kata lain, page component adalah titik utama sebuah halaman dalam Next.js yang akan merangkai seluruh elemen tersebut menjadi satu kesatuan tampilan yang utuh.


Struktur Folder

Di dalam folder app, silakan tambahkan sebuah folder baru dengan nama products. Kemudian, di dalam folder products buatlah sebuah file dengan nama page.tsx.

Perlu diperhatikan bahwa ketika kita membuat folder products di dalam app, folder tersebut secara otomatis akan menjadi route pada aplikasi Next.js. Artinya, file page.tsx di dalam folder tersebut akan merender halaman dengan URL:

/products

Silakan install package berikut terlebih dahulu:

npx shadcn@latest add alert

Kemudian buat file baru didalam components/ui buat dengan nama global-alert kemudian masukkan kode berikut ini:

"use client"

import { createContext, useContext, useState, ReactNode } from "react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"

type AlertType = "default" | "destructive"

interface AlertState {
  visible: boolean
  type: AlertType
  title: string
  message: string
}

interface AlertContextValue {
  showAlert: (type: AlertType, title: string, message: string) => void
}

const AlertContext = createContext<AlertContextValue | undefined>(undefined)

export function AlertProvider({ children }: { children: ReactNode }) {
  const [alert, setAlert] = useState<AlertState>({
    visible: false,
    type: "default",
    title: "",
    message: "",
  })

  const showAlert = (type: AlertType, title: string, message: string) => {
    setAlert({ visible: true, type, title, message })
    setTimeout(() => {
      setAlert((prev) => ({ ...prev, visible: false }))
    }, 3000)
  }

  return (
    <AlertContext.Provider value={{ showAlert }}>
      {alert.visible && (
        <div className="fixed top-4 right-4 z-50 w-96">
          <Alert variant={alert.type}>
            <AlertTitle>{alert.title}</AlertTitle>
            <AlertDescription>{alert.message}</AlertDescription>
          </Alert>
        </div>
      )}
      {children}
    </AlertContext.Provider>
  )
}

export function useAlert() {
  const context = useContext(AlertContext)
  if (!context) throw new Error("useAlert must be used within an AlertProvider")
  return context
}

File ini berisi sebuah Global Alert System dengan React Context, yang bisa dipanggil dari component manapun di aplikasi.

Tipe Data

type AlertType = "default" | "destructive"
  • Hanya ada 2 tipe alert: normal (default) dan error (destructive).
interface AlertState {
  visible: boolean
  type: AlertType
  title: string
  message: string
}
  • State yang disimpan untuk alert: apakah tampil (visible), jenis alert (type), judul, dan pesan.
interface AlertContextValue {
  showAlert: (type: AlertType, title: string, message: string) => void
}
  • Menyediakan fungsi showAlert agar component lain bisa memanggil alert.

Context

const AlertContext = createContext<AlertContextValue | undefined>(undefined)
  • Membuat AlertContext untuk membagikan fungsi showAlert ke seluruh aplikasi.

Provider

export function AlertProvider({ children }: { children: ReactNode }) {
  const [alert, setAlert] = useState<AlertState>({
    visible: false,
    type: "default",
    title: "",
    message: "",
  })
  • AlertProvider adalah pembungkus utama aplikasi.
  • Menyimpan state alert (apakah aktif, jenis, judul, pesan).

Integrasi AlertProvider di Next.js App Router

Ketika kita ingin menggunakan useAlert() dari library react-alert, hook ini hanya bisa dipanggil jika komponen berada di dalam AlertProvider. Kalau tidak, akan muncul error:

Runtime Error
useAlert must be used within an AlertProvider

Solusinya adalah dengan menambahkan AlertProvider di file RootLayout agar seluruh aplikasi bisa mengakses alert.

Silakan buak file layout.tsx kemudian ubah menjadi seperti berikut ini:

import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider"
import { AlertProvider } from "@/components/ui/global-alert"

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          <AlertProvider>
            {children}
          </AlertProvider>
        </ThemeProvider>
      </body>
    </html>
  );
}

Dengan menambahkan <AlertProvider> Semua halaman di dalam app/ sudah otomatis bisa menggunakan useAlert.


Page Products

Silakan buka file page.tsx didalam folder products, kemudian masukkan baris kode berikut ini:

"use client"

import { useState } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { SiteHeader } from "@/components/site-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"

import { useProducts } from "@/hooks/useProducts"
import { Product, ProductPayload } from "@/services/products"
import { ProductTable } from "@/components/products/ProductTable"
import { ProductForm } from "@/components/products/ProductForm"
import { DeleteConfirmDialog } from "@/components/products/DeleteConfirmDialog"
import { Breadcrumbs } from "@/components/products/Breadcrumbs"
import { useAlert } from "@/components/ui/global-alert"

export default function ProductPage() {
    const { products, loading, addProduct, editProduct, removeProduct } = useProducts()
    const { showAlert } = useAlert()
    // Form state
    const [form, setForm] = useState<ProductPayload>({ name: "", description: "", price: 0, stock: 0 })
    const [errors, setErrors] = useState<Record<string, string>>({})
    const [isSubmitting, setIsSubmitting] = useState(false)

    // Dialog state
    const [addOpen, setAddOpen] = useState(false)
    const [editOpen, setEditOpen] = useState(false)
    const [deleteOpen, setDeleteOpen] = useState(false)

    const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
    const [productToDelete, setProductToDelete] = useState<Product | null>(null)

    const validateForm = () => {
        const newErrors: Record<string, string> = {}
        if (!form.name.trim()) newErrors.name = "Nama produk wajib diisi"
        if (form.price <= 0) newErrors.price = "Harga harus lebih dari 0"
        if (form.stock < 0) newErrors.stock = "Stok tidak boleh negatif"
        setErrors(newErrors)
        return Object.keys(newErrors).length === 0
    }

    const handleAddSubmit = async (e: React.FormEvent) => {
        e.preventDefault()
        if (!validateForm()) return
        setIsSubmitting(true)
        try {
            await addProduct(form)
            setForm({ name: "", description: "", price: 0, stock: 0 })
            setAddOpen(false)
            showAlert("default", "Produk Ditambahkan", "Produk baru berhasil disimpan.")
        } finally {
            setIsSubmitting(false)
        }
    }

    const handleEditSubmit = async (e: React.FormEvent) => {
        e.preventDefault()
        if (!validateForm() || !selectedProduct) return
        setIsSubmitting(true)
        try {
            await editProduct(selectedProduct.id, form)
            setForm({ name: "", description: "", price: 0, stock: 0 })
            setSelectedProduct(null)
            setEditOpen(false)
            showAlert("default", "Produk Diperbarui", "Data produk berhasil diperbarui.")
        } finally {
            setIsSubmitting(false)
        }
    }

    const handleDeleteConfirm = async () => {
        if (!productToDelete) return
        await removeProduct(productToDelete.id)
        setProductToDelete(null)
        setDeleteOpen(false)
        showAlert("destructive", "Produk Dihapus", "Produk berhasil dihapus.")
    }

    return (
        <SidebarProvider
            style={
                {
                    "--sidebar-width": "calc(var(--spacing) * 72)",
                    "--header-height": "calc(var(--spacing) * 12)",
                } as React.CSSProperties
            }
        >
            <AppSidebar variant="inset" />
            <SidebarInset>
                <SiteHeader />
                <div className="flex flex-1 flex-col">
                    <div className="@container/main flex flex-1 flex-col gap-2">
                        <div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
                            <div className="px-4 lg:px-6">
                                <div className="flex items-center justify-between mb-4">
                                    {/* Dialog Tambah */}
                                    <Dialog open={addOpen} onOpenChange={setAddOpen}>
                                        <DialogTrigger asChild>
                                            <Button variant="outline">Tambah Product</Button>
                                        </DialogTrigger>
                                        <DialogContent>
                                            <DialogHeader>
                                                <DialogTitle>Tambah Produk Baru</DialogTitle>
                                                <DialogDescription>Isi data produk di bawah, lalu klik simpan.</DialogDescription>
                                            </DialogHeader>
                                            <ProductForm
                                                form={form}
                                                setForm={setForm}
                                                errors={errors}
                                                isSubmitting={isSubmitting}
                                                onSubmit={handleAddSubmit}
                                            />
                                        </DialogContent>
                                    </Dialog>

                                    <Breadcrumbs />
                                </div>

                                {loading ? (
                                    <p>Loading...</p>
                                ) : (
                                    <ProductTable
                                        products={products}
                                        onEdit={(product) => {
                                            setSelectedProduct(product)
                                            setForm({
                                                name: product.name,
                                                description: product.description || "",
                                                price: product.price,
                                                stock: product.stock,
                                            })
                                            setEditOpen(true)
                                        }}
                                        onDelete={(product) => {
                                            setProductToDelete(product)
                                            setDeleteOpen(true)
                                        }}
                                    />
                                )}
                            </div>
                        </div>
                    </div>
                </div>
            </SidebarInset>

            {/* Dialog Edit */}
            <Dialog open={editOpen} onOpenChange={setEditOpen}>
                <DialogContent>
                    <DialogHeader>
                        <DialogTitle>Edit Produk</DialogTitle>
                        <DialogDescription>Ubah data produk di bawah, lalu klik simpan.</DialogDescription>
                    </DialogHeader>
                    <ProductForm
                        form={form}
                        setForm={setForm}
                        errors={errors}
                        isSubmitting={isSubmitting}
                        onSubmit={handleEditSubmit}
                    />
                </DialogContent>
            </Dialog>

            {/* Dialog Delete */}
            <DeleteConfirmDialog
                open={deleteOpen}
                onOpenChange={setDeleteOpen}
                product={productToDelete}
                onConfirm={handleDeleteConfirm}
            />
        </SidebarProvider>
    )
}

Directive & Import

"use client"

import { useState } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { SiteHeader } from "@/components/site-header"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"

import { useProducts } from "@/hooks/useProducts"
import { Product, ProductPayload } from "@/services/products"
import { ProductTable } from "@/components/products/ProductTable"
import { ProductForm } from "@/components/products/ProductForm"
import { DeleteConfirmDialog } from "@/components/products/DeleteConfirmDialog"
import { Breadcrumbs } from "@/components/products/Breadcrumbs"
import { useAlert } from "@/components/ui/global-alert"
  • "use client" → menandakan bahwa ini Client Component (boleh pakai state, event, hook).
  • Import berbagai komponen & hook:
    • Layout: Sidebar, Header.
    • UI: Button, Dialog.
    • Produk: Table, Form, Delete dialog, Breadcrumb.
    • HookuseProducts (untuk CRUD produk), useAlert (untuk notifikasi).

Deklarasi Komponen

export default function ProductPage() {
  • Mendefinisikan halaman ProductPage yang akan dirender di Next.js.
  • Semua logic CRUD dan UI produk ada di sini.

Hook Data Produk & Alert

const { products, loading, addProduct, editProduct, removeProduct } = useProducts()
const { showAlert } = useAlert()
  • useProducts → ambil data produk + fungsi CRUD.
  • useAlert → untuk menampilkan pesan feedback (misalnya "Produk berhasil ditambahkan").

Form State

const [form, setForm] = useState<ProductPayload>({ name: "", description: "", price: 0, stock: 0 })
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
  • form → menampung input form produk (Tambah/Edit).
  • errors → validasi form.
  • isSubmitting → status loading saat form disubmit.

Dialog State

const [addOpen, setAddOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
  • Kontrol buka/tutup dialog: tambah, edit, hapus.

Produk yang Dipilih

const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
const [productToDelete, setProductToDelete] = useState<Product | null>(null)
  • selectedProduct → produk yang sedang diedit.
  • productToDelete → produk yang sedang dihapus.

Validasi Form

const validateForm = () => {
  const newErrors: Record<string, string> = {}
  if (!form.name.trim()) newErrors.name = "Nama produk wajib diisi"
  if (form.price <= 0) newErrors.price = "Harga harus lebih dari 0"
  if (form.stock < 0) newErrors.stock = "Stok tidak boleh negatif"
  setErrors(newErrors)
  return Object.keys(newErrors).length === 0
}
  • Mengecek form sebelum disubmit:
    • Nama wajib diisi.
    • Harga > 0.
    • Stok ≥ 0.
  • Kalau ada error → simpan di errors.

Handle Tambah Produk

const handleAddSubmit = async (e: React.FormEvent) => {
  e.preventDefault()
  if (!validateForm()) return
  setIsSubmitting(true)
  try {
    await addProduct(form)
    setForm({ name: "", description: "", price: 0, stock: 0 })
    setAddOpen(false)
    showAlert("default", "Produk Ditambahkan", "Produk baru berhasil disimpan.")
  } finally {
    setIsSubmitting(false)
  }
}
  • Cek validasi → kirim data ke API (addProduct).
  • Reset form → tutup dialog → tampilkan notifikasi sukses.

Handle Edit Produk

const handleEditSubmit = async (e: React.FormEvent) => {
  e.preventDefault()
  if (!validateForm() || !selectedProduct) return
  setIsSubmitting(true)
  try {
    await editProduct(selectedProduct.id, form)
    setForm({ name: "", description: "", price: 0, stock: 0 })
    setSelectedProduct(null)
    setEditOpen(false)
    showAlert("default", "Produk Diperbarui", "Data produk berhasil diperbarui.")
  } finally {
    setIsSubmitting(false)
  }
}
  • Sama seperti tambah, tapi memakai editProduct dan butuh selectedProduct.

Handle Hapus Produk

const handleDeleteConfirm = async () => {
  if (!productToDelete) return
  await removeProduct(productToDelete.id)
  setProductToDelete(null)
  setDeleteOpen(false)
  showAlert("destructive", "Produk Dihapus", "Produk berhasil dihapus.")
}
  • Menghapus produk lewat removeProduct.
  • Reset state & tutup dialog delete.

Render Layout

return (
  <SidebarProvider style={{ "--sidebar-width": "...", "--header-height": "..." }}>
    <AppSidebar variant="inset" />
    <SidebarInset>
      <SiteHeader />
      <div className="flex flex-1 flex-col">
        ...
      </div>
    </SidebarInset>
  • Bungkus halaman dengan SidebarProvider.
  • Tampilkan AppSidebar dan SiteHeader.

Dialog Tambah Produk

<Dialog open={addOpen} onOpenChange={setAddOpen}>
  <DialogTrigger asChild>
    <Button variant="outline">Tambah Product</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Tambah Produk Baru</DialogTitle>
      <DialogDescription>Isi data produk di bawah, lalu klik simpan.</DialogDescription>
    </DialogHeader>
    <ProductForm
      form={form}
      setForm={setForm}
      errors={errors}
      isSubmitting={isSubmitting}
      onSubmit={handleAddSubmit}
    />
  </DialogContent>
</Dialog>
  • Tombol Tambah Produk akan membuka popup form.
  • Di dalamnya ada ProductForm.

Breadcrumbs

<Breadcrumbs />
  • Menampilkan navigasi breadcrumb di bagian atas halaman.

Tabel Produk

{loading ? (
  <p>Loading...</p>
) : (
  <ProductTable
    products={products}
    onEdit={(product) => { ... }}
    onDelete={(product) => { ... }}
  />
)}
  • Jika loading → tampil teks loading.
  • Jika tidak → render tabel produk (ProductTable) dengan action Edit & Delete.

Dialog Edit Produk

<Dialog open={editOpen} onOpenChange={setEditOpen}>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Edit Produk</DialogTitle>
      <DialogDescription>Ubah data produk di bawah, lalu klik simpan.</DialogDescription>
    </DialogHeader>
    <ProductForm
      form={form}
      setForm={setForm}
      errors={errors}
      isSubmitting={isSubmitting}
      onSubmit={handleEditSubmit}
    />
  </DialogContent>
</Dialog>
  • Mirip Tambah Produk, hanya bedanya pakai handleEditSubmit.

Dialog Delete Produk

<DeleteConfirmDialog
  open={deleteOpen}
  onOpenChange={setDeleteOpen}
  product={productToDelete}
  onConfirm={handleDeleteConfirm}
/>
  • Popup konfirmasi hapus.
  • Tombol Ya/Hapus akan memanggil handleDeleteConfirm.

Sampai pada tahap ini, kita telah berhasil membuat satu halaman lengkap beserta logikanya. Pada materi selanjutnya, kita akan melanjutkan dengan melakukan uji coba menggunakan Laravel API agar data dapat terhubung secara real-time.

🔥 Flash Sale


📜 Table Of Contents


📌 Daftar Episode


Daftar eBook