Build a Multi-Page Dashboard

Build a Next.js dashboard with a persistent sidebar and animated UI components.

Introduction

30 Second Summary

Think about the apps you use every day. Slack, Notion, Vercel. They all have one thing in common: a sidebar that stays put while you hop between pages.

In this project, you will build a multi-page AI platform dashboard with a collapsible sidebar that persists across page navigations. You will wire up four distinct pages, each showcasing animated UI components, and create a login flow as the entry point.

What You'll Build

A polished dashboard application using Next.js nested layouts, shadcn/ui sidebar components, and Magic UI animated elements.

By the end of this project, you'll have:

  • A persistent sidebar layout powered by Next.js App Router nested layouts that never re-renders when you switch pages.
  • Four dashboard pages (Dashboard, Models, Usage, API Keys) each featuring different animated Magic UI components like BentoGrid, NumberTicker, and MagicCard.
  • A standalone login page with root redirect and active link highlighting in the sidebar.
  • Secret Mission: Add a dark mode toggle and a middleware-based auth guard that redirects unauthenticated users away from dashboard routes.

Are there any prerequisites?

This project assumes basic familiarity with React components and JSX. Some experience with the Next.js file structure is also helpful.

Before We Start

Before We Start...

Scaffold the Project and Install Components

Every modern dashboard you use shares the same foundation: a component library, a routing framework, and pre-built UI blocks that you customize.

In this step, you will scaffold a Next.js project using a shadcn/ui preset, add a pre-built sidebar block, and install animated Magic UI components. This gives you everything you need before writing custom code.

In this step, get ready to:
  • Scaffold a Next.js project with your shadcn preset.
  • Add the sidebar-07 block for a collapsible sidebar template.
  • Install 5 Magic UI animated components.
Scaffold your Next.js project with shadcn

The shadcn CLI can scaffold an entire Next.js project from a preset. A preset bundles your design tokens, colors, and component configuration into a single shareable code.

  • Open your terminal.

Opening your terminal in WSL2

Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar. Type Terminal and press Enter. If you are on Windows, make sure you are in your WSL2 Ubuntu session.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Next.js project by running this command:
pnpm dlx shadcn@latest init --preset b3kcos8Tg --template next --pointer

What does this command do?

  • pnpm dlx shadcn@latest init downloads and runs the shadcn CLI to initialize a new project.
  • --preset b3kcos8Tg loads a custom design system preset with pre-configured colors, fonts, and styles.
  • --template next scaffolds a full Next.js App Router project.
  • --pointer adds cursor:pointer CSS to buttons for better click affordance.

What will the CLI ask me?

The CLI will prompt you for a project name. Enter ai-dashboard when asked. It will then install all dependencies automatically. This takes about a minute.

  • Once the command finishes, move into your new project folder by running:
cd ai-dashboard
  • Confirm the project was created by running:
ls

You should see folders like app, components, node_modules, and files like package.json and next.config.ts.

Command not found or install failed?

  • Make sure you have pnpm installed globally. Run pnpm --version to check.
  • If you see a network error, check your internet connection and try the command again.
  • help me debug my shadcn init error
  • Open the project in VS Codium by running:
codium .

VS Codium opens with your project structure visible in the Explorer sidebar on the left.

Add the sidebar-07 block

shadcn/ui provides pre-built "blocks" that combine multiple components into ready-made UI patterns. The sidebar-07 block gives you a collapsible sidebar that shrinks to icons.

  • In your terminal (inside the ai-dashboard folder), add the sidebar-07 block by running:
pnpm dlx shadcn@latest add sidebar-07

What does this install?

This command installs the full sidebar-07 block. It creates components/app-sidebar.tsx (the main sidebar component) along with all the sidebar primitive components inside components/ui/sidebar.tsx.

It also includes sub-components like a team switcher, navigation groups, and a user dropdown. You will customize all of these in a later step.

  • Confirm the sidebar components were installed by running:
ls components/

You should see app-sidebar.tsx and a ui folder in the output.

Don't see app-sidebar.tsx?

  • Make sure you are running the command from inside the ai-dashboard folder, not one level above it.
  • If the command asked you to overwrite existing files, enter y to accept.
  • help me troubleshoot my sidebar-07 installation
Install the Magic UI animated components

Magic UI provides animated components that follow the same install pattern as shadcn/ui. Each component gets copied directly into your project so you own the source code.

  • Install all 5 Magic UI components in a single command by running:
pnpm dlx shadcn@latest add @magicui/bento-grid @magicui/number-ticker @magicui/magic-card @magicui/border-beam @magicui/flickering-grid

What are these components?

  • bento-grid creates responsive card grid layouts (like Apple's product pages).
  • number-ticker animates numbers counting up when they scroll into view.
  • magic-card adds a gradient spotlight effect that follows your cursor on hover.
  • border-beam animates a glowing beam traveling around a card's border.
  • flickering-grid renders an animated canvas background with randomly flickering squares.
  • Verify the Magic UI components were installed by running:
ls components/ui/ | grep -E 'bento|number|magic|border|flickering'

You should see bento-grid.tsx, number-ticker.tsx, magic-card.tsx, border-beam.tsx, and flickering-grid.tsx listed in the output.

  • Start the development server to confirm everything compiles by running:
pnpm dev

You should see output indicating the server is running at http://localhost:3000. Open that URL in your browser to see the default sidebar-07 page.

Seeing a compile error?

  • If you see module-not-found errors mentioning motion/react, the Magic UI components need the motion library. Run pnpm add motion and restart the server.
  • If the browser shows a blank page, check your terminal for red error messages. The first error in the stack trace usually tells you what is missing.
  • help me fix my dev server error

Your project is scaffolded with all the components you need. Next up, you will restructure the file layout to support multiple pages with a persistent sidebar.

Restructure for Multi-Page Routing

Right now, the sidebar-07 block lives as a single page at app/dashboard/page.tsx. Everything is bundled together. To build a multi-page dashboard where the sidebar stays in place as you navigate between pages, you need to separate the layout from the page content.

Next.js App Router solves this with nested layouts. A layout.tsx file wraps all child pages in its folder. It never re-renders when users navigate between those pages. This is the exact pattern behind dashboards at Vercel, Linear, and Stripe.

In this step, get ready to:
  • Create a nested layout that wraps all dashboard pages with the sidebar.
  • Add placeholder pages for each dashboard section.
  • Verify that navigation keeps the sidebar persistent.
Create the dashboard layout

A layout.tsx file in the app/dashboard/ folder automatically wraps every page inside that folder. It receives a children prop that represents the current page content.

  • In the VS Codium Explorer sidebar, right-click the app/dashboard folder.
  • Select New File and name it layout.tsx.
  • Paste the following code into app/dashboard/layout.tsx:
import { AppSidebar } from "@/components/app-sidebar"
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbList,
  BreadcrumbPage,
} from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"
import {
  SidebarInset,
  SidebarProvider,
  SidebarTrigger,
} from "@/components/ui/sidebar"

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <SidebarProvider>
      {/* The sidebar renders once and stays persistent */}
      <AppSidebar />
      <SidebarInset>
        <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
          <div className="flex items-center gap-2 px-4">
            {/* Toggle button to collapse/expand the sidebar */}
            <SidebarTrigger className="-ml-1" />
            <Separator
              orientation="vertical"
              className="mr-2 data-[orientation=vertical]:h-4"
            />
            <Breadcrumb>
              <BreadcrumbList>
                <BreadcrumbItem>
                  <BreadcrumbPage>Platform</BreadcrumbPage>
                </BreadcrumbItem>
              </BreadcrumbList>
            </Breadcrumb>
          </div>
        </header>
        {/* Page content swaps here without re-rendering the sidebar */}
        <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
          {children}
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}

What does this code do?

  • SidebarProvider wraps the entire layout and manages the sidebar's open/collapsed state.
  • AppSidebar renders the collapsible sidebar. Because it lives in the layout (not a page), it never re-renders during navigation.
  • SidebarInset is the main content area that sits beside the sidebar.
  • {children} is where Next.js injects the active page component. When you navigate to /dashboard/models, only this part swaps.

Why nested layouts instead of lifting state?

You could technically keep the sidebar persistent by lifting it into a React context or a client-side wrapper. But Next.js nested layouts handle this at the framework level with zero extra code.

The layout component stays mounted across navigations automatically. No state management, no context providers, no re-renders. The router knows to only swap the children slot.

  • Save app/dashboard/layout.tsx.

Seeing a duplicate sidebar?

If the existing app/dashboard/page.tsx still contains the full sidebar-07 template code, you will see two sidebars stacked. This is expected right now. You will replace that page content in the next substep.

Need help? Why am I seeing a duplicate sidebar after creating layout.tsx?

Create placeholder pages for each route

In the App Router, each folder under app/dashboard/ becomes a URL segment. A page.tsx file inside that folder makes the route publicly accessible.

  • Open your terminal in VS Codium by pressing Ctrl+`.
  • Create the sub-route directories by running this command:
mkdir -p app/dashboard/models app/dashboard/usage app/dashboard/api-keys

Now you need four page files. Each one will start as a simple placeholder. You will add the real content in later steps.

  • Replace the entire contents of app/dashboard/page.tsx with this placeholder:
// Main dashboard landing page
export default function DashboardPage() {
  return <div>Dashboard</div>
}
  • Create app/dashboard/models/page.tsx with this placeholder:
// Models management page
export default function ModelsPage() {
  return <div>Models</div>
}
  • Create app/dashboard/usage/page.tsx with this placeholder:
// Usage metrics page
export default function UsagePage() {
  return <div>Usage</div>
}
  • Create app/dashboard/api-keys/page.tsx with this placeholder:
// API key management page
export default function ApiKeysPage() {
  return <div>API Keys</div>
}

How does file-system routing work here?

Each folder name becomes a URL path segment. The models/ folder maps to /dashboard/models. The api-keys/ folder maps to /dashboard/api-keys.

All four pages are children of the app/dashboard/ folder, so they all get wrapped by layout.tsx automatically. No import statements or route configuration needed.

  • Save all four page files.
  • Confirm your folder structure by running:
ls app/dashboard/

You should see layout.tsx, page.tsx, models/, usage/, and api-keys/ listed in the output.

Missing a folder?

Double-check that the mkdir -p command ran from the project root (the folder containing app/). If you ran it from inside app/dashboard/, the folders ended up nested too deep.

Still stuck? Help me fix my dashboard folder structure.

Verify persistent sidebar navigation
  • Start the dev server by running:
pnpm dev
  • Open your browser and navigate to http://localhost:3000/dashboard.
  • You should see the collapsible sidebar on the left with the header breadcrumb at the top, and the word "Dashboard" in the content area.
  • In the browser address bar, change the URL to http://localhost:3000/dashboard/models and press Enter.
  • The content area should now show "Models" while the sidebar remains exactly where it was.
  • Try http://localhost:3000/dashboard/usage and http://localhost:3000/dashboard/api-keys to confirm all routes load.

What should I see?

Each URL should display its placeholder text ("Dashboard", "Models", "Usage", or "API Keys") in the content area. The sidebar with its collapse toggle and breadcrumb header stays fixed. Only the content slot changes.

Seeing a 404 page?

Make sure each route folder has a file named exactly page.tsx (not Page.tsx or index.tsx). Next.js only recognizes page.tsx as a route entry point.

Also check that each file exports a default function. A named export without export default will not render.

Need more help? Why is my dashboard route returning a 404?

✔️ Awesome, I've got everything!

Great. Double check that you have saved all five files before moving on.

ⓧ I'd like to double check the full code

Here is the complete layout file. The placeholder pages are single-line components so compare them against what you have.

import { AppSidebar } from "@/components/app-sidebar"
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbList,
  BreadcrumbPage,
} from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"
import {
  SidebarInset,
  SidebarProvider,
  SidebarTrigger,
} from "@/components/ui/sidebar"

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <SidebarProvider>
      <AppSidebar />
      <SidebarInset>
        <header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
          <div className="flex items-center gap-2 px-4">
            <SidebarTrigger className="-ml-1" />
            <Separator
              orientation="vertical"
              className="mr-2 data-[orientation=vertical]:h-4"
            />
            <Breadcrumb>
              <BreadcrumbList>
                <BreadcrumbItem>
                  <BreadcrumbPage>Platform</BreadcrumbPage>
                </BreadcrumbItem>
              </BreadcrumbList>
            </Breadcrumb>
          </div>
        </header>
        <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
          {children}
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
export default function DashboardPage() {
  return <div>Dashboard</div>
}
export default function ModelsPage() {
  return <div>Models</div>
}
export default function UsagePage() {
  return <div>Usage</div>
}
export default function ApiKeysPage() {
  return <div>API Keys</div>
}

Your multi-page routing structure is in place. The sidebar persists across every dashboard page without a single line of state management. Next up, you will customize that sidebar with your own navigation items and active link highlighting.

Customize the Sidebar

In the previous step, you restructured your project so that every dashboard route shares a persistent sidebar layout. The sidebar itself still shows the default placeholder content from the sidebar-07 block.

Now you will replace that default content with your own navigation items, a streamlined user dropdown, and active link highlighting that shows which page the user is currently on.

In this step, get ready to:
  • Replace the default navigation with 4 custom routes using Lucide icons and Link-based navigation.
  • Simplify the user dropdown to only Billing and Logout.
  • Add active link highlighting using the usePathname() hook.
Replace the default sidebar component

The default sidebar-07 block includes several sections you do not need: a team switcher, nested sub-menus, and a projects list. You will replace the entire file with a focused component that has exactly the 4 routes your dashboard needs.

  • Open components/app-sidebar.tsx in your editor.
  • Select all the existing content and delete it.
  • Paste the following replacement code:
"use client"

import * as React from "react"
import {
  LayoutDashboard,
  Bot,
  BarChart3,
  Key,
  CreditCard,
  LogOut,
} from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"

import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarRail,
} from "@/components/ui/sidebar"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"

// Define the 4 navigation items for the sidebar
const navItems = [
  { title: "Dashboard", url: "/dashboard", icon: LayoutDashboard },
  { title: "Models", url: "/dashboard/models", icon: Bot },
  { title: "Usage", url: "/dashboard/usage", icon: BarChart3 },
  { title: "API Keys", url: "/dashboard/api-keys", icon: Key },
]

export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
  // Get the current URL path for active link highlighting
  const pathname = usePathname()

  return (
    <Sidebar collapsible="icon" {...props}>
      <SidebarHeader>
        <SidebarMenu>
          <SidebarMenuItem>
            <SidebarMenuButton size="lg" asChild>
              <Link href="/dashboard">
                <div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
                  <Bot className="size-4" />
                </div>
                <div className="grid flex-1 text-left text-sm leading-tight">
                  <span className="truncate font-semibold">AI Platform</span>
                  <span className="truncate text-xs">Enterprise</span>
                </div>
              </Link>
            </SidebarMenuButton>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarHeader>
      <SidebarContent>
        <SidebarGroup>
          <SidebarGroupLabel>Platform</SidebarGroupLabel>
          <SidebarMenu>
            {navItems.map((item) => (
              <SidebarMenuItem key={item.title}>
                <SidebarMenuButton
                  asChild
                  isActive={pathname === item.url}
                  tooltip={item.title}
                >
                  <Link href={item.url}>
                    <item.icon />
                    <span>{item.title}</span>
                  </Link>
                </SidebarMenuButton>
              </SidebarMenuItem>
            ))}
          </SidebarMenu>
        </SidebarGroup>
      </SidebarContent>
      <SidebarFooter>
        <SidebarMenu>
          <SidebarMenuItem>
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <SidebarMenuButton
                  size="lg"
                  className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
                >
                  <Avatar className="h-8 w-8 rounded-lg">
                    <AvatarFallback className="rounded-lg">U</AvatarFallback>
                  </Avatar>
                  <div className="grid flex-1 text-left text-sm leading-tight">
                    <span className="truncate font-semibold">User</span>
                    <span className="truncate text-xs">user@example.com</span>
                  </div>
                </SidebarMenuButton>
              </DropdownMenuTrigger>
              <DropdownMenuContent
                className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
                side="bottom"
                align="end"
                sideOffset={4}
              >
                <DropdownMenuItem>
                  <CreditCard className="mr-2 h-4 w-4" />
                  Billing
                </DropdownMenuItem>
                <DropdownMenuItem asChild>
                  <Link href="/login">
                    <LogOut className="mr-2 h-4 w-4" />
                    Logout
                  </Link>
                </DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarFooter>
      <SidebarRail />
    </Sidebar>
  )
}

What does this code do?

This component replaces the entire default sidebar with a streamlined version tailored to your AI platform dashboard.

  • The navItems array defines 4 routes, each with a title, URL path, and Lucide icon.
  • Each navigation item renders as a Link component from Next.js, enabling client-side navigation without full page reloads.
  • The SidebarHeader displays your brand name (AI Platform) with a Bot icon.
  • The usePathname() hook reads the current URL and feeds it to isActive so the current page gets highlighted in the sidebar.
  • Save components/app-sidebar.tsx.
  • Refresh your browser at http://localhost:3000/dashboard. You should see the sidebar with an "AI Platform" header, four navigation items (Dashboard, Models, Usage, API Keys), and a user avatar at the bottom.

Sidebar not rendering?

  • Check that you have the "use client" directive at the very top of the file. The usePathname() hook requires a Client Component.
  • Make sure the file is saved at components/app-sidebar.tsx (not inside a subfolder). The import in your layout references @/components/app-sidebar.
  • If you see import errors in the terminal, confirm that lucide-react is installed. It should already be present from the shadcn preset.

Help me debug my sidebar component

Understand the custom user dropdown

The default sidebar-07 user dropdown includes items like Account, Notifications, and several others. Your version keeps only what matters for this dashboard: Billing and Logout.

How does the user dropdown work?

The SidebarFooter section at the bottom of the sidebar renders an avatar with a DropdownMenu. Clicking the avatar opens a menu with two options.

  • Billing uses the CreditCard icon. It is a plain menu item for now.
  • Logout uses the LogOut icon and wraps a Link to /login. The asChild prop lets the DropdownMenuItem delegate rendering to the Link component.
  • Click the user avatar at the bottom of the sidebar.
  • Confirm that a dropdown appears with exactly two items: Billing and Logout.
Verify active link highlighting

Every production dashboard shows users which page they are on. Your sidebar uses the usePathname() hook from Next.js to compare the current URL against each nav item's URL.

How does active link highlighting work?

The usePathname() hook returns the current path (e.g. /dashboard/models). Each SidebarMenuButton receives isActive={pathname === item.url}, which applies active styling when the condition is true.

Because the sidebar lives in the dashboard layout and does not re-render on navigation, the hook re-evaluates on each route change while the rest of the sidebar stays stable.

  • Click Models in the sidebar.
  • Confirm that the Models item appears highlighted while the Dashboard item is no longer highlighted.
  • Click Usage and then API Keys to confirm each one highlights when active.

Active highlight not changing?

  • Make sure you are clicking the sidebar links, not typing URLs in the address bar. Both should work, but clicking confirms the Link component navigation is wired correctly.
  • Double check that each nav item's url value matches the route folder exactly. For example, /dashboard/api-keys must match the folder name app/dashboard/api-keys/.
  • If no items highlight at all, confirm that the "use client" directive is present. Server Components cannot use hooks.

Why is my sidebar active highlight not working?

✔️ Awesome, I've got everything!

Make sure you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete components/app-sidebar.tsx file for comparison:

"use client"

import * as React from "react"
import {
  LayoutDashboard,
  Bot,
  BarChart3,
  Key,
  CreditCard,
  LogOut,
} from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"

import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarRail,
} from "@/components/ui/sidebar"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"

const navItems = [
  { title: "Dashboard", url: "/dashboard", icon: LayoutDashboard },
  { title: "Models", url: "/dashboard/models", icon: Bot },
  { title: "Usage", url: "/dashboard/usage", icon: BarChart3 },
  { title: "API Keys", url: "/dashboard/api-keys", icon: Key },
]

export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
  const pathname = usePathname()

  return (
    <Sidebar collapsible="icon" {...props}>
      <SidebarHeader>
        <SidebarMenu>
          <SidebarMenuItem>
            <SidebarMenuButton size="lg" asChild>
              <Link href="/dashboard">
                <div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
                  <Bot className="size-4" />
                </div>
                <div className="grid flex-1 text-left text-sm leading-tight">
                  <span className="truncate font-semibold">AI Platform</span>
                  <span className="truncate text-xs">Enterprise</span>
                </div>
              </Link>
            </SidebarMenuButton>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarHeader>
      <SidebarContent>
        <SidebarGroup>
          <SidebarGroupLabel>Platform</SidebarGroupLabel>
          <SidebarMenu>
            {navItems.map((item) => (
              <SidebarMenuItem key={item.title}>
                <SidebarMenuButton
                  asChild
                  isActive={pathname === item.url}
                  tooltip={item.title}
                >
                  <Link href={item.url}>
                    <item.icon />
                    <span>{item.title}</span>
                  </Link>
                </SidebarMenuButton>
              </SidebarMenuItem>
            ))}
          </SidebarMenu>
        </SidebarGroup>
      </SidebarContent>
      <SidebarFooter>
        <SidebarMenu>
          <SidebarMenuItem>
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <SidebarMenuButton
                  size="lg"
                  className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
                >
                  <Avatar className="h-8 w-8 rounded-lg">
                    <AvatarFallback className="rounded-lg">U</AvatarFallback>
                  </Avatar>
                  <div className="grid flex-1 text-left text-sm leading-tight">
                    <span className="truncate font-semibold">User</span>
                    <span className="truncate text-xs">user@example.com</span>
                  </div>
                </SidebarMenuButton>
              </DropdownMenuTrigger>
              <DropdownMenuContent
                className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
                side="bottom"
                align="end"
                sideOffset={4}
              >
                <DropdownMenuItem>
                  <CreditCard className="mr-2 h-4 w-4" />
                  Billing
                </DropdownMenuItem>
                <DropdownMenuItem asChild>
                  <Link href="/login">
                    <LogOut className="mr-2 h-4 w-4" />
                    Logout
                  </Link>
                </DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarFooter>
      <SidebarRail />
    </Sidebar>
  )
}

Your sidebar is fully customized with clean navigation, a minimal user dropdown, and active link feedback. Next up, you will build the actual page content for each route using animated Magic UI components.

Build the Dashboard and Models Pages

Your sidebar is wired up, your layout is persisting across routes, and your placeholder pages are ready to be filled with real content. Now it is time to bring those pages to life with animated data visualizations.

In this step, you will build the Dashboard page using BentoGrid and NumberTicker to display animated platform metrics. You will also build the Models page using MagicCard components with gradient hover effects.

In this step, get ready to:
  • Build the Dashboard page with a BentoGrid layout and animated stat counters.
  • Build the Models page with interactive MagicCard components for each AI model.
  • Confirm both pages render their animated components in the browser.
Build the Dashboard page with BentoGrid and NumberTicker

The Dashboard page is the first thing users see after logging in. It needs to display key platform metrics in an engaging layout that draws the eye.

BentoGrid arranges cards in a responsive grid (similar to a masonry layout). NumberTicker animates numbers counting up from zero to their final value when they scroll into view.

  • Open app/dashboard/page.tsx in your editor.
  • Select all the placeholder content and replace it with the following imports and stats data:
"use client"

import { BentoCard, BentoGrid } from "@/components/ui/bento-grid"
import { NumberTicker } from "@/components/ui/number-ticker"
import { Activity, Bot, Coins, Clock } from "lucide-react"

// Platform metrics data for the dashboard cards
const stats = [
  {
    name: "Total Requests",
    value: 128450,
    Icon: Activity,
    description: "API calls this month",
    className: "col-span-3 lg:col-span-1",
  },
  {
    name: "Active Models",
    value: 12,
    Icon: Bot,
    description: "Models currently deployed",
    className: "col-span-3 lg:col-span-1",
  },
  {
    name: "Tokens Used",
    value: 2450000,
    Icon: Coins,
    description: "Total tokens consumed",
    className: "col-span-3 lg:col-span-1",
  },
]

What does this code do?

The "use client" directive marks this as a Client Component. NumberTicker uses browser animations, so it needs to run on the client.

The stats array defines three metric cards. Each entry has a numeric value that NumberTicker will animate, an Icon component from Lucide, and a className controlling its grid column span.

  • Add the DashboardPage component below the stats array in the same file:
export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold tracking-tight">Dashboard</h2>
        <p className="text-muted-foreground">
          Overview of your platform usage and metrics.
        </p>
      </div>
      {/* BentoGrid displays stat cards in a responsive 3-column layout */}
      <BentoGrid className="auto-rows-[12rem] lg:grid-cols-3">
        {stats.map((stat) => (
          <BentoCard
            key={stat.name}
            name={stat.name}
            className={stat.className}
            background={<div className="absolute inset-0 bg-gradient-to-br from-muted/50 to-muted/20" />}
            Icon={stat.Icon}
            description={stat.description}
            href="#"
            cta={
              <span className="text-3xl font-bold">
                <NumberTicker value={stat.value} />
              </span>
            }
          />
        ))}
      </BentoGrid>
      {/* Additional stat cards for Uptime and Response Time */}
      <div className="grid gap-4 md:grid-cols-2">
        <div className="rounded-xl border bg-card p-6">
          <h3 className="font-semibold">Uptime</h3>
          <p className="text-4xl font-bold mt-2">
            <NumberTicker value={99.9} decimalPlaces={1} />%
          </p>
          <p className="text-muted-foreground text-sm mt-1">Last 30 days</p>
        </div>
        <div className="rounded-xl border bg-card p-6">
          <h3 className="font-semibold">Avg Response Time</h3>
          <p className="text-4xl font-bold mt-2">
            <NumberTicker value={145} />ms
          </p>
          <p className="text-muted-foreground text-sm mt-1">P95 latency</p>
        </div>
      </div>
    </div>
  )
}

What does this component do?

The BentoGrid maps over the stats array and renders a BentoCard for each metric. The cta prop is repurposed here to display the animated NumberTicker value instead of a button label.

Below the BentoGrid, two standalone cards show Uptime (with one decimal place) and Average Response Time. The decimalPlaces prop tells NumberTicker to animate to 99.9 instead of rounding.

  • Save app/dashboard/page.tsx.
  • Switch to your browser and navigate to /dashboard. You should see three BentoGrid cards with numbers animating up from zero, plus two additional cards showing Uptime and Response Time.

Numbers not animating?

Make sure "use client" is the very first line of the file. Without it, the component runs as a Server Component and browser-based animations will not work.

If you see an import error, confirm that the Magic UI components were installed in Step 1. You can re-run the install with pnpm dlx shadcn@latest add @magicui/bento-grid @magicui/number-ticker.

Help me debug my Dashboard page

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

"use client"

import { BentoCard, BentoGrid } from "@/components/ui/bento-grid"
import { NumberTicker } from "@/components/ui/number-ticker"
import { Activity, Bot, Coins, Clock } from "lucide-react"

const stats = [
  {
    name: "Total Requests",
    value: 128450,
    Icon: Activity,
    description: "API calls this month",
    className: "col-span-3 lg:col-span-1",
  },
  {
    name: "Active Models",
    value: 12,
    Icon: Bot,
    description: "Models currently deployed",
    className: "col-span-3 lg:col-span-1",
  },
  {
    name: "Tokens Used",
    value: 2450000,
    Icon: Coins,
    description: "Total tokens consumed",
    className: "col-span-3 lg:col-span-1",
  },
]

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold tracking-tight">Dashboard</h2>
        <p className="text-muted-foreground">
          Overview of your platform usage and metrics.
        </p>
      </div>
      <BentoGrid className="auto-rows-[12rem] lg:grid-cols-3">
        {stats.map((stat) => (
          <BentoCard
            key={stat.name}
            name={stat.name}
            className={stat.className}
            background={<div className="absolute inset-0 bg-gradient-to-br from-muted/50 to-muted/20" />}
            Icon={stat.Icon}
            description={stat.description}
            href="#"
            cta={
              <span className="text-3xl font-bold">
                <NumberTicker value={stat.value} />
              </span>
            }
          />
        ))}
      </BentoGrid>
      <div className="grid gap-4 md:grid-cols-2">
        <div className="rounded-xl border bg-card p-6">
          <h3 className="font-semibold">Uptime</h3>
          <p className="text-4xl font-bold mt-2">
            <NumberTicker value={99.9} decimalPlaces={1} />%
          </p>
          <p className="text-muted-foreground text-sm mt-1">Last 30 days</p>
        </div>
        <div className="rounded-xl border bg-card p-6">
          <h3 className="font-semibold">Avg Response Time</h3>
          <p className="text-4xl font-bold mt-2">
            <NumberTicker value={145} />ms
          </p>
          <p className="text-muted-foreground text-sm mt-1">P95 latency</p>
        </div>
      </div>
    </div>
  )
}
Build the Models page with MagicCard

The Models page displays each deployed AI model as an interactive card. MagicCard adds a spotlight gradient effect that follows the cursor on hover, giving each card a premium feel.

  • Open app/dashboard/models/page.tsx in your editor.
  • Select all the placeholder content and replace it with the following imports and models data:
"use client"

import { MagicCard } from "@/components/ui/magic-card"
import { Bot } from "lucide-react"

// Array of AI models with their status and daily request counts
const models = [
  { name: "GPT-4o", status: "Active", requests: "45.2K/day" },
  { name: "Claude 3.5", status: "Active", requests: "32.1K/day" },
  { name: "Gemini Pro", status: "Inactive", requests: "0/day" },
  { name: "Llama 3", status: "Active", requests: "18.7K/day" },
  { name: "Mistral Large", status: "Active", requests: "9.3K/day" },
  { name: "Command R+", status: "Inactive", requests: "0/day" },
]

What does this data represent?

Each model entry has a name, a status (Active or Inactive), and a requests string showing daily traffic. The component will render these as interactive cards with colour-coded status badges.

  • Add the ModelsPage component below the models array in the same file:
export default function ModelsPage() {
  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold tracking-tight">Models</h2>
        <p className="text-muted-foreground">
          Manage your deployed AI models.
        </p>
      </div>
      {/* Responsive grid of MagicCard components */}
      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
        {models.map((model) => (
          <MagicCard
            key={model.name}
            className="cursor-pointer p-6"
            gradientColor="#262626"
          >
            <div className="flex items-start gap-3">
              <Bot className="h-8 w-8 text-muted-foreground" />
              <div>
                <h3 className="font-semibold">{model.name}</h3>
                <p className="text-sm text-muted-foreground mt-1">
                  {model.requests}
                </p>
                {/* Colour-coded status badge */}
                <span
                  className={`mt-2 inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
                    model.status === "Active"
                      ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
                      : "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
                  }`}
                >
                  {model.status}
                </span>
              </div>
            </div>
          </MagicCard>
        ))}
      </div>
    </div>
  )
}

What does this component do?

Each model renders inside a MagicCard with gradientColor="#262626". When you hover over a card, a dark spotlight gradient follows your cursor.

The status badge uses a conditional class to render green for Active models and gray for Inactive ones. The dark: prefix variants ensure the badges look correct in dark mode.

  • Save app/dashboard/models/page.tsx.
  • In your browser, click Models in the sidebar. You should see six cards in a 3-column grid, each with a bot icon, model name, request count, and a green or gray status badge.

Cards not showing the gradient hover effect?

MagicCard relies on the motion package (installed automatically as a dependency of Magic UI). If you see a module-not-found error for motion/react, stop the dev server and re-run pnpm install to ensure all dependencies are resolved.

Also confirm that "use client" is the first line of the file. MagicCard tracks mouse position, which requires a client-side environment.

Help me debug my Models page

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

"use client"

import { MagicCard } from "@/components/ui/magic-card"
import { Bot } from "lucide-react"

const models = [
  { name: "GPT-4o", status: "Active", requests: "45.2K/day" },
  { name: "Claude 3.5", status: "Active", requests: "32.1K/day" },
  { name: "Gemini Pro", status: "Inactive", requests: "0/day" },
  { name: "Llama 3", status: "Active", requests: "18.7K/day" },
  { name: "Mistral Large", status: "Active", requests: "9.3K/day" },
  { name: "Command R+", status: "Inactive", requests: "0/day" },
]

export default function ModelsPage() {
  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold tracking-tight">Models</h2>
        <p className="text-muted-foreground">
          Manage your deployed AI models.
        </p>
      </div>
      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
        {models.map((model) => (
          <MagicCard
            key={model.name}
            className="cursor-pointer p-6"
            gradientColor="#262626"
          >
            <div className="flex items-start gap-3">
              <Bot className="h-8 w-8 text-muted-foreground" />
              <div>
                <h3 className="font-semibold">{model.name}</h3>
                <p className="text-sm text-muted-foreground mt-1">
                  {model.requests}
                </p>
                <span
                  className={`mt-2 inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
                    model.status === "Active"
                      ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
                      : "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
                  }`}
                >
                  {model.status}
                </span>
              </div>
            </div>
          </MagicCard>
        ))}
      </div>
    </div>
  )
}
Confirm both pages render correctly

Both pages are now built. Time to do a quick navigation test to make sure everything works together with your persistent sidebar.

  • In your browser, click Dashboard in the sidebar. Confirm you see the BentoGrid cards with animated numbers counting up.
  • Click Models in the sidebar. Confirm you see six model cards and that hovering over them reveals the gradient spotlight effect.
  • Navigate back and forth between the two pages. The sidebar should remain in place without any flicker or re-render.

Why does the sidebar stay in place?

Your app/dashboard/layout.tsx wraps all child routes with the sidebar. When you navigate between /dashboard and /dashboard/models, Next.js only swaps the {children} portion. The layout (and everything it renders, including the sidebar) never unmounts.

Two pages down, two to go. Next up, you will build the Usage and API Keys pages with BorderBeam and FlickeringGrid, then add a login page with a root redirect.

Build the Usage and API Keys Pages, Add Login

Your Dashboard and Models pages are live with animated stats and interactive hover effects. Two placeholder pages remain in your sidebar: Usage and API Keys.

In this step, you'll build both remaining pages using BorderBeam and FlickeringGrid animated components. You'll also create a standalone login page and wire up the root URL to redirect visitors there.

In this step, get ready to:
  • Build the Usage page with animated border beams and live-updating metrics.
  • Build the API Keys page with a flickering grid background behind a key management table.
  • Create a login page and redirect the root URL to it.
Build the Usage page with BorderBeam stat cards

BorderBeam renders a glowing beam that travels along the border of a card. It requires its parent container to have position: relative and overflow: hidden so the beam stays clipped to the card edges.

  • Open app/dashboard/usage/page.tsx in your editor.
  • Replace the entire placeholder content with the following code:
"use client"

import { BorderBeam } from "@/components/ui/border-beam"
import { NumberTicker } from "@/components/ui/number-ticker"

// Usage metrics displayed as stat cards
const usageStats = [
  { label: "Requests Today", value: 4521, unit: "" },
  { label: "Tokens This Hour", value: 89200, unit: "" },
  { label: "Cost (MTD)", value: 142.5, unit: "$", decimalPlaces: 2 },
  { label: "Rate Limit", value: 78, unit: "%", decimalPlaces: 0 },
]

export default function UsagePage() {
  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold tracking-tight">Usage</h2>
        <p className="text-muted-foreground">
          Monitor your real-time platform usage.
        </p>
      </div>
      {/* Stat cards grid - each card has a BorderBeam animation */}
      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
        {usageStats.map((stat) => (
          <div
            key={stat.label}
            className="relative overflow-hidden rounded-xl border bg-card p-6"
          >
            <BorderBeam size={80} duration={8} />
            <p className="text-sm text-muted-foreground">{stat.label}</p>
            <p className="text-3xl font-bold mt-2">
              {stat.unit === "$" && "$"}
              <NumberTicker
                value={stat.value}
                decimalPlaces={stat.decimalPlaces || 0}
              />
              {stat.unit === "%" && "%"}
            </p>
          </div>
        ))}
      </div>
      {/* Monthly breakdown with a purple-themed BorderBeam */}
      <div className="relative overflow-hidden rounded-xl border bg-card p-6">
        <BorderBeam size={120} duration={10} colorFrom="#4f46e5" colorTo="#7c3aed" />
        <h3 className="font-semibold">Monthly Breakdown</h3>
        <div className="mt-4 grid grid-cols-3 gap-4 text-center">
          <div>
            <p className="text-2xl font-bold">
              <NumberTicker value={1250000} />
            </p>
            <p className="text-sm text-muted-foreground">Input Tokens</p>
          </div>
          <div>
            <p className="text-2xl font-bold">
              <NumberTicker value={890000} />
            </p>
            <p className="text-sm text-muted-foreground">Output Tokens</p>
          </div>
          <div>
            <p className="text-2xl font-bold">
              <NumberTicker value={42} />
            </p>
            <p className="text-sm text-muted-foreground">Active Endpoints</p>
          </div>
        </div>
      </div>
    </div>
  )
}

What does this code do?

The usageStats array defines four metrics, each with a label, numeric value, and optional unit symbol ($ or %).

Each stat card uses relative overflow-hidden so BorderBeam's animated beam stays clipped to the card border. The Monthly Breakdown card has custom purple colors via colorFrom and colorTo props.

  • Save app/dashboard/usage/page.tsx.
  • In your browser, click Usage in the sidebar. You should see four stat cards with glowing animated borders and a Monthly Breakdown section below them.

Don't see the animated borders?

Make sure every stat card's outer div has both relative and overflow-hidden in its className. Without these, the beam renders but escapes the card boundaries.

If you see a blank page, check that your file starts with "use client". BorderBeam uses browser APIs that require client-side rendering.

Help me debug my BorderBeam stat cards on the Usage page.

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on.

ⓧ I'd like to double check the full code

"use client"

import { BorderBeam } from "@/components/ui/border-beam"
import { NumberTicker } from "@/components/ui/number-ticker"

const usageStats = [
  { label: "Requests Today", value: 4521, unit: "" },
  { label: "Tokens This Hour", value: 89200, unit: "" },
  { label: "Cost (MTD)", value: 142.5, unit: "$", decimalPlaces: 2 },
  { label: "Rate Limit", value: 78, unit: "%", decimalPlaces: 0 },
]

export default function UsagePage() {
  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold tracking-tight">Usage</h2>
        <p className="text-muted-foreground">
          Monitor your real-time platform usage.
        </p>
      </div>
      <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
        {usageStats.map((stat) => (
          <div
            key={stat.label}
            className="relative overflow-hidden rounded-xl border bg-card p-6"
          >
            <BorderBeam size={80} duration={8} />
            <p className="text-sm text-muted-foreground">{stat.label}</p>
            <p className="text-3xl font-bold mt-2">
              {stat.unit === "$" && "$"}
              <NumberTicker
                value={stat.value}
                decimalPlaces={stat.decimalPlaces || 0}
              />
              {stat.unit === "%" && "%"}
            </p>
          </div>
        ))}
      </div>
      <div className="relative overflow-hidden rounded-xl border bg-card p-6">
        <BorderBeam size={120} duration={10} colorFrom="#4f46e5" colorTo="#7c3aed" />
        <h3 className="font-semibold">Monthly Breakdown</h3>
        <div className="mt-4 grid grid-cols-3 gap-4 text-center">
          <div>
            <p className="text-2xl font-bold">
              <NumberTicker value={1250000} />
            </p>
            <p className="text-sm text-muted-foreground">Input Tokens</p>
          </div>
          <div>
            <p className="text-2xl font-bold">
              <NumberTicker value={890000} />
            </p>
            <p className="text-sm text-muted-foreground">Output Tokens</p>
          </div>
          <div>
            <p className="text-2xl font-bold">
              <NumberTicker value={42} />
            </p>
            <p className="text-sm text-muted-foreground">Active Endpoints</p>
          </div>
        </div>
      </div>
    </div>
  )
}
Build the API Keys page with FlickeringGrid

FlickeringGrid renders a canvas-based animation of small squares that flicker in and out. You'll position it as an absolute background layer behind a key management table.

  • Open app/dashboard/api-keys/page.tsx in your editor.
  • Replace the entire placeholder content with the following code:
"use client"

import { FlickeringGrid } from "@/components/ui/flickering-grid"
import { Button } from "@/components/ui/button"
import { Copy, Plus, Trash2 } from "lucide-react"

// Sample API key data
const apiKeys = [
  { name: "Production", key: "sk-prod-****-****-abcd", created: "2025-03-15" },
  { name: "Development", key: "sk-dev-****-****-efgh", created: "2025-04-02" },
  { name: "Testing", key: "sk-test-****-****-ijkl", created: "2025-04-20" },
]

export default function ApiKeysPage() {
  return (
    <div className="space-y-6">
      {/* Header with Create Key button */}
      <div className="flex items-center justify-between">
        <div>
          <h2 className="text-2xl font-bold tracking-tight">API Keys</h2>
          <p className="text-muted-foreground">
            Manage your API keys for platform access.
          </p>
        </div>
        <Button>
          <Plus className="mr-2 h-4 w-4" />
          Create Key
        </Button>
      </div>
      {/* Key table with FlickeringGrid background */}
      <div className="relative overflow-hidden rounded-xl border">
        <FlickeringGrid
          className="absolute inset-0 z-0"
          squareSize={4}
          gridGap={6}
          color="rgb(99, 102, 241)"
          maxOpacity={0.15}
          flickerChance={0.1}
        />
        <div className="relative z-10">
          <div className="divide-y">
            {apiKeys.map((apiKey) => (
              <div
                key={apiKey.name}
                className="flex items-center justify-between p-4 backdrop-blur-sm"
              >
                <div>
                  <p className="font-medium">{apiKey.name}</p>
                  <p className="text-sm text-muted-foreground font-mono">
                    {apiKey.key}
                  </p>
                </div>
                <div className="flex items-center gap-2">
                  <span className="text-xs text-muted-foreground">
                    {apiKey.created}
                  </span>
                  <Button variant="ghost" size="icon">
                    <Copy className="h-4 w-4" />
                  </Button>
                  <Button variant="ghost" size="icon">
                    <Trash2 className="h-4 w-4" />
                  </Button>
                </div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  )
}

What does this code do?

The FlickeringGrid component is positioned absolutely with z-0 so it fills the container as a background layer. The actual key rows sit in a relative z-10 wrapper so they appear above the animation.

Each row uses backdrop-blur-sm for a frosted glass effect that lets the grid show through subtly. The maxOpacity={0.15} keeps the animation subtle so it does not distract from the table content.

  • Save app/dashboard/api-keys/page.tsx.
  • In your browser, click API Keys in the sidebar. You should see a flickering indigo grid behind three API key rows with copy and delete buttons.

Grid not visible or covering the content?

If the grid covers your key rows, check that the content wrapper has relative z-10 and the FlickeringGrid has z-0. The z-index layering controls which element renders on top.

If you see no grid at all, confirm the parent container has relative overflow-hidden in its className.

Help me fix my FlickeringGrid background on the API Keys page.

✔️ Awesome, I've got everything!

Great. Make sure you've saved your file before moving on.

ⓧ I'd like to double check the full code

"use client"

import { FlickeringGrid } from "@/components/ui/flickering-grid"
import { Button } from "@/components/ui/button"
import { Copy, Plus, Trash2 } from "lucide-react"

const apiKeys = [
  { name: "Production", key: "sk-prod-****-****-abcd", created: "2025-03-15" },
  { name: "Development", key: "sk-dev-****-****-efgh", created: "2025-04-02" },
  { name: "Testing", key: "sk-test-****-****-ijkl", created: "2025-04-20" },
]

export default function ApiKeysPage() {
  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <h2 className="text-2xl font-bold tracking-tight">API Keys</h2>
          <p className="text-muted-foreground">
            Manage your API keys for platform access.
          </p>
        </div>
        <Button>
          <Plus className="mr-2 h-4 w-4" />
          Create Key
        </Button>
      </div>
      <div className="relative overflow-hidden rounded-xl border">
        <FlickeringGrid
          className="absolute inset-0 z-0"
          squareSize={4}
          gridGap={6}
          color="rgb(99, 102, 241)"
          maxOpacity={0.15}
          flickerChance={0.1}
        />
        <div className="relative z-10">
          <div className="divide-y">
            {apiKeys.map((apiKey) => (
              <div
                key={apiKey.name}
                className="flex items-center justify-between p-4 backdrop-blur-sm"
              >
                <div>
                  <p className="font-medium">{apiKey.name}</p>
                  <p className="text-sm text-muted-foreground font-mono">
                    {apiKey.key}
                  </p>
                </div>
                <div className="flex items-center gap-2">
                  <span className="text-xs text-muted-foreground">
                    {apiKey.created}
                  </span>
                  <Button variant="ghost" size="icon">
                    <Copy className="h-4 w-4" />
                  </Button>
                  <Button variant="ghost" size="icon">
                    <Trash2 className="h-4 w-4" />
                  </Button>
                </div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  )
}
Create the login page and root redirect

Right now, visiting the root URL shows the default Next.js welcome page. A real dashboard needs a login screen as its entry point.

  • Create a new file at app/login/page.tsx. This file makes /login a publicly accessible route.
  • Paste the following code into the new file:
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export default function LoginPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-sm space-y-6 p-6">
        {/* Page heading */}
        <div className="space-y-2 text-center">
          <h1 className="text-2xl font-bold">Welcome back</h1>
          <p className="text-muted-foreground text-sm">
            Sign in to your account to continue
          </p>
        </div>
        {/* Login form fields */}
        <div className="space-y-4">
          <div className="space-y-2">
            <Label htmlFor="email">Email</Label>
            <Input id="email" type="email" placeholder="you@example.com" />
          </div>
          <div className="space-y-2">
            <Label htmlFor="password">Password</Label>
            <Input id="password" type="password" />
          </div>
          {/* Sign In navigates directly to the dashboard */}
          <Button className="w-full" asChild>
            <Link href="/dashboard">Sign In</Link>
          </Button>
        </div>
      </div>
    </div>
  )
}

What does this code do?

This is a simple login form with email and password fields. The Sign In button uses the Link component to navigate directly to /dashboard without any real authentication.

Notice this page lives outside the app/dashboard/ folder. That means it does NOT get wrapped by the dashboard layout, so no sidebar appears on the login screen.

  • Save app/login/page.tsx.
  • Navigate to http://localhost:3000/login in your browser. You should see a centered sign-in form with no sidebar.

Seeing a 404 at /login?

Check that your file is at app/login/page.tsx (not app/dashboard/login/page.tsx). The login page must be a sibling of the dashboard folder, not inside it.

Help me figure out why my /login route is returning a 404.

Now you'll make the root URL redirect visitors to this login page automatically.

  • Open app/page.tsx (the root page).
  • Replace the entire file content with the following:
import { redirect } from "next/navigation"

export default function Home() {
  // Immediately redirect visitors to the login page
  redirect("/login")
}

What does redirect() do here?

The redirect() function from next/navigation performs a server-side redirect before the page even renders. Anyone visiting / gets sent to /login instantly.

  • Save app/page.tsx.
  • Navigate to http://localhost:3000 in your browser. You should be automatically redirected to the login page at /login.
  • Click Sign In on the login page. You should land on the dashboard with the sidebar visible.

Redirect not working?

Make sure you removed ALL the old content from app/page.tsx. If any JSX remains, the file may error out instead of redirecting.

Also confirm the import is from next/navigation (not next/router). The App Router uses next/navigation for all navigation utilities.

Help me fix my root page redirect to /login.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export default function LoginPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-sm space-y-6 p-6">
        <div className="space-y-2 text-center">
          <h1 className="text-2xl font-bold">Welcome back</h1>
          <p className="text-muted-foreground text-sm">
            Sign in to your account to continue
          </p>
        </div>
        <div className="space-y-4">
          <div className="space-y-2">
            <Label htmlFor="email">Email</Label>
            <Input id="email" type="email" placeholder="you@example.com" />
          </div>
          <div className="space-y-2">
            <Label htmlFor="password">Password</Label>
            <Input id="password" type="password" />
          </div>
          <Button className="w-full" asChild>
            <Link href="/dashboard">Sign In</Link>
          </Button>
        </div>
      </div>
    </div>
  )
}
import { redirect } from "next/navigation"

export default function Home() {
  redirect("/login")
}

Secret mission

Add Dark Mode and an Auth Guard

Your dashboard looks great, but production apps need two more things: dark mode for user preference and route protection to keep unauthorized users out. In this secret mission, you will add both.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future reference, or delete them to free up disk space.

Resources you used:

  • Next.js project folder on your Desktop (contains all source code and dependencies).
  • The node_modules directory inside the project (installed packages).
  • Local dev server process (if still running).

Keep everything running

No action needed. Choose this if you are still actively building or want to revisit the project soon.

  • Your project folder stays on your Desktop and you can restart the dev server anytime with pnpm dev.
  • No ongoing costs since everything is local.

Pause - I'll come back to this later

Stop the dev server to free up memory. Your files stay intact so you can pick up where you left off.

  • In your terminal, press Ctrl+C to stop the dev server if it is still running.
  • Close VS Codium. Your project folder remains on your Desktop for later.
  • To resume, open the project folder in VS Codium and run pnpm dev again.

Delete - I don't want to use this again

Remove all project files and free up disk space completely.

  • In your terminal, press Ctrl+C to stop the dev server if it is still running.
  • Close VS Codium.
  • Delete the entire project folder from your Desktop. You can drag it to the Trash (macOS) or Recycle Bin (Windows), or run rm -rf ~/Desktop/your-project-folder from the terminal.
  • Node.js and pnpm remain installed on your system for future projects. You do not need to uninstall them.

That's a Wrap!

That's a Wrap!

Nice work! You just built a professional multi-page dashboard with persistent sidebar navigation using Next.js App Router nested layouts, shadcn/ui components, and animated Magic UI elements.

You've learned how to:

  • Use the nested layout pattern in Next.js App Router to keep a sidebar persistent across page navigations without re-rendering.
  • Customize the shadcn sidebar-07 block with your own navigation items, a user dropdown, and active link highlighting using usePathname().
  • Integrate 5 Magic UI animated components (BentoGrid, NumberTicker, MagicCard, BorderBeam, FlickeringGrid) across 4 distinct dashboard pages with a standalone login flow.
  • Add a dark mode toggle and a middleware-based auth guard that redirects unauthenticated users away from dashboard routes.

Ready to quiz yourself?