Build Nextjs 15 & React 19 Dashboard App Step By Step

basir

Bassir Jafarzadeh (Programming Teacher)

Posted on July 17, 2024

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
💖 💪 🙅 🚩

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related