Hello and welcome to my coding course to build a full-fledged admin dashboard by the best tech stack in the world: Nextjs 15, React 19, Drizzle Orm, and Shadcn UI.
👉 Code : https://github.com/basir/next-15-admin-dashboard
👉 Demo : https://next-15-admin-dashboard.vercel.app
👉 Q/A : https://github.com/basir/next-15-admin-dashboard/issues
Watch Nextjs 15 & React 19 Dashboard App Step By Step Tutorial
This admin dashboard is the updated version of acme project on https://nextjs.org/learn
Here I walk you though all steps to build a real-world admin dashboard from scratch.
- we will develop a responsive homepage that follows the best design practices we have. A header with hero section and call to action button to login.
- A dashboard screen with sidebar navigation on desktop and header menu on mobile device.
- We'll create stat boxes, bar charts, data tables on dashboard page.
- invoice management from where you can filter, create, update and delete invoices.
- also we'll create customers page where you can filter users based on their name and email.
My name is Basir and I’ll be your instructor in this course. I am a senior web developer in international companies like ROI Vision in Montreal, and a coding instructor with 50 thousands students around the world.
You need to open the code editor along with me and start coding throughout this course.
I teach you:
- creating admin dashboard web app by next.js 15 and react 19
- designing header, footer, sidebar, menu and search box by shadcn and tailwind
- enable partial pre-rendering to improve website performance
- create database models by drizzle orm and postgres database to handle invoices, customers and users.
- handling form inputs by useActionState and Zod data validator
- updating data by server actions without using any api
- rendering beautiful charts by recharts
- handling authentication and authorization by next-auth
- and toggling dark and light theme by next-theme
- at the end you'll learn how to deploy admin dashboard on vercel.
I designed this course for beginner web developers who want to learn all new features of next 15 and react 19 features in a real-world project. If you are or want to a web developer, take this course to become a professional web developer, have a great project in your portfolio and get a job in 22 million job opportunities around the world.
The only requirement for this course is having basic knowledge on react and next.js.
01. create next app
- npm install -g pnpm
- pnpm create next-app@rc
- pnpm dev
- lib/constants.ts
export const SERVER_URL = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000' export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME || 'NextAdmin' export const APP_DESCRIPTION = process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'An modern dashboard built with Next.js 15, Postgres, Shadcn' export const ITEMS_PER_PAGE = Number(process.env.ITEMS_PER_PAGE) || 5
- components/shared/fonts.ts
import { Inter, Lusitana } from 'next/font/google' export const inter = Inter({ subsets: ['latin'] }) export const lusitana = Lusitana({ weight: ['400', '700'], subsets: ['latin'], })
- app/layout.tsx
export const metadata: Metadata = { title: { template: `%s | ${APP_NAME}`, default: APP_NAME, }, description: APP_DESCRIPTION, metadataBase: new URL(SERVER_URL), } export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html lang="en" suppressHydrationWarning> <body className={`${inter.className} antialiased`}>{children}</body> </html> ) }
- components/shared/app-logo.tsx
export default function AppLogo() { return ( <Link href="/" className="flex-start"> <div className={`${lusitana.className} flex flex-row items-end space-x-2`} > <Image src="/logo.png" width={32} height={32} alt={`${APP_NAME} logo`} priority /> <span className="text-xl">{APP_NAME}</span> </div> </Link> ) }
- app/page.tsx
export default function Page() { return ( <main className="flex min-h-screen flex-col "> <div className="flex h-20 shrink-0 items-center rounded-lg p-4 md:h-40 bg-secondary"> <AppLogo /> </div> <div className="mt-4 flex grow flex-col gap-4 md:flex-row"> <div className="flex flex-col justify-center gap-6 rounded-lg px-6 py-10 md:w-2/5 md:px-20"> <p className={`${lusitana.className} text-xl md:text-3xl md:leading-normal`} > <strong>Welcome to Next 15 Admin Dashboard.</strong> </p> <Link href="/login"> <span>Log in</span> <ArrowRightIcon className="w-6" /> </Link> </div> <div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12"> <Image src="/hero-desktop.png" width={1000} height={760} alt="Screenshots of the dashboard project showing desktop version" className="hidden md:block" /> <Image src="/hero-mobile.png" width={560} height={620} alt="Screenshot of the dashboard project showing mobile version" className="block md:hidden" /> </div> </div> </main> ) }
02. create login page
- pnpm add next-auth@beta bcryptjs
- pnpm add -D @types/bcryptjs
-
lib/placeholder-data.ts
const users = [ { id: '410544b2-4001-4271-9855-fec4b6a6442a', name: 'User', email: 'user@nextmail.com', password: hashSync('123456', 10), }, ] export { users }
-
auth.config.ts
import type { NextAuthConfig } from 'next-auth' export const authConfig = { pages: { signIn: '/login', }, providers: [ // added later in auth.ts since it requires bcrypt which is only compatible with Node.js // while this file is also used in non-Node.js environments ], callbacks: { authorized({ auth, request: { nextUrl } }) { const isLoggedIn = !!auth?.user const isOnDashboard = nextUrl.pathname.startsWith('/dashboard') if (isOnDashboard) { if (isLoggedIn) return true return false // Redirect unauthenticated users to login page } else if (isLoggedIn) { return Response.redirect(new URL('/dashboard', nextUrl)) } return true }, }, } satisfies NextAuthConfig
-
auth.ts
export const { auth, signIn, signOut } = NextAuth({ ...authConfig, providers: [ credentials({ async authorize(credentials) { const user = users.find((x) => x.email === credentials.email) if (!user) return null const passwordsMatch = await compare( credentials.password as string, user.password ) if (passwordsMatch) return user console.log('Invalid credentials') return null }, }), ], })
-
middleware.ts
import NextAuth from 'next-auth' import { authConfig } from './auth.config' export default NextAuth(authConfig).auth export const config = { // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher matcher: [ '/((?!api|_next/static|_next/image|.*\\.svg$|.*\\.png$|.*\\.jpeg$).*)', ], }
-
lib/actions/user.actions.ts
'use server' export async function authenticate( prevState: string | undefined, formData: FormData ) { try { await signIn('credentials', formData) } catch (error) { if (error instanceof AuthError) { switch (error.type) { case 'CredentialsSignin': return 'Invalid credentials.' default: return 'Something went wrong.' } } throw error } }
install shadcn-ui from https://ui.shadcn.com/docs/installation/next
pnpm dlx shadcn-ui@latest add button card
-
components/shared/login-form.tsx
export default function LoginForm() { const [errorMessage, formAction, isPending] = useActionState( authenticate, undefined ) return ( <form action={formAction}> <div className="flex-1 rounded-lg px-6 pb-4 pt-8"> <h1 className={`${lusitana.className} mb-3 text-2xl`}> Please log in to continue. </h1> <div className="w-full"> <div> <label className="mb-3 mt-5 block text-xs font-medium " htmlFor="email" > Email </label> <div className="relative"> <input className="peer block w-full rounded-md border py-[9px] pl-10 text-sm outline-2 " id="email" type="email" name="email" placeholder="Enter your email address" required /> <AtSign className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " /> </div> </div> <div className="mt-4"> <label className="mb-3 mt-5 block text-xs font-medium " htmlFor="password" > Password </label> <div className="relative"> <input className="peer block w-full rounded-md border py-[9px] pl-10 text-sm outline-2 " id="password" type="password" name="password" placeholder="Enter password" required minLength={6} /> <LockKeyhole className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " /> </div> </div> </div> <div className="mt-4"> <Button aria-disabled={isPending}> Log in <ArrowRightIcon className="ml-auto h-5 w-5 " /> </Button> </div> <div className="flex h-8 items-end space-x-1" aria-live="polite" aria-atomic="true" > {errorMessage && ( <> <CircleAlert className="h-5 w-5 text-red-500" /> <p className="text-sm text-red-500">{errorMessage}</p> </> )} </div> </div> </form> ) }
app/login/page.tsx
export default function LoginPage() { return ( <div className="flex justify-center items-center min-h-screen w-full "> <main className="w-full max-w-md mx-auto"> <Card> <CardHeader className="space-y-4 flex justify-center items-center"> <AppLogo /> </CardHeader> <CardContent className="space-y-4"> <LoginForm /> </CardContent> </Card> </main> </div> ) }
03. create dashboard page
- pnpm dlx shadcn-ui@latest add dropdown-menu
- pnpm add next-themes
- app/layout.tsx
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange > {children} </ThemeProvider>
- components/shared/dashboard/mode-toggle.tsx
export default function ModeToggle() { const { theme, setTheme } = useTheme() const [mounted, setMounted] = React.useState(false) React.useEffect(() => { setMounted(true) }, []) if (!mounted) { return null } return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="w-full text-muted-foreground justify-start focus-visible:ring-0 focus-visible:ring-offset-0" > <SunMoon className="w-6 mr-2" /> <span className="hidden md:block"> {capitalizeFirstLetter(theme!)} Theme </span> </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-56"> <DropdownMenuLabel>Appearance</DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuCheckboxItem checked={theme === 'system'} onClick={() => setTheme('system')} > System </DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem checked={theme === 'light'} onClick={() => setTheme('light')} > Light </DropdownMenuCheckboxItem> <DropdownMenuCheckboxItem checked={theme === 'dark'} onClick={() => setTheme('dark')} > Dark </DropdownMenuCheckboxItem> </DropdownMenuContent> </DropdownMenu> ) }
- components/shared/dashboard/sidenav.tsx
export default function SideNav() { return ( <div className="flex h-full flex-col px-3 py-4 md:px-2"> <div> <AppLogo /> </div> <div className="flex grow flex-row space-x-2 md:flex-col md:space-x-0 md:space-y-2 md:mt-2"> <NavLinks /> <div className="h-auto w-full grow rounded-md md:block"></div> <div className="flex md:flex-col "> <ModeToggle /> <form action={async () => { 'use server' await signOut() }} > <Button variant="ghost" className="w-full justify-start text-muted-foreground" > <PowerIcon className="w-6 mr-2" /> <div className="hidden md:block">Sign Out</div> </Button> </form> </div> </div> </div> ) }
- app/dashboard/layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) { return ( <div className="flex h-screen flex-col md:flex-row md:overflow-hidden"> <div className="w-full flex-none md:w-52 bg-secondary"> <SideNav /> </div> <div className="grow p-6 md:overflow-y-auto ">{children}</div> </div> ) }
- pnpm dlx shadcn-ui@latest add skeleton
- components/shared/skeletons.tsx
export function CardSkeleton() { return ( <Card> <CardHeader className="flex flex-row space-y-0 space-x-3 "> <Skeleton className="w-6 h-6 rounded-full" /> <Skeleton className="w-20 h-6" /> </CardHeader> <CardContent> <Skeleton className="h-10 w-full" /> </CardContent> </Card> ) } export function CardsSkeleton() { return ( <> <CardSkeleton /> <CardSkeleton /> <CardSkeleton /> <CardSkeleton /> </> ) } export function RevenueChartSkeleton() { return ( <Card className="w-full md:col-span-4"> <CardHeader> <Skeleton className="w-36 h-6 mb-4" /> </CardHeader> <CardContent> <Skeleton className="sm:grid-cols-13 mt-0 grid h-[450px] grid-cols-12 items-end gap-2 rounded-md p-4 md:gap-4" /> </CardContent> </Card> ) } export function InvoiceSkeleton() { return ( <div className="flex flex-row items-center justify-between border-b py-4"> <div className="flex items-center space-x-4"> <Skeleton className="w-6 h-6 rounded-full" /> <div className="min-w-0 space-y-2"> <Skeleton className="w-20 h-6" /> <Skeleton className="w-20 h-6" /> </div> </div> <Skeleton className="w-20 h-6" /> </div> ) } export function LatestInvoicesSkeleton() { return ( <Card className="flex w-full flex-col md:col-span-4"> <CardHeader> <Skeleton className="w-36 h-6 mb-4" /> </CardHeader> <CardContent> <div> <InvoiceSkeleton /> <InvoiceSkeleton /> <InvoiceSkeleton /> <InvoiceSkeleton /> <InvoiceSkeleton /> </div> </CardContent> </Card> ) } export default function DashboardSkeleton() { return ( <> <Skeleton className="w-36 h-6 mb-4" /> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"> <CardSkeleton /> <CardSkeleton /> <CardSkeleton /> <CardSkeleton /> </div> <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8"> <RevenueChartSkeleton /> <LatestInvoicesSkeleton /> </div> </> ) }
- app/dashboard/(overview)/page.tsx
export default async function Page() { return ( <main> <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}> Dashboard </h1> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"> <CardsSkeleton /> </div> <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8"> <RevenueChartSkeleton /> <LatestInvoicesSkeleton /> </div> </main> ) }
- dd
import DashboardSkeleton from '@/components/shared/skeletons' export default function Loading() { return <DashboardSkeleton /> }
04. connect to database
- create postgres database on https://vercel.com/storage/postgres
- pnpm add drizzle-orm @vercel/postgres
- pnpm add -D drizzle-kit
- db/env-config.ts
import { loadEnvConfig } from '@next/env' const projectDir = process.cwd() loadEnvConfig(projectDir)
- db/schema.ts
import { pgTable, uuid, varchar, unique, integer, text, date, } from 'drizzle-orm/pg-core' import { sql } from 'drizzle-orm' export const customers = pgTable('customers', { id: uuid('id') .default(sql`uuid_generate_v4()`) .primaryKey() .notNull(), name: varchar('name', { length: 255 }).notNull(), email: varchar('email', { length: 255 }).notNull(), image_url: varchar('image_url', { length: 255 }).notNull(), }) export const revenue = pgTable( 'revenue', { month: varchar('month', { length: 4 }).notNull(), revenue: integer('revenue').notNull(), }, (table) => { return { revenue_month_key: unique('revenue_month_key').on(table.month), } } ) export const users = pgTable( 'users', { id: uuid('id') .default(sql`uuid_generate_v4()`) .primaryKey() .notNull(), name: varchar('name', { length: 255 }).notNull(), email: text('email').notNull(), password: text('password').notNull(), }, (table) => { return { users_email_key: unique('users_email_key').on(table.email), } } ) export const invoices = pgTable('invoices', { id: uuid('id') .default(sql`uuid_generate_v4()`) .primaryKey() .notNull(), customer_id: uuid('customer_id').notNull(), amount: integer('amount').notNull(), status: varchar('status', { length: 255 }).notNull(), date: date('date').notNull(), })
- db/drizzle.ts
import * as schema from './schema' import { drizzle } from 'drizzle-orm/vercel-postgres' import { sql } from '@vercel/postgres' const db = drizzle(sql, { schema, }) export default db
- drizzle.config.ts
import '@/db/env-config' import { defineConfig } from 'drizzle-kit' export default defineConfig({ schema: './db/schema.ts', out: './drizzle', dialect: 'postgresql', dbCredentials: { url: process.env.POSTGRES_URL!, }, })
- lib/placeholder-data.ts
const customers = [ { id: 'd6e15727-9fe1-4961-8c5b-ea44a9bd81aa', name: 'Amari Hart', email: 'amari@gmail.com', image_url: '/customers/a1.jpeg', }, { id: '3958dc9e-712f-4377-85e9-fec4b6a6442a', name: 'Alexandria Brown', email: 'brown@gmail.com', image_url: '/customers/a2.jpeg', }, { id: '3958dc9e-742f-4377-85e9-fec4b6a6442a', name: 'Emery Cabrera', email: 'emery@example.com', image_url: '/customers/a3.jpeg', }, { id: '76d65c26-f784-44a2-ac19-586678f7c2f2', name: 'Michael Novotny', email: 'michael@novotny.com', image_url: '/customers/a4.jpeg', }, { id: 'CC27C14A-0ACF-4F4A-A6C9-D45682C144B9', name: 'Lily Conrad', email: 'lily@yahoo.com', image_url: '/customers/a5.jpeg', }, { id: '13D07535-C59E-4157-A011-F8D2EF4E0CBB', name: 'Ricky Mata', email: 'ricky@live.com', image_url: '/customers/a6.jpeg', }, ] const invoices = [ { customer_id: customers[0].id, amount: 15795, status: 'pending', date: '2022-12-06', }, { customer_id: customers[1].id, amount: 20348, status: 'pending', date: '2022-11-14', }, { customer_id: customers[4].id, amount: 3040, status: 'paid', date: '2022-10-29', }, { customer_id: customers[3].id, amount: 44800, status: 'paid', date: '2023-09-10', }, { customer_id: customers[5].id, amount: 34577, status: 'pending', date: '2023-08-05', }, { customer_id: customers[2].id, amount: 54246, status: 'pending', date: '2023-07-16', }, { customer_id: customers[0].id, amount: 666, status: 'pending', date: '2023-06-27', }, { customer_id: customers[3].id, amount: 32545, status: 'paid', date: '2023-06-09', }, { customer_id: customers[4].id, amount: 1250, status: 'paid', date: '2023-06-17', }, { customer_id: customers[5].id, amount: 8546, status: 'paid', date: '2023-06-07', }, { customer_id: customers[1].id, amount: 500, status: 'paid', date: '2023-08-19', }, { customer_id: customers[5].id, amount: 8945, status: 'paid', date: '2023-06-03', }, { customer_id: customers[2].id, amount: 1000, status: 'paid', date: '2022-06-05', }, ] const revenue = [ { month: 'Jan', revenue: 2000 }, { month: 'Feb', revenue: 1800 }, { month: 'Mar', revenue: 2200 }, { month: 'Apr', revenue: 2500 }, { month: 'May', revenue: 2300 }, { month: 'Jun', revenue: 3200 }, { month: 'Jul', revenue: 3500 }, { month: 'Aug', revenue: 3700 }, { month: 'Sep', revenue: 2500 }, { month: 'Oct', revenue: 2800 }, { month: 'Nov', revenue: 3000 }, { month: 'Dec', revenue: 4800 }, ] export { users, customers, invoices, revenue }
- db/seed.ts
import '@/db/env-config' import { customers, invoices, revenue, users } from '@/lib/placeholder-data' import db from './drizzle' import * as schema from './schema' import { exit } from 'process' const main = async () => { try { await db.transaction(async (tx) => { await tx.delete(schema.revenue) await tx.delete(schema.invoices) await tx.delete(schema.customers) await tx.delete(schema.users) await tx.insert(schema.users).values(users) await tx.insert(schema.customers).values(customers) await tx.insert(schema.invoices).values(invoices) await tx.insert(schema.revenue).values(revenue) }) console.log('Database seeded successfully') exit(0) } catch (error) { console.error(error) throw new Error('Failed to seed database') } } main()
05. load data from database
- lib/actions/invoice.actions.ts
export async function fetchCardData() { try { const invoiceCountPromise = db.select({ count: count() }).from(invoices) const customerCountPromise = db .select({ count: count() }) .from(customers) const invoiceStatusPromise = db .select({ paid: sql<number>`SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END)`, pending: sql<number>`SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END)`, }) .from(invoices) const data = await Promise.all([ invoiceCountPromise, customerCountPromise, invoiceStatusPromise, ]) const numberOfInvoices = Number(data[0][0].count ?? '0') const numberOfCustomers = Number(data[1][0].count ?? '0') const totalPaidInvoices = formatCurrency(data[2][0].paid ?? '0') const totalPendingInvoices = formatCurrency(data[2][0].pending ?? '0') return { numberOfCustomers, numberOfInvoices, totalPaidInvoices, totalPendingInvoices, } } catch (error) { console.error('Database Error:', error) throw new Error('Failed to fetch card data.') } }
- components/shared/dashboard/stat-cards-wrapper.tsx
const iconMap = { collected: BanknoteIcon, customers: UsersIcon, pending: ClockIcon, invoices: InboxIcon, } export default async function StatCardsWrapper() { const { numberOfInvoices, numberOfCustomers, totalPaidInvoices, totalPendingInvoices, } = await fetchCardData() return ( <> <StatCard title="Collected" value={totalPaidInvoices} type="collected" /> <StatCard title="Pending" value={totalPendingInvoices} type="pending" /> <StatCard title="Total Invoices" value={numberOfInvoices} type="invoices" /> <StatCard title="Total Customers" value={numberOfCustomers} type="customers" /> </> ) } export function StatCard({ title, value, type, }: { title: string value: number | string type: 'invoices' | 'customers' | 'pending' | 'collected' }) { const Icon = iconMap[type] return ( <Card> <CardHeader className="flex flex-row space-y-0 space-x-3 "> {Icon ? <Icon className="h-5 w-5 " /> : null} <h3 className="ml-2 text-sm font-medium">{title}</h3> </CardHeader> <CardContent> <p className={`${lusitana.className} truncate rounded-xl p-4 text-2xl`} > {value} </p> </CardContent> </Card> ) }
- app/dashboard/(overview)/page.tsx
<h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}> Dashboard </h1> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4"> <Suspense fallback={<CardsSkeleton />}> <StatCardsWrapper /> </Suspense> </div>
06. display revenue chart
- pnpm add recharts react-is@rc
- components/shared/dashboard/revenue-chart.tsx
'use client' export default function RevenueChart({ revenue, }: { revenue: { month: string; revenue: number }[] }) { if (!revenue || revenue.length === 0) { return <p className="mt-4 text-gray-400">No data available.</p> } return ( <ResponsiveContainer width="100%" height={450}> <BarChart data={revenue}> <XAxis dataKey="month" fontSize={12} tickLine={false} axisLine={true} /> <YAxis fontSize={12} tickLine={false} axisLine={true} tickFormatter={(value: number) => `$${value}`} /> <Bar dataKey="revenue" fill="currentColor" radius={[4, 4, 0, 0]} className="fill-primary" /> </BarChart> </ResponsiveContainer> ) }
- components/shared/dashboard/revenue-chart-wrapper.tsx
export default async function RevenueChartWrapper() { const revenue = await fetchRevenue() return ( <Card className="w-full md:col-span-4"> <CardHeader> <h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}> Recent Revenue </h2> </CardHeader> <CardContent className="p-0"> <RevenueChart revenue={revenue} /> </CardContent> </Card> ) }
- app/dashboard/(overview)/page.tsx
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8"> <Suspense fallback={<RevenueChartSkeleton />}> <RevenueChartWrapper /> </Suspense> </div>
07. create latest invoices table
- lib/actions/invoice.actions.ts
export async function fetchLatestInvoices() { try { const data = await db .select({ amount: invoices.amount, name: customers.name, image_url: customers.image_url, email: customers.email, id: invoices.id, }) .from(invoices) .innerJoin(customers, eq(invoices.customer_id, customers.id)) .orderBy(desc(invoices.date)) .limit(5) const latestInvoices = data.map((invoice) => ({ ...invoice, amount: formatCurrency(invoice.amount), })) return latestInvoices } catch (error) { console.error('Database Error:', error) throw new Error('Failed to fetch the latest invoices.') } }
- components/shared/dashboard/latest-invoices.tsx
export default async function LatestInvoices() { const latestInvoices = await fetchLatestInvoices() return ( <Card className="flex w-full flex-col md:col-span-4"> <CardHeader> <h2 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}> Latest Invoices </h2> </CardHeader> <CardContent> <div> <div> {latestInvoices.map((invoice, i) => { return ( <div key={invoice.id} className={cn( 'flex flex-row items-center justify-between py-4', { 'border-t': i !== 0, } )} > <div className="flex items-center"> <Image src={invoice.image_url} alt={`${invoice.name}'s profile picture`} className="mr-4 rounded-full" width={32} height={32} /> <div className="min-w-0"> <p className="truncate text-sm font-semibold md:text-base"> {invoice.name} </p> <p className="hidden text-sm text-gray-500 sm:block"> {invoice.email} </p> </div> </div> <p className={`${lusitana.className} truncate text-sm font-medium md:text-base`} > {invoice.amount} </p> </div> ) })} </div> <div className="flex items-center pb-2 pt-6"> <RefreshCcw className="h-5 w-5 text-gray-500" /> <h3 className="ml-2 text-sm text-gray-500 ">Updated just now</h3> </div> </div> </CardContent> </Card> ) }
- app/dashboard/(overview)/page.tsx
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8"> <Suspense fallback={<LatestInvoicesSkeleton />}> <LatestInvoices /> </Suspense> </div>
08. authenticate user from database
- lib/actions/user.actions.ts
export async function getUser(email: string) { const user = await db.query.users.findFirst({ where: eq(users.email, email as string), }) if (!user) throw new Error('User not found') return user }
- auth.ts
export const { auth, signIn, signOut } = NextAuth({ ...authConfig, providers: [ Credentials({ async authorize(credentials) { const parsedCredentials = z .object({ email: z.string().email(), password: z.string().min(6) }) .safeParse(credentials) if (parsedCredentials.success) { const { email, password } = parsedCredentials.data const user = await getUser(email) if (!user) return null const passwordsMatch = await bcryptjs.compare( password, user.password ) if (passwordsMatch) return user } console.log('Invalid credentials') return null }, }), ], })
09. list or delete invoices
- pnpm add use-debounce
- lib/actions/invoice.actions.ts
export async function deleteInvoice(id: string) { try { await db.delete(invoices).where(eq(invoices.id, id)) revalidatePath('/dashboard/invoices') return { message: 'Deleted Invoice' } } catch (error) { return { message: 'Database Error: Failed to Delete Invoice.' } } } export async function fetchFilteredInvoices( query: string, currentPage: number ) { const offset = (currentPage - 1) * ITEMS_PER_PAGE try { const data = await db .select({ id: invoices.id, amount: invoices.amount, name: customers.name, email: customers.email, image_url: customers.image_url, status: invoices.status, date: invoices.date, }) .from(invoices) .innerJoin(customers, eq(invoices.customer_id, customers.id)) .where( or( ilike(customers.name, sql`${`%${query}%`}`), ilike(customers.email, sql`${`%${query}%`}`), ilike(invoices.status, sql`${`%${query}%`}`) ) ) .orderBy(desc(invoices.date)) .limit(ITEMS_PER_PAGE) .offset(offset) return data } catch (error) { console.error('Database Error:', error) throw new Error('Failed to fetch invoices.') } } export async function fetchInvoicesPages(query: string) { try { const data = await db .select({ count: count(), }) .from(invoices) .innerJoin(customers, eq(invoices.customer_id, customers.id)) .where( or( ilike(customers.name, sql`${`%${query}%`}`), ilike(customers.email, sql`${`%${query}%`}`), ilike(invoices.status, sql`${`%${query}%`}`) ) ) const totalPages = Math.ceil(Number(data[0].count) / ITEMS_PER_PAGE) return totalPages } catch (error) { console.error('Database Error:', error) throw new Error('Failed to fetch total number of invoices.') } }
- components/shared/search.tsx
export default function Search({ placeholder }: { placeholder: string }) { const searchParams = useSearchParams() const { replace } = useRouter() const pathname = usePathname() const handleSearch = useDebouncedCallback((term: string) => { console.log(`Searching... ${term}`) const params = new URLSearchParams(searchParams) params.set('page', '1') if (term) { params.set('query', term) } else { params.delete('query') } replace(`${pathname}?${params.toString()}`) }, 300) return ( <div className="relative flex flex-1 flex-shrink-0"> <label htmlFor="search" className="sr-only"> Search </label> <input className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500" placeholder={placeholder} onChange={(e) => { handleSearch(e.target.value) }} defaultValue={searchParams.get('query')?.toString()} /> <SearchIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" /> </div> ) }
- components/shared/invoices/buttons.tsx
export function UpdateInvoice({ id }: { id: string }) { return ( <Button variant="outline" asChild> <Link href={`/dashboard/invoices/${id}/edit`}> <PencilIcon className="w-5" /> </Link> </Button> ) } export function DeleteInvoice({ id }: { id: string }) { const deleteInvoiceWithId = deleteInvoice.bind(null, id) return ( <form action={deleteInvoiceWithId}> <Button variant="outline" type="submit"> <span className="sr-only">Delete</span> <TrashIcon className="w-5" /> </Button> </form> ) }
- components/shared/invoices/status.tsx
import { Badge } from '@/components/ui/badge' import { CheckIcon, ClockIcon } from 'lucide-react' export default function InvoiceStatus({ status }: { status: string }) { return ( <Badge variant={status === 'paid' ? 'secondary' : 'default'}> {status === 'pending' ? ( <> Pending <ClockIcon className="ml-1 w-4" /> </> ) : null} {status === 'paid' ? ( <> Paid <CheckIcon className="ml-1 w-4" /> </> ) : null} </Badge> ) }
- lib/utils.ts
export const formatCurrency = (amount: number) => { return (amount / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD', }) } export const formatDateToLocal = ( dateStr: string, locale: string = 'en-US' ) => { const date = new Date(dateStr) const options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric', } const formatter = new Intl.DateTimeFormat(locale, options) return formatter.format(date) }
- components/shared/invoices/table.tsx
export default async function InvoicesTable({ query, currentPage, }: { query: string currentPage: number }) { const invoices = await fetchFilteredInvoices(query, currentPage) return ( <div className="mt-6 flow-root"> <div className="inline-block min-w-full align-middle"> <div className="rounded-lg p-2 md:pt-0"> <div className="md:hidden"> {invoices?.map((invoice) => ( <div key={invoice.id} className="mb-2 w-full rounded-md p-4"> <div className="flex items-center justify-between border-b pb-4"> <div> <div className="mb-2 flex items-center"> <Image src={invoice.image_url} className="mr-2 rounded-full" width={28} height={28} alt={`${invoice.name}'s profile picture`} /> <p>{invoice.name}</p> </div> <p className="text-sm text-muted">{invoice.email}</p> </div> <InvoiceStatus status={invoice.status} /> </div> <div className="flex w-full items-center justify-between pt-4"> <div> <p className="text-xl font-medium"> {formatCurrency(invoice.amount)} </p> <p>{formatDateToLocal(invoice.date)}</p> </div> <div className="flex justify-end gap-2"> <UpdateInvoice id={invoice.id} /> <DeleteInvoice id={invoice.id} /> </div> </div> </div> ))} </div> <table className="hidden min-w-full md:table"> <thead className="rounded-lg text-left text-sm font-normal"> <tr> <th scope="col" className="px-4 py-5 font-medium sm:pl-6"> Customer </th> <th scope="col" className="px-3 py-5 font-medium"> Email </th> <th scope="col" className="px-3 py-5 font-medium"> Amount </th> <th scope="col" className="px-3 py-5 font-medium"> Date </th> <th scope="col" className="px-3 py-5 font-medium"> Status </th> <th scope="col" className="relative py-3 pl-6 pr-3"> <span className="sr-only">Edit</span> </th> </tr> </thead> <tbody> {invoices?.map((invoice) => ( <tr key={invoice.id} className="w-full border-b py-3 text-sm last-of-type:border-none [&:first-child>td:first-child]:rounded-tl-lg [&:first-child>td:last-child]:rounded-tr-lg [&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg" > <td className="whitespace-nowrap py-3 pl-6 pr-3"> <div className="flex items-center gap-3"> <Image src={invoice.image_url} className="rounded-full" width={28} height={28} alt={`${invoice.name}'s profile picture`} /> <p>{invoice.name}</p> </div> </td> <td className="whitespace-nowrap px-3 py-3"> {invoice.email} </td> <td className="whitespace-nowrap px-3 py-3"> {formatCurrency(invoice.amount)} </td> <td className="whitespace-nowrap px-3 py-3"> {formatDateToLocal(invoice.date)} </td> <td className="whitespace-nowrap px-3 py-3"> <InvoiceStatus status={invoice.status} /> </td> <td className="whitespace-nowrap py-3 pl-6 pr-3"> <div className="flex justify-end gap-3"> <UpdateInvoice id={invoice.id} /> <DeleteInvoice id={invoice.id} /> </div> </td> </tr> ))} </tbody> </table> </div> </div> </div> ) }
- lib/utils.ts
export const generatePagination = ( currentPage: number, totalPages: number ) => { // If the total number of pages is 7 or less, // display all pages without any ellipsis. if (totalPages <= 7) { return Array.from({ length: totalPages }, (_, i) => i + 1) } // If the current page is among the first 3 pages, // show the first 3, an ellipsis, and the last 2 pages. if (currentPage <= 3) { return [1, 2, 3, '...', totalPages - 1, totalPages] } // If the current page is among the last 3 pages, // show the first 2, an ellipsis, and the last 3 pages. if (currentPage >= totalPages - 2) { return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages] } // If the current page is somewhere in the middle, // show the first page, an ellipsis, the current page and its neighbors, // another ellipsis, and the last page. return [ 1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages, ] }
- components/shared/invoices/pagination.tsx
export default function Pagination({ totalPages }: { totalPages: number }) { const pathname = usePathname() const searchParams = useSearchParams() const currentPage = Number(searchParams.get('page')) || 1 const createPageURL = (pageNumber: number | string) => { const params = new URLSearchParams(searchParams) params.set('page', pageNumber.toString()) return `${pathname}?${params.toString()}` } const allPages = generatePagination(currentPage, totalPages) return ( <div className="inline-flex"> <PaginationArrow direction="left" href={createPageURL(currentPage - 1)} isDisabled={currentPage <= 1} /> <div className="flex -space-x-px"> {allPages.map((page, index) => { let position: 'first' | 'last' | 'single' | 'middle' | undefined if (index === 0) position = 'first' if (index === allPages.length - 1) position = 'last' if (allPages.length === 1) position = 'single' if (page === '...') position = 'middle' return ( <PaginationNumber key={`${page}-${index}`} href={createPageURL(page)} page={page} position={position} isActive={currentPage === page} /> ) })} </div> <PaginationArrow direction="right" href={createPageURL(currentPage + 1)} isDisabled={currentPage >= totalPages} /> </div> ) } function PaginationNumber({ page, href, isActive, position, }: { page: number | string href: string position?: 'first' | 'last' | 'middle' | 'single' isActive: boolean }) { const className = cn( 'flex h-10 w-10 items-center justify-center text-sm border', { 'rounded-l-md': position === 'first' || position === 'single', 'rounded-r-md': position === 'last' || position === 'single', 'z-10 bg-primary text-secondary': isActive, 'hover:bg-secondary': !isActive && position !== 'middle', 'text-gray-300': position === 'middle', } ) return isActive || position === 'middle' ? ( <div className={className}>{page}</div> ) : ( <Link href={href} className={className}> {page} </Link> ) } function PaginationArrow({ href, direction, isDisabled, }: { href: string direction: 'left' | 'right' isDisabled?: boolean }) { const className = cn( 'flex h-10 w-10 items-center justify-center rounded-md border', { 'pointer-events-none text-gray-300': isDisabled, 'hover:bg-secondary': !isDisabled, 'mr-2 md:mr-4': direction === 'left', 'ml-2 md:ml-4': direction === 'right', } ) const icon = direction === 'left' ? ( <ArrowLeftIcon className="w-4" /> ) : ( <ArrowRightIcon className="w-4" /> ) return isDisabled ? ( <div className={className}>{icon}</div> ) : ( <Link className={className} href={href}> {icon} </Link> ) }
-
app/dashboard/invoices/page.tsx
export const metadata: Metadata = { title: 'Invoices', } export default async function Page({ searchParams, }: { searchParams?: { query?: string page?: string } }) { const query = searchParams?.query || '' const currentPage = Number(searchParams?.page) || 1 const totalPages = await fetchInvoicesPages(query) return ( <div className="w-full"> <div className="flex w-full items-center justify-between"> <h1 className={`${lusitana.className} text-2xl`}>Invoices</h1> </div> <div className="mt-4 flex items-center justify-between gap-2 md:mt-8"> <Search placeholder="Search invoices..." /> <CreateInvoice /> </div> <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />} > <Table query={query} currentPage={currentPage} /> </Suspense> <div className="mt-5 flex w-full justify-center"> <Pagination totalPages={totalPages} /> </div> </div> ) }
-
app/dashboard/invoices/error.tsx
'use client' import { useEffect } from 'react' export default function Error({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { useEffect(() => { // Optionally log the error to an error reporting service console.error(error) }, [error]) return ( <main className="flex h-full flex-col items-center justify-center"> <h2 className="text-center">Something went wrong!</h2> <button className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400" onClick={ // Attempt to recover by trying to re-render the invoices route () => reset() } > Try again </button> </main> ) }
10. create or update invoices
- types/index.ts
// This file contains type definitions for your data. export type FormattedCustomersTable = { id: string name: string email: string image_url: string total_invoices: number total_pending: string total_paid: string } export type CustomerField = { id: string name: string } export type InvoiceForm = { id: string customer_id: string amount: number status: 'pending' | 'paid' }
- lib/actions/invoice.actions.ts
const FormSchema = z.object({ id: z.string(), customerId: z.string({ invalid_type_error: 'Please select a customer.', }), amount: z.coerce .number() .gt(0, { message: 'Please enter an amount greater than $0.' }), status: z.enum(['pending', 'paid'], { invalid_type_error: 'Please select an invoice status.', }), date: z.string(), }) const CreateInvoice = FormSchema.omit({ id: true, date: true }) const UpdateInvoice = FormSchema.omit({ date: true, id: true }) export type State = { errors?: { customerId?: string[] amount?: string[] status?: string[] } message?: string | null } export async function createInvoice(prevState: State, formData: FormData) { // Validate form fields using Zod const validatedFields = CreateInvoice.safeParse({ customerId: formData.get('customerId'), amount: formData.get('amount'), status: formData.get('status'), }) // If form validation fails, return errors early. Otherwise, continue. if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors, message: 'Missing Fields. Failed to Create Invoice.', } } // Prepare data for insertion into the database const { customerId, amount, status } = validatedFields.data const amountInCents = amount * 100 const date = new Date().toISOString().split('T')[0] // Insert data into the database try { await db.insert(invoices).values({ customer_id: customerId, amount: amountInCents, status, date, }) } catch (error) { // If a database error occurs, return a more specific error. return { message: 'Database Error: Failed to Create Invoice.', } } // Revalidate the cache for the invoices page and redirect the user. revalidatePath('/dashboard/invoices') redirect('/dashboard/invoices') } export async function updateInvoice( id: string, prevState: State, formData: FormData ) { const validatedFields = UpdateInvoice.safeParse({ customerId: formData.get('customerId'), amount: formData.get('amount'), status: formData.get('status'), }) if (!validatedFields.success) { return { errors: validatedFields.error.flatten().fieldErrors, message: 'Missing Fields. Failed to Update Invoice.', } } const { customerId, amount, status } = validatedFields.data const amountInCents = amount * 100 try { await db .update(invoices) .set({ customer_id: customerId, amount: amountInCents, status, }) .where(eq(invoices.id, id)) } catch (error) { return { message: 'Database Error: Failed to Update Invoice.' } } revalidatePath('/dashboard/invoices') redirect('/dashboard/invoices') }
- components/shared/invoices/create-form.tsx
'use client' export default function Form({ customers }: { customers: CustomerField[] }) { const initialState: State = { message: null, errors: {} } const [state, formAction] = useActionState(createInvoice, initialState) return ( <form action={formAction}> <div className="rounded-md p-4 md:p-6"> {/* Customer Name */} <div className="mb-4"> <label htmlFor="customer" className="mb-2 block text-sm font-medium" > Choose customer </label> <div className="relative"> <select id="customer" name="customerId" className="peer block w-full cursor-pointer rounded-md border py-2 pl-10 text-sm outline-2 " defaultValue="" aria-describedby="customer-error" > <option value="" disabled> Select a customer </option> {customers.map((customer) => ( <option key={customer.id} value={customer.id}> {customer.name} </option> ))} </select> <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " /> </div> <div id="customer-error" aria-live="polite" aria-atomic="true"> {state.errors?.customerId && state.errors.customerId.map((error: string) => ( <p className="mt-2 text-sm text-red-500" key={error}> {error} </p> ))} </div> </div> {/* Invoice Amount */} <div className="mb-4"> <label htmlFor="amount" className="mb-2 block text-sm font-medium"> Choose an amount </label> <div className="relative mt-2 rounded-md"> <div className="relative"> <input id="amount" name="amount" type="number" step="0.01" placeholder="Enter USD amount" className="peer block w-full rounded-md border py-2 pl-10 text-sm outline-2 " aria-describedby="amount-error" /> <DollarSign className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " /> </div> </div> <div id="amount-error" aria-live="polite" aria-atomic="true"> {state.errors?.amount && state.errors.amount.map((error: string) => ( <p className="mt-2 text-sm text-red-500" key={error}> {error} </p> ))} </div> </div> {/* Invoice Status */} <fieldset> <legend className="mb-2 block text-sm font-medium"> Set the invoice status </legend> <div className="rounded-md border px-[14px] py-3"> <div className="flex gap-4"> <div className="flex items-center"> <input id="pending" name="status" type="radio" value="pending" className="text-white-600 h-4 w-4 cursor-pointer focus:ring-2" /> <label htmlFor="pending" className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium " > Pending <ClockIcon className="h-4 w-4" /> </label> </div> <div className="flex items-center"> <input id="paid" name="status" type="radio" value="paid" className="h-4 w-4 cursor-pointer focus:ring-2" /> <label htmlFor="paid" className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium " > Paid <CheckIcon className="h-4 w-4" /> </label> </div> </div> </div> <div id="status-error" aria-live="polite" aria-atomic="true"> {state.errors?.status && state.errors.status.map((error: string) => ( <p className="mt-2 text-sm text-red-500" key={error}> {error} </p> ))} </div> </fieldset> <div aria-live="polite" aria-atomic="true"> {state.message ? ( <p className="mt-2 text-sm text-red-500">{state.message}</p> ) : null} </div> </div> <div className="mt-6 flex justify-end gap-4"> <Button variant="outline" asChild> <Link href="/dashboard/invoices">Cancel</Link> </Button> <Button type="submit">Create Invoice</Button> </div> </form> ) }
- components/shared/invoices/breadcrumbs.tsx
import Link from 'next/link' import { lusitana } from '@/components/shared/fonts' import { cn } from '@/lib/utils' interface Breadcrumb { label: string href: string active?: boolean } export default function Breadcrumbs({ breadcrumbs, }: { breadcrumbs: Breadcrumb[] }) { return ( <nav aria-label="Breadcrumb" className="mb-6 block"> <ol className={cn(lusitana.className, 'flex text-xl md:text-2xl')}> {breadcrumbs.map((breadcrumb, index) => ( <li key={breadcrumb.href} aria-current={breadcrumb.active}> <Link href={breadcrumb.href}>{breadcrumb.label}</Link> {index < breadcrumbs.length - 1 ? ( <span className="mx-3 inline-block">/</span> ) : null} </li> ))} </ol> </nav> ) }
- app/dashboard/invoices/create/page.tsx
export const metadata: Metadata = { title: 'Create Invoice', } export default async function Page() { const customers = await fetchCustomers() return ( <main> <Breadcrumbs breadcrumbs={[ { label: 'Invoices', href: '/dashboard/invoices' }, { label: 'Create Invoice', href: '/dashboard/invoices/create', active: true, }, ]} /> <Form customers={customers} /> </main> ) }
- app/dashboard/invoices/[id]/edit/not-found.tsx
import { Frown } from 'lucide-react' import Link from 'next/link' export default function NotFound() { return ( <main className="flex h-full flex-col items-center justify-center gap-2"> <Frown className="w-10 text-gray-400" /> <h2 className="text-xl font-semibold">404 Not Found</h2> <p>Could not find the requested invoice.</p> <Link href="/dashboard/invoices" className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400" > Go Back </Link> </main> ) }
- lib/actions/invoice.actions.ts
export async function fetchInvoiceById(id: string) { try { const data = await db .select({ id: invoices.id, customer_id: invoices.customer_id, amount: invoices.amount, status: invoices.status, date: invoices.date, }) .from(invoices) .where(eq(invoices.id, id)) const invoice = data.map((invoice) => ({ ...invoice, // Convert amount from cents to dollars status: invoice.status === 'paid' ? 'paid' : 'pending', amount: invoice.amount / 100, })) return invoice[0] as InvoiceForm } catch (error) { console.error('Database Error:', error) throw new Error('Failed to fetch invoice.') } }
- components/shared/invoices/edit-form.tsx
export default function EditInvoiceForm({ invoice, customers, }: { invoice: InvoiceForm customers: CustomerField[] }) { const initialState: State = { message: null, errors: {} } const updateInvoiceWithId = updateInvoice.bind(null, invoice.id) const [state, formAction] = useActionState( updateInvoiceWithId, initialState ) return ( <form action={formAction}> <div className="rounded-md p-4 md:p-6"> {/* Customer Name */} <div className="mb-4"> <label htmlFor="customer" className="mb-2 block text-sm font-medium" > Choose customer </label> <div className="relative"> <select id="customer" name="customerId" className="peer block w-full cursor-pointer rounded-md border py-2 pl-10 text-sm outline-2 " defaultValue={invoice.customer_id} aria-describedby="customer-error" > <option value="" disabled> Select a customer </option> {customers.map((customer) => ( <option key={customer.id} value={customer.id}> {customer.name} </option> ))} </select> <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " /> </div> <div id="customer-error" aria-live="polite" aria-atomic="true"> {state.errors?.customerId && state.errors.customerId.map((error: string) => ( <p className="mt-2 text-sm text-red-500" key={error}> {error} </p> ))} </div> </div> {/* Invoice Amount */} <div className="mb-4"> <label htmlFor="amount" className="mb-2 block text-sm font-medium"> Choose an amount </label> <div className="relative mt-2 rounded-md"> <div className="relative"> <input id="amount" name="amount" type="number" defaultValue={invoice.amount} step="0.01" placeholder="Enter USD amount" className="peer block w-full rounded-md border py-2 pl-10 text-sm outline-2 " aria-describedby="amount-error" /> <DollarSignIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 " /> </div> </div> <div id="amount-error" aria-live="polite" aria-atomic="true"> {state.errors?.amount && state.errors.amount.map((error: string) => ( <p className="mt-2 text-sm text-red-500" key={error}> {error} </p> ))} </div> </div> {/* Invoice Status */} <fieldset> <legend className="mb-2 block text-sm font-medium"> Set the invoice status </legend> <div className="rounded-md border px-[14px] py-3"> <div className="flex gap-4"> <div className="flex items-center"> <input id="pending" name="status" type="radio" value="pending" defaultChecked={invoice.status === 'pending'} className="h-4 w-4 focus:ring-2" /> <label htmlFor="pending" className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium " > Pending <ClockIcon className="h-4 w-4" /> </label> </div> <div className="flex items-center"> <input id="paid" name="status" type="radio" value="paid" defaultChecked={invoice.status === 'paid'} className="h-4 w-4 focus:ring-2" /> <label htmlFor="paid" className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium " > Paid <CheckIcon className="h-4 w-4" /> </label> </div> </div> </div> <div id="status-error" aria-live="polite" aria-atomic="true"> {state.errors?.status && state.errors.status.map((error: string) => ( <p className="mt-2 text-sm text-red-500" key={error}> {error} </p> ))} </div> </fieldset> <div aria-live="polite" aria-atomic="true"> {state.message ? ( <p className="my-2 text-sm text-red-500">{state.message}</p> ) : null} </div> </div> <div className="mt-6 flex justify-end gap-4"> <Button variant="ghost"> <Link href="/dashboard/invoices">Cancel</Link> </Button> <Button type="submit">Edit Invoice</Button> </div> </form> ) }
- app/dashboard/invoices/[id]/edit/page.tsx
export const metadata: Metadata = { title: 'Edit Invoice', } export default async function Page({ params }: { params: { id: string } }) { const id = params.id const [invoice, customers] = await Promise.all([ fetchInvoiceById(id), fetchCustomers(), ]) if (!invoice) { notFound() } return ( <main> <Breadcrumbs breadcrumbs={[ { label: 'Invoices', href: '/dashboard/invoices' }, { label: 'Edit Invoice', href: `/dashboard/invoices/${id}/edit`, active: true, }, ]} /> <Form invoice={invoice} customers={customers} /> </main> ) }
11. list customers
- lib/actions/customers.actions.ts
export async function fetchFilteredCustomers(query: string) { const data = await db .select({ id: customers.id, name: customers.name, email: customers.email, image_url: customers.image_url, total_invoices: sql<number>`count(${invoices.id})`, total_pending: sql<number>`SUM(CASE WHEN ${invoices.status} = 'pending' THEN ${invoices.amount} ELSE 0 END)`, total_paid: sql<number>`SUM(CASE WHEN ${invoices.status} = 'paid' THEN ${invoices.amount} ELSE 0 END)`, }) .from(customers) .leftJoin(invoices, eq(customers.id, invoices.customer_id)) .where( or( ilike(customers.name, sql`${`%${query}%`}`), ilike(customers.email, sql`${`%${query}%`}`) ) ) .groupBy( customers.id, customers.name, customers.email, customers.image_url ) .orderBy(asc(customers.id)) return data.map((row) => ({ ...row, total_invoices: row.total_invoices ?? 0, total_pending: formatCurrency(row.total_pending ?? 0), total_paid: formatCurrency(row.total_paid ?? 0), })) }
- components/shared/customers/table.tsx
export default async function CustomersTable({ customers, }: { customers: FormattedCustomersTable[] }) { return ( <div className="w-full"> <h1 className={`${lusitana.className} mb-8 text-xl md:text-2xl`}> Customers </h1> <Search placeholder="Search customers..." /> <div className="mt-6 flow-root"> <div className="overflow-x-auto"> <div className="inline-block min-w-full align-middle"> <div className="overflow-hidden rounded-md p-2 md:pt-0"> <div className="md:hidden"> {customers?.map((customer) => ( <div key={customer.id} className="mb-2 w-full rounded-md p-4" > <div className="flex items-center justify-between border-b pb-4"> <div> <div className="mb-2 flex items-center"> <div className="flex items-center gap-3"> <Image src={customer.image_url} className="rounded-full" alt={`${customer.name}'s profile picture`} width={28} height={28} /> <p>{customer.name}</p> </div> </div> <p className="text-sm text-muted"> {customer.email} </p> </div> </div> <div className="flex w-full items-center justify-between border-b py-5"> <div className="flex w-1/2 flex-col"> <p className="text-xs">Pending</p> <p className="font-medium"> {customer.total_pending} </p> </div> <div className="flex w-1/2 flex-col"> <p className="text-xs">Paid</p> <p className="font-medium">{customer.total_paid}</p> </div> </div> <div className="pt-4 text-sm"> <p>{customer.total_invoices} invoices</p> </div> </div> ))} </div> <table className="hidden min-w-full rounded-md md:table"> <thead className="rounded-md text-left text-sm font-normal"> <tr> <th scope="col" className="px-4 py-5 font-medium sm:pl-6" > Name </th> <th scope="col" className="px-3 py-5 font-medium"> Email </th> <th scope="col" className="px-3 py-5 font-medium"> Total Invoices </th> <th scope="col" className="px-3 py-5 font-medium"> Total Pending </th> <th scope="col" className="px-4 py-5 font-medium"> Total Paid </th> </tr> </thead> <tbody className="divide-y "> {customers.map((customer) => ( <tr key={customer.id} className="group"> <td className="whitespace-nowrap py-5 pl-4 pr-3 text-sm group-first-of-type:rounded-md group-last-of-type:rounded-md sm:pl-6"> <div className="flex items-center gap-3"> <Image src={customer.image_url} className="rounded-full" alt={`${customer.name}'s profile picture`} width={28} height={28} /> <p>{customer.name}</p> </div> </td> <td className="whitespace-nowrap px-4 py-5 text-sm"> {customer.email} </td> <td className="whitespace-nowrap px-4 py-5 text-sm"> {customer.total_invoices} </td> <td className="whitespace-nowrap px-4 py-5 text-sm"> {customer.total_pending} </td> <td className="whitespace-nowrap px-4 py-5 text-sm group-first-of-type:rounded-md group-last-of-type:rounded-md"> {customer.total_paid} </td> </tr> ))} </tbody> </table> </div> </div> </div> </div> </div> ) }
- app/dashboard/customers/page.tsx
export const metadata: Metadata = { title: 'Customers', } export default async function Page({ searchParams, }: { searchParams?: { query?: string page?: string } }) { const query = searchParams?.query || '' const customers = await fetchFilteredCustomers(query) return ( <main> <CustomersTable customers={customers} /> </main> ) }
12. enable partial pre-rendering
- next.config.mjs
/** @type {import('next').NextConfig} */ const nextConfig = { experimental: { ppr: 'incremental', }, } export default nextConfig
- app/layout.tsx
export const experimental_ppr = true
13. deploy-on-vercel
- create vercel account
- connect github to vercel
- create new app
- select github repo
- add env variables
- deploy app
Top comments (1)
Very in-depth and comprehensive guide! Out of curiosity, could you elaborate more on how Nextjs 15's partial pre-rendering feature works with this setup?