DEV Community

Cover image for Build Nextjs 15 & React 19 Dashboard App Step By Step

Build Nextjs 15 & React 19 Dashboard App Step By Step

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

  1. npm install -g pnpm
  2. pnpm create next-app@rc
  3. pnpm dev
  4. 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
Enter fullscreen mode Exit fullscreen mode
  1. 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'],
   })
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode

02. create login page

  1. pnpm add next-auth@beta bcryptjs
  2. pnpm add -D @types/bcryptjs
  3. lib/placeholder-data.ts

    const users = [
      {
        id: '410544b2-4001-4271-9855-fec4b6a6442a',
        name: 'User',
        email: 'user@nextmail.com',
        password: hashSync('123456', 10),
      },
    ]
    
    export { users }
    
  4. 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
    
  5. 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
          },
        }),
      ],
    })
    
  6. 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$).*)',
      ],
    }
    
  7. 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
      }
    }
    
  8. install shadcn-ui from https://ui.shadcn.com/docs/installation/next

  9. pnpm dlx shadcn-ui@latest add button card

  10. 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>
      )
    }
    
  11. 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

03. create dashboard page

  1. pnpm dlx shadcn-ui@latest add dropdown-menu
  2. pnpm add next-themes
  3. app/layout.tsx
   <ThemeProvider
     attribute="class"
     defaultTheme="system"
     enableSystem
     disableTransitionOnChange
   >
     {children}
   </ThemeProvider>
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. pnpm dlx shadcn-ui@latest add skeleton
  2. 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>
       </>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
  )
}
Enter fullscreen mode Exit fullscreen mode
  1. dd
import DashboardSkeleton from '@/components/shared/skeletons'

export default function Loading() {
  return <DashboardSkeleton />
}
Enter fullscreen mode Exit fullscreen mode

04. connect to database

  1. create postgres database on https://vercel.com/storage/postgres
  2. pnpm add drizzle-orm @vercel/postgres
  3. pnpm add -D drizzle-kit
  4. db/env-config.ts
import { loadEnvConfig } from '@next/env'

const projectDir = process.cwd()
loadEnvConfig(projectDir)
Enter fullscreen mode Exit fullscreen mode
  1. 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(),
   })
Enter fullscreen mode Exit fullscreen mode
  1. 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
Enter fullscreen mode Exit fullscreen mode
  1. 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!,
     },
   })
Enter fullscreen mode Exit fullscreen mode
  1. 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 }
Enter fullscreen mode Exit fullscreen mode
  1. 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()
Enter fullscreen mode Exit fullscreen mode

05. load data from database

  1. 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.')
     }
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
Enter fullscreen mode Exit fullscreen mode

06. display revenue chart

  1. pnpm add recharts react-is@rc
  2. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
Enter fullscreen mode Exit fullscreen mode

07. create latest invoices table

  1. 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.')
     }
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
Enter fullscreen mode Exit fullscreen mode

08. authenticate user from database

  1. 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
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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
         },
       }),
     ],
   })
Enter fullscreen mode Exit fullscreen mode

09. list or delete invoices

  1. pnpm add use-debounce
  2. 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.')
     }
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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)
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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,
     ]
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
      )
    }
    
  2. 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

  1. 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'
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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')
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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.')
     }
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode

11. list customers

  1. 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),
     }))
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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>
     )
   }
Enter fullscreen mode Exit fullscreen mode

12. enable partial pre-rendering

  1. next.config.mjs
   /** @type {import('next').NextConfig} */

   const nextConfig = {
     experimental: {
       ppr: 'incremental',
     },
   }

   export default nextConfig
Enter fullscreen mode Exit fullscreen mode
  1. app/layout.tsx
   export const experimental_ppr = true
Enter fullscreen mode Exit fullscreen mode

13. deploy-on-vercel

  1. create vercel account
  2. connect github to vercel
  3. create new app
  4. select github repo
  5. add env variables
  6. deploy app

Top comments (1)

Collapse
 
bernert profile image
BernerT

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?