Build a Product Catalog with Supabase

Build a full-stack product catalog with admin dashboard and image uploads.

Introduction

30 Second Summary

Every online store you browse has a product catalog behind it. Someone built the grid of images, names, and prices you scroll through, and someone else built the admin panel that keeps it all up to date.

In this project, you will build a full product catalog website from scratch. You will create a public page where visitors browse products in a responsive grid, and an admin dashboard where you can sign in to add, edit, and delete products with image uploads.

What You'll Build

A Next.js full-stack application with a Supabase backend, deployed live on Vercel.

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

  • A live product catalog website with a Shopee-style responsive grid layout, warm red/orange/yellow color scheme, and a dark mode toggle.
  • A Supabase backend with a products table, image storage bucket, and Row Level Security policies protecting your data.
  • An admin dashboard with email/password authentication that supports full CRUD (create, read, update, delete) on products with image uploads.
  • Secret Mission: Add a search bar and price-range filter to the public catalog.

Are there any prerequisites?

None. This guide walks you through setting up every tool and account you need. No prior HTML, CSS, or JavaScript experience is required.

Set Up Your Project and Supabase Account

Before you can build a product catalog, you need the right foundation in place. Next.js gives you a full-stack React framework that handles both the frontend pages and the backend API.

Supabase provides the database, file storage, and authentication your catalog needs. In this step, you will scaffold a Next.js project and connect it to a free Supabase account.

In this step, get ready to:
  • Create a Next.js project with JavaScript and Tailwind CSS.
  • Install the Supabase client packages.
  • Sign up for Supabase and connect your project with environment variables.
Create your Next.js project

Next.js uses a scaffolding tool called create-next-app to generate your project structure automatically. It includes Tailwind CSS for styling and the App Router for page navigation by default.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest product-catalog --js

What will this command ask you?

The create-next-app tool will ask if you want to use the recommended Next.js defaults. Select No, customize settings so you can choose JavaScript.

When prompted, select No for TypeScript, Yes for Tailwind CSS, No for src/ directory, Yes for App Router, and accept the defaults for all other options.

  • Once the scaffolding finishes, move into your new project folder by running:
cd product-catalog
  • Confirm the project was created by running:
ls

You should see files like package.json, app/, and node_modules/ in the output.

Command not found or permission error?

Make sure you have Node.js version 20 or higher installed. Run node --version to check. If your version is below 20, update Node.js from nodejs.org before continuing.

help me fix a create-next-app error

Install the Supabase client packages

Your Next.js app needs two Supabase libraries to communicate with the database. One handles browser-side requests and the other handles server-side requests.

  • Install both Supabase packages by running:
npm install @supabase/supabase-js @supabase/ssr

What do these packages do?

@supabase/supabase-js is the main Supabase client library. It lets your app query the database, upload files, and manage authentication.

@supabase/ssr adds server-side rendering support. It handles cookies so your app can maintain user sessions across page loads.

You should see output confirming the packages were added to your project.

Install failed with dependency errors?

If you see errors mentioning peer dependencies or version conflicts, try running the install command again with the --legacy-peer-deps flag. This resolves most version mismatch warnings.

help me fix npm install errors for Supabase packages

Sign up for Supabase and create your environment file

Supabase needs a Project URL and a Publishable Key so your app knows which database to connect to. You will store these values in a local environment file that Next.js reads automatically.

  • Open your browser and go to supabase.com.
  • Click Start your project and sign up for a free account (you can use GitHub or email).
  • Once signed in, click New Project.
  • Enter a project name (e.g. product-catalog).
  • Set a database password and save it somewhere safe.
  • Choose a region close to you, then click Create new project.
  • Wait about 1 minute for your project to finish provisioning.

Now you need to find your Project URL and Publishable Key.

  • In your Supabase project dashboard, click the Connect button at the top of the page.
  • Copy the Project URL (it looks like https://your-project.supabase.co).
  • Copy the Publishable key (it starts with sb_publishable_).

Where else can I find these values?

You can also find your keys by going to Settings then API Keys in the Supabase dashboard sidebar. The Publishable key is safe to use in frontend code because Row Level Security controls data access at the database level.

  • Back in VS Code, create a new file called .env.local in the root of your product-catalog folder.
  • Paste the following into .env.local, replacing the placeholder values with the URL and key you copied:
NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key

What do these variables do?

NEXT_PUBLIC_SUPABASE_URL tells your app which Supabase project to connect to.

NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY authenticates requests from your app. The NEXT_PUBLIC_ prefix makes these available in both server and browser code.

  • Save .env.local.
  • Verify everything works by starting the development server:
npm run dev

You should see output confirming the server is running at http://localhost:3000.

  • Open http://localhost:3000 in your browser.

You should see the default Next.js welcome page.

Page not loading at localhost:3000?

Check the terminal for error messages. If you see a message about a missing module, run npm install again. If port 3000 is already in use, the terminal will show an alternative port number to use instead.

help me fix my Next.js dev server not starting

✔️ Awesome, I've got everything!

Great work. Make sure you have saved .env.local and your dev server is running.

ⓧ I'd like to double check the full code

Here is the complete .env.local file. Replace the placeholder values with your actual Supabase credentials.

NEXT_PUBLIC_SUPABASE_URL=your-supabase-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-supabase-publishable-key

Your development environment is ready. Next up, you will configure the Supabase backend with a products table and storage bucket to hold your catalog data.

Configure the Supabase Backend

Your project is scaffolded and connected to Supabase. Now it's time to build the backend that will actually store your product data and images.

Every product in your catalog needs a database row (name, price, image URL) and an uploaded image file. In this step, you'll create the database table, a storage bucket for images, and the utility files that let your app communicate with Supabase.

In this step, get ready to:
  • Create the products table with Row Level Security policies.
  • Set up the product-images storage bucket.
  • Create the Supabase client utility files.
Create the products table and RLS policies

Your product catalog needs a structured place to store each product's details. You'll create a table in Supabase's PostgreSQL database using the SQL Editor.

  • In your Supabase dashboard, click SQL Editor in the left sidebar.
  • Click New query to open a blank query tab.
  • Paste the following SQL to create your products table and security policies, then click Run:
-- Create the products table
create table products (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  price numeric not null,
  image_url text,
  created_at timestamptz default now()
);

-- Enable Row Level Security
alter table products enable row level security;

-- Allow anyone to view products (public catalog)
create policy "Anyone can view products"
  on products for select
  using (true);

-- Only authenticated users can add products
create policy "Authenticated users can insert products"
  on products for insert
  to authenticated
  with check (true);

-- Only authenticated users can update products
create policy "Authenticated users can update products"
  on products for update
  to authenticated
  using (true)
  with check (true);

-- Only authenticated users can delete products
create policy "Authenticated users can delete products"
  on products for delete
  to authenticated
  using (true);

What does this SQL do?

The first block creates a products table with five columns. Each product gets a unique id (auto-generated UUID), a name, a price, an image_url (link to the uploaded image), and a created_at timestamp.

The Row Level Security (RLS) policies control who can do what. The SELECT policy lets anyone read products (your public catalog). The INSERT, UPDATE, and DELETE policies restrict changes to authenticated users only (your admin).

  • You should see a Success. No rows returned message at the bottom of the SQL Editor.

Seeing an error after running the SQL?

If you see a "relation already exists" error, the table was already created from a previous attempt. You can drop it first with drop table if exists products; and re-run the full query.

If you see a syntax error, make sure you copied the entire SQL block including the semicolons at the end of each statement.

Still stuck? Help me fix my SQL query in the Supabase SQL Editor

Create the product-images storage bucket

Product images need a dedicated storage location. Supabase Storage organizes files into buckets. You'll create a public bucket so anyone can view product images, but only admins can upload new ones.

  • In the left sidebar of your Supabase dashboard, click Storage.
  • Click New Bucket.
  • Enter product-images as the bucket name.
  • Toggle Public bucket to on. This allows anyone to view images via their URL.
  • Click Create Bucket.

Why a public bucket?

A public bucket means anyone with the image URL can view it. This is exactly what you want for product images on a catalog page. Uploads, updates, and deletions still require authentication, so only your admin can manage files.

Now you need to add storage policies so authenticated users can upload and manage images. You'll do this with SQL.

  • Switch back to the SQL Editor in the left sidebar.
  • Open a new query tab and paste the following SQL, then click Run:
-- Allow anyone to view/download product images
create policy "Anyone can view product images"
  on storage.objects for select
  using (bucket_id = 'product-images');

-- Allow authenticated users to upload product images
create policy "Authenticated users can upload product images"
  on storage.objects for insert
  to authenticated
  with check (bucket_id = 'product-images');

-- Allow authenticated users to update product images
create policy "Authenticated users can update product images"
  on storage.objects for update
  to authenticated
  using (bucket_id = 'product-images');

-- Allow authenticated users to delete product images
create policy "Authenticated users can delete product images"
  on storage.objects for delete
  to authenticated
  using (bucket_id = 'product-images');

What does this SQL do?

Storage policies in Supabase are RLS policies on the storage.objects table. The bucket_id = 'product-images' condition scopes each policy to only affect files in your product-images bucket.

The SELECT policy allows public downloads. The INSERT, UPDATE, and DELETE policies are restricted to the authenticated role, meaning only signed-in users (your admin) can manage files.

  • You should see a Success. No rows returned message confirming the policies were created.

Seeing a duplicate policy error?

If you ran this query more than once, the policies already exist. You can drop them first by running drop policy statements with the policy names, or simply continue since the policies are already in place.

Need help? Help me fix my Supabase storage policy error

Create the Supabase client utility files

Your Next.js app needs two ways to talk to Supabase. The browser client handles interactions from the user's browser (like form submissions). The server client handles data fetching on the server before the page loads.

  • Switch back to VS Code.
  • Create a new folder called lib in your project root, then create a folder called supabase inside it. You can do this by running:
mkdir -p lib/supabase
  • Confirm the folder exists by running:
ls lib/supabase

You should see an empty directory listed (no output, no errors).

  • Create the browser client file by creating lib/supabase/client.js and pasting the following code:
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
  )
}

What does this file do?

This creates a Supabase client for browser-side code. It uses createBrowserClient from @supabase/ssr which automatically handles cookie-based authentication in the browser. The environment variables you set in .env.local tell it where your Supabase project lives.

  • Save lib/supabase/client.js.

Now create the server client. This one is more complex because it needs to handle cookies for server-side authentication.

  • Create lib/supabase/server.js and paste the following code:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // The setAll method was called from a Server Component.
            // This can be ignored if you have middleware refreshing user sessions.
          }
        },
      },
    }
  )
}

What does this file do?

This creates a Supabase client for server-side code (Server Components and Route Handlers). It uses createServerClient which needs access to cookies for reading and writing authentication tokens.

The getAll function reads existing cookies. The setAll function writes updated cookies. The try/catch handles a known limitation where setting cookies from a Server Component throws an error that can be safely ignored.

  • Save lib/supabase/server.js.

Seeing import errors in VS Code?

If VS Code underlines the imports with red, make sure you installed @supabase/ssr in the previous step. You can verify by checking your package.json for @supabase/ssr in the dependencies list.

Still stuck? Help me fix import errors for @supabase/ssr in my Next.js project

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for both utility files:

import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
  )
}
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // The setAll method was called from a Server Component.
            // This can be ignored if you have middleware refreshing user sessions.
          }
        },
      },
    }
  )
}

Your backend is fully configured. Next up, you'll build the public catalog page that fetches products from the database and displays them in a responsive grid.

Build the Public Catalog Page

Your Supabase backend is ready with a products table and image storage bucket. Now it is time to build the frontend that visitors will see.

In this step, you will create the public catalog page with a responsive product grid, a warm red/orange/yellow color scheme, and a dark mode toggle. The page fetches product data directly from Supabase on the server so it loads instantly for visitors.

In this step, get ready to:
  • Update the global styles and layout to support dark mode.
  • Create a theme toggle component that switches between light and dark mode.
  • Build the product grid and main page with live Supabase data fetching.
Update the global styles and layout

Before building components, you need to configure your app shell. Tailwind CSS v4 uses a CSS-first approach where you configure dark mode directly in your stylesheet instead of a JavaScript config file.

  • In VS Code, open app/globals.css from the Explorer sidebar.
  • Select all the existing content and replace it with this code:
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));
  • Now open app/layout.js and replace all of its content with the following:
import './globals.css'

export const metadata = {
  title: 'Product Catalog',
  description: 'Browse our product catalog',
}

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className="bg-white dark:bg-gray-900 transition-colors duration-300">
        {children}
      </body>
    </html>
  )
}

What does this code do?

The globals.css file imports Tailwind CSS and registers a custom dark mode variant. The @custom-variant dark line tells Tailwind to activate dark: classes whenever a dark CSS class is present on a parent element, instead of relying on the operating system preference.

The layout.js file wraps every page in your app. The suppressHydrationWarning attribute prevents React warnings when the dark class is added client-side. The body uses dark:bg-gray-900 to switch to a dark background when dark mode is toggled.

  • Save both app/globals.css and app/layout.js.

You will not see a visible change yet. The next chunks build the components that this layout renders.

✔️ 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 "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));
import './globals.css'

export const metadata = {
  title: 'Product Catalog',
  description: 'Browse our product catalog',
}

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body className="bg-white dark:bg-gray-900 transition-colors duration-300">
        {children}
      </body>
    </html>
  )
}
Add dark mode support with a theme toggle

Dark mode needs a button that toggles a dark class on the <html> element and remembers the user's preference. You will build this as a client component because it uses browser APIs like localStorage and document.

  • Create a new folder inside app/ called components. Right-click the app folder in the Explorer sidebar and select New Folder.
  • Inside app/components/, create a new file called ThemeToggle.js.
  • Paste the following code into ThemeToggle.js:
'use client'

import { useState, useEffect } from 'react'

export default function ThemeToggle() {
  const [dark, setDark] = useState(false)

  useEffect(() => {
    const saved = localStorage.getItem('theme')
    if (saved === 'dark') {
      setDark(true)
      document.documentElement.classList.add('dark')
    }
  }, [])

  function toggleTheme() {
    const newDark = !dark
    setDark(newDark)
    if (newDark) {
      document.documentElement.classList.add('dark')
      localStorage.setItem('theme', 'dark')
    } else {
      document.documentElement.classList.remove('dark')
      localStorage.setItem('theme', 'light')
    }
  }

  return (
    <button
      onClick={toggleTheme}
      className="px-4 py-2 rounded-lg bg-orange-100 dark:bg-gray-700 text-orange-800 dark:text-yellow-300 font-medium hover:bg-orange-200 dark:hover:bg-gray-600 transition-colors"
    >
      {dark ? 'Light Mode' : 'Dark Mode'}
    </button>
  )
}

What does this code do?

  • 'use client' marks this as a client component that runs in the browser. Server components cannot use useState or useEffect.
  • The useEffect hook checks localStorage on page load. If the user previously chose dark mode, it applies the dark class immediately.
  • The toggleTheme function flips the state, adds or removes the dark class on document.documentElement (the <html> tag), and saves the preference.
  • Save app/components/ThemeToggle.js.

This component is not rendered anywhere yet. The next substep builds the page that uses it.

✔️ Awesome, I've got everything!

Great. Double check you saved the file before moving on.

ⓧ I'd like to double check the full code

'use client'

import { useState, useEffect } from 'react'

export default function ThemeToggle() {
  const [dark, setDark] = useState(false)

  useEffect(() => {
    const saved = localStorage.getItem('theme')
    if (saved === 'dark') {
      setDark(true)
      document.documentElement.classList.add('dark')
    }
  }, [])

  function toggleTheme() {
    const newDark = !dark
    setDark(newDark)
    if (newDark) {
      document.documentElement.classList.add('dark')
      localStorage.setItem('theme', 'dark')
    } else {
      document.documentElement.classList.remove('dark')
      localStorage.setItem('theme', 'light')
    }
  }

  return (
    <button
      onClick={toggleTheme}
      className="px-4 py-2 rounded-lg bg-orange-100 dark:bg-gray-700 text-orange-800 dark:text-yellow-300 font-medium hover:bg-orange-200 dark:hover:bg-gray-600 transition-colors"
    >
      {dark ? 'Light Mode' : 'Dark Mode'}
    </button>
  )
}
Fetch products and display the catalog grid

Now you will build the product grid component and the main page. The page is a server component that fetches products from Supabase before the HTML reaches the browser. This means the catalog loads instantly with no loading spinners.

  • Create a new file called ProductGrid.js inside app/components/.
  • Paste the following code into ProductGrid.js:
export default function ProductGrid({ products }) {
  if (!products || products.length === 0) {
    return (
      <div className="text-center py-20">
        <p className="text-xl text-gray-500 dark:text-gray-400">
          No products yet. Check back soon!
        </p>
      </div>
    )
  }

  return (
    <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
      {products.map((product) => (
        <div
          key={product.id}
          className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow overflow-hidden border border-orange-100 dark:border-gray-700"
        >
          <div className="aspect-square overflow-hidden bg-gray-100 dark:bg-gray-700">
            <img
              src={product.image_url}
              alt={product.name}
              className="w-full h-full object-cover"
            />
          </div>
          <div className="p-3">
            <h3 className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
              {product.name}
            </h3>
            <p className="text-red-600 dark:text-orange-400 font-bold mt-1">
              ${Number(product.price).toFixed(2)}
            </p>
          </div>
        </div>
      ))}
    </div>
  )
}

What does this code do?

  • The component receives a products array as a prop. If it is empty, an "No products yet" message displays.
  • The responsive grid uses Tailwind's grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 to show 2 columns on small screens and up to 5 on large screens.
  • Each card shows the product image in a square aspect ratio, the product name (truncated if too long), and the price in red (or orange in dark mode).
  • Save app/components/ProductGrid.js.
  • Now open app/page.js and replace all of its content with the following:
import { createClient } from '@/lib/supabase/server'
import ThemeToggle from './components/ThemeToggle'
import ProductGrid from './components/ProductGrid'

export default async function Home() {
  const supabase = await createClient()
  const { data: products } = await supabase
    .from('products')
    .select()
    .order('created_at', { ascending: false })

  return (
    <div className="min-h-screen bg-gradient-to-b from-orange-50 to-white dark:from-gray-900 dark:to-gray-800">
      <header className="bg-gradient-to-r from-red-600 via-orange-500 to-yellow-500 dark:from-red-800 dark:via-orange-700 dark:to-yellow-700 shadow-lg">
        <div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
          <h1 className="text-2xl font-bold text-white">Product Catalog</h1>
          <ThemeToggle />
        </div>
      </header>
      <main className="max-w-7xl mx-auto px-4 py-8">
        <ProductGrid products={products} />
      </main>
    </div>
  )
}

What does this code do?

  • The async keyword makes this a server component that can await data. It calls createClient() from your server utility and queries all products ordered by newest first.
  • The header uses a gradient from red through orange to yellow, giving it the warm Shopee-inspired color scheme.
  • The ThemeToggle button sits in the header. The ProductGrid receives the fetched products and renders cards or the empty state.
  • Save app/page.js.
  • Return to your browser at http://localhost:3000 and refresh the page.

You should see a warm red-to-orange gradient header with "Product Catalog" on the left and a "Dark Mode" button on the right. Below the header, the message "No products yet. Check back soon!" displays because the database is empty.

  • Click the Dark Mode button in the top right corner.

The page background switches to dark gray, the header gradient deepens, and the button label changes to "Light Mode". Click it again to switch back.

Page not loading or showing errors?

  • Make sure your dev server is still running. If you stopped it, run npm run dev again from your project folder.
  • Check that your .env.local file has the correct Supabase URL and key. A wrong URL causes a connection error.
  • If you see a module not found error, make sure ThemeToggle.js and ProductGrid.js are inside app/components/ (not in the project root).

Still stuck? Help me debug why my catalog page is not rendering correctly.

✔️ Awesome, I've got everything!

Great. Double check you saved all files before continuing.

ⓧ I'd like to double check the full code

export default function ProductGrid({ products }) {
  if (!products || products.length === 0) {
    return (
      <div className="text-center py-20">
        <p className="text-xl text-gray-500 dark:text-gray-400">
          No products yet. Check back soon!
        </p>
      </div>
    )
  }

  return (
    <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
      {products.map((product) => (
        <div
          key={product.id}
          className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow overflow-hidden border border-orange-100 dark:border-gray-700"
        >
          <div className="aspect-square overflow-hidden bg-gray-100 dark:bg-gray-700">
            <img
              src={product.image_url}
              alt={product.name}
              className="w-full h-full object-cover"
            />
          </div>
          <div className="p-3">
            <h3 className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
              {product.name}
            </h3>
            <p className="text-red-600 dark:text-orange-400 font-bold mt-1">
              ${Number(product.price).toFixed(2)}
            </p>
          </div>
        </div>
      ))}
    </div>
  )
}
import { createClient } from '@/lib/supabase/server'
import ThemeToggle from './components/ThemeToggle'
import ProductGrid from './components/ProductGrid'

export default async function Home() {
  const supabase = await createClient()
  const { data: products } = await supabase
    .from('products')
    .select()
    .order('created_at', { ascending: false })

  return (
    <div className="min-h-screen bg-gradient-to-b from-orange-50 to-white dark:from-gray-900 dark:to-gray-800">
      <header className="bg-gradient-to-r from-red-600 via-orange-500 to-yellow-500 dark:from-red-800 dark:via-orange-700 dark:to-yellow-700 shadow-lg">
        <div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
          <h1 className="text-2xl font-bold text-white">Product Catalog</h1>
          <ThemeToggle />
        </div>
      </header>
      <main className="max-w-7xl mx-auto px-4 py-8">
        <ProductGrid products={products} />
      </main>
    </div>
  )
}

Your public catalog page is live with a professional color scheme and dark mode support. Next up, you will build the admin login and dashboard so you can add products to this catalog.

Build the Admin Login and Dashboard

Your public catalog page looks great, but right now nobody can add products to it. You need a way for an admin to sign in and manage the catalog.

In this step, you will build a complete admin panel with email/password login, product creation with image upload, editing, and deletion. You will also add middleware that keeps admin sessions fresh across page navigations.

In this step, get ready to:
  • Create a login page and session middleware for admin authentication.
  • Build a protected admin page that checks authentication before loading.
  • Build a full CRUD dashboard with image upload, editing, and deletion.
Create the login page and auth middleware

Before building the login form itself, you need middleware that runs on every admin route request. This middleware refreshes the user's session tokens so they stay authenticated as they navigate between pages.

  • Create a new file called middleware.js in the root of your project (the same level as package.json) by pasting the following code:
import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'

export async function middleware(request) {
  let supabaseResponse = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet, headers) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          supabaseResponse = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
          if (headers) {
            Object.entries(headers).forEach(([key, value]) =>
              supabaseResponse.headers.set(key, value)
            )
          }
        },
      },
    }
  )

  await supabase.auth.getClaims()

  return supabaseResponse
}

export const config = {
  matcher: ['/admin/:path*'],
}

What does this code do?

This middleware intercepts every request to /admin routes before they reach the page component.

  • It creates a Supabase client that can read and write cookies on the request/response pair.
  • Calling supabase.auth.getClaims() refreshes expired tokens automatically, so the user stays signed in.
  • The matcher config limits this middleware to /admin/:path* routes only, leaving the public catalog unaffected.
  • Save middleware.js.
  • You won't see a visible change yet. The next chunk creates the login page that uses this middleware.

Seeing a module error?

  • Make sure middleware.js is at the project root (next to package.json), not inside the app/ folder. Next.js only detects middleware at the root level.
  • Confirm you have @supabase/ssr installed. If not, run npm install @supabase/ssr again.

Why isn't my middleware running on admin routes?

Now you will build the login form. This is a client component because it needs to manage form state and handle the sign-in interaction in the browser.

  • Create the folder path app/admin/login/ inside your project.
  • Create a file called page.js inside app/admin/login/ and paste the following code:
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'

export default function AdminLogin() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)
  const router = useRouter()
  const supabase = createClient()

  async function handleLogin(e) {
    e.preventDefault()
    setLoading(true)
    setError('')

    const { error } = await supabase.auth.signInWithPassword({ email, password })

    if (error) {
      setError(error.message)
      setLoading(false)
    } else {
      router.push('/admin')
    }
  }

  return (
    <div className="min-h-screen bg-gradient-to-b from-orange-50 to-white flex items-center justify-center px-4">
      <div className="bg-white rounded-xl shadow-lg p-8 w-full max-w-md border border-orange-100">
        <h1 className="text-2xl font-bold text-center text-red-600 mb-6">Admin Login</h1>
        <form onSubmit={handleLogin} className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full px-4 py-2 border border-orange-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              className="w-full px-4 py-2 border border-orange-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
            />
          </div>
          {error && <p className="text-red-500 text-sm">{error}</p>}
          <button
            type="submit"
            disabled={loading}
            className="w-full py-3 bg-gradient-to-r from-red-600 to-orange-500 text-white font-bold rounded-lg hover:from-red-700 hover:to-orange-600 transition-all disabled:opacity-50"
          >
            {loading ? 'Signing in...' : 'Sign In'}
          </button>
        </form>
      </div>
    </div>
  )
}

What does this code do?

  • The 'use client' directive makes this a client component so React can manage form state in the browser.
  • On form submit, handleLogin calls supabase.auth.signInWithPassword() with the email and password the user typed.
  • If sign-in succeeds, the user is redirected to /admin. If it fails, the error message from Supabase is shown below the form.
  • The warm gradient styling matches the public catalog header for a consistent look.
  • Save app/admin/login/page.js.
  • Visit http://localhost:3000/admin/login in your browser. You should see a centered login card with email and password fields and a red-to-orange gradient sign-in button.

Seeing a 404 page?

  • Check that the file is at app/admin/login/page.js (not app/admin/login.js). Next.js App Router needs the page.js filename inside a folder to create a route.
  • Make sure your dev server is still running. If it stopped, run npm run dev again.

Why can't I see my login page at /admin/login?

✔️ Awesome, I've got everything!

Great. Double check you have saved both middleware.js and app/admin/login/page.js.

ⓧ I'd like to double check the full code

Here is middleware.js (project root):

import { createServerClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'

export async function middleware(request) {
  let supabaseResponse = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet, headers) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          supabaseResponse = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
          if (headers) {
            Object.entries(headers).forEach(([key, value]) =>
              supabaseResponse.headers.set(key, value)
            )
          }
        },
      },
    }
  )

  await supabase.auth.getClaims()

  return supabaseResponse
}

export const config = {
  matcher: ['/admin/:path*'],
}

Here is app/admin/login/page.js:

'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'

export default function AdminLogin() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)
  const router = useRouter()
  const supabase = createClient()

  async function handleLogin(e) {
    e.preventDefault()
    setLoading(true)
    setError('')

    const { error } = await supabase.auth.signInWithPassword({ email, password })

    if (error) {
      setError(error.message)
      setLoading(false)
    } else {
      router.push('/admin')
    }
  }

  return (
    <div className="min-h-screen bg-gradient-to-b from-orange-50 to-white flex items-center justify-center px-4">
      <div className="bg-white rounded-xl shadow-lg p-8 w-full max-w-md border border-orange-100">
        <h1 className="text-2xl font-bold text-center text-red-600 mb-6">Admin Login</h1>
        <form onSubmit={handleLogin} className="space-y-4">
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full px-4 py-2 border border-orange-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
            />
          </div>
          <div>
            <label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
            <input
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              className="w-full px-4 py-2 border border-orange-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
            />
          </div>
          {error && <p className="text-red-500 text-sm">{error}</p>}
          <button
            type="submit"
            disabled={loading}
            className="w-full py-3 bg-gradient-to-r from-red-600 to-orange-500 text-white font-bold rounded-lg hover:from-red-700 hover:to-orange-600 transition-all disabled:opacity-50"
          >
            {loading ? 'Signing in...' : 'Sign In'}
          </button>
        </form>
      </div>
    </div>
  )
}
Create the admin page

The admin page is a server component. It checks whether the visitor is authenticated before showing anything. If they are not signed in, it redirects them straight to the login page.

  • Create a file called page.js inside app/admin/ (not inside the login subfolder) and paste the following code:
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import AdminDashboard from './AdminDashboard'

export default async function AdminPage() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    redirect('/admin/login')
  }

  const { data: products } = await supabase
    .from('products')
    .select()
    .order('created_at', { ascending: false })

  return <AdminDashboard products={products || []} />
}

What does this code do?

  • It uses the server-side Supabase client to call supabase.auth.getUser(), which securely checks the session from cookies.
  • If no user is found, redirect('/admin/login') sends the visitor to the login page before any dashboard content loads.
  • If authenticated, it fetches all products and passes them into the AdminDashboard client component (which you will create next).
  • Save app/admin/page.js.
  • Visit http://localhost:3000/admin in your browser. Since you are not signed in, you should be immediately redirected to /admin/login.

Getting a build error about AdminDashboard?

This is expected. The import references ./AdminDashboard which does not exist yet. You will create it in the next substep. If your dev server shows an error, that is fine for now.

Help me fix this AdminDashboard import error.

Build the product form component

The AdminDashboard component handles everything an admin does: adding products with image uploads, editing product details, and deleting products. It is a client component because it manages interactive form state and calls Supabase directly from the browser.

  • Create a file called AdminDashboard.js inside app/admin/ and paste the following code:
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'

export default function AdminDashboard({ products: initialProducts }) {
  const [products, setProducts] = useState(initialProducts)
  const [showForm, setShowForm] = useState(false)
  const [editingProduct, setEditingProduct] = useState(null)
  const [name, setName] = useState('')
  const [price, setPrice] = useState('')
  const [image, setImage] = useState(null)
  const [loading, setLoading] = useState(false)
  const router = useRouter()
  const supabase = createClient()

  async function handleSignOut() {
    await supabase.auth.signOut()
    router.push('/admin/login')
  }

  function openAddForm() {
    setEditingProduct(null)
    setName('')
    setPrice('')
    setImage(null)
    setShowForm(true)
  }

  function openEditForm(product) {
    setEditingProduct(product)
    setName(product.name)
    setPrice(product.price.toString())
    setImage(null)
    setShowForm(true)
  }

  async function handleSubmit(e) {
    e.preventDefault()
    setLoading(true)

    let imageUrl = editingProduct?.image_url || ''

    if (image) {
      const fileExt = image.name.split('.').pop()
      const fileName = `${Date.now()}.${fileExt}`

      const { error: uploadError } = await supabase.storage
        .from('product-images')
        .upload(fileName, image)

      if (uploadError) {
        alert('Error uploading image: ' + uploadError.message)
        setLoading(false)
        return
      }

      const { data: urlData } = supabase.storage
        .from('product-images')
        .getPublicUrl(fileName)

      imageUrl = urlData.publicUrl
    }

    if (editingProduct) {
      const { error } = await supabase
        .from('products')
        .update({ name, price: parseFloat(price), image_url: imageUrl })
        .eq('id', editingProduct.id)

      if (error) {
        alert('Error updating product: ' + error.message)
        setLoading(false)
        return
      }
    } else {
      const { error } = await supabase
        .from('products')
        .insert({ name, price: parseFloat(price), image_url: imageUrl })

      if (error) {
        alert('Error adding product: ' + error.message)
        setLoading(false)
        return
      }
    }

    setShowForm(false)
    setLoading(false)
    router.refresh()
  }

  async function handleDelete(product) {
    if (!confirm('Are you sure you want to delete this product?')) return

    const fileName = product.image_url.split('/').pop()
    await supabase.storage.from('product-images').remove([fileName])

    const { error } = await supabase
      .from('products')
      .delete()
      .eq('id', product.id)

    if (error) {
      alert('Error deleting product: ' + error.message)
      return
    }

    router.refresh()
  }

  return (
    <div className="min-h-screen bg-gradient-to-b from-orange-50 to-white">
      <header className="bg-gradient-to-r from-red-600 via-orange-500 to-yellow-500 shadow-lg">
        <div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
          <h1 className="text-2xl font-bold text-white">Admin Dashboard</h1>
          <button
            onClick={handleSignOut}
            className="px-4 py-2 bg-white text-red-600 font-medium rounded-lg hover:bg-red-50 transition-colors"
          >
            Sign Out
          </button>
        </div>
      </header>

      <main className="max-w-7xl mx-auto px-4 py-8">
        <div className="flex justify-between items-center mb-6">
          <h2 className="text-xl font-bold text-gray-800">Products ({products.length})</h2>
          <button
            onClick={openAddForm}
            className="px-6 py-2 bg-gradient-to-r from-red-600 to-orange-500 text-white font-bold rounded-lg hover:from-red-700 hover:to-orange-600 transition-all"
          >
            + Add Product
          </button>
        </div>

        {showForm && (
          <div className="bg-white rounded-xl shadow-lg p-6 mb-6 border border-orange-100">
            <h3 className="text-lg font-bold text-gray-800 mb-4">
              {editingProduct ? 'Edit Product' : 'Add New Product'}
            </h3>
            <form onSubmit={handleSubmit} className="space-y-4">
              <div>
                <label className="block text-sm font-medium text-gray-700 mb-1">Product Name</label>
                <input
                  type="text"
                  value={name}
                  onChange={(e) => setName(e.target.value)}
                  required
                  className="w-full px-4 py-2 border border-orange-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
                />
              </div>
              <div>
                <label className="block text-sm font-medium text-gray-700 mb-1">Price ($)</label>
                <input
                  type="number"
                  step="0.01"
                  value={price}
                  onChange={(e) => setPrice(e.target.value)}
                  required
                  className="w-full px-4 py-2 border border-orange-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
                />
              </div>
              <div>
                <label className="block text-sm font-medium text-gray-700 mb-1">
                  Product Image {editingProduct && '(leave empty to keep current)'}
                </label>
                <input
                  type="file"
                  accept="image/*"
                  onChange={(e) => setImage(e.target.files[0])}
                  required={!editingProduct}
                  className="w-full px-4 py-2 border border-orange-200 rounded-lg"
                />
              </div>
              <div className="flex gap-3">
                <button
                  type="submit"
                  disabled={loading}
                  className="px-6 py-2 bg-gradient-to-r from-red-600 to-orange-500 text-white font-bold rounded-lg hover:from-red-700 hover:to-orange-600 transition-all disabled:opacity-50"
                >
                  {loading ? 'Saving...' : editingProduct ? 'Update Product' : 'Add Product'}
                </button>
                <button
                  type="button"
                  onClick={() => setShowForm(false)}
                  className="px-6 py-2 bg-gray-200 text-gray-700 font-medium rounded-lg hover:bg-gray-300 transition-colors"
                >
                  Cancel
                </button>
              </div>
            </form>
          </div>
        )}

        <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
          {products.map((product) => (
            <div key={product.id} className="bg-white rounded-lg shadow-md p-4 border border-orange-100">
              <img
                src={product.image_url}
                alt={product.name}
                className="w-full h-48 object-cover rounded-lg mb-3"
              />
              <h3 className="font-medium text-gray-800">{product.name}</h3>
              <p className="text-red-600 font-bold">${Number(product.price).toFixed(2)}</p>
              <div className="flex gap-2 mt-3">
                <button
                  onClick={() => openEditForm(product)}
                  className="flex-1 px-3 py-1 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 transition-colors text-sm font-medium"
                >
                  Edit
                </button>
                <button
                  onClick={() => handleDelete(product)}
                  className="flex-1 px-3 py-1 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors text-sm font-medium"
                >
                  Delete
                </button>
              </div>
            </div>
          ))}
        </div>

        {products.length === 0 && (
          <p className="text-center text-gray-500 py-10">No products yet. Click "Add Product" to get started!</p>
        )}
      </main>
    </div>
  )
}

What does this code do?

This component is the heart of your admin panel. Here is what each section handles:

  • handleSignOut signs the user out and redirects back to the login page.
  • openAddForm and openEditForm toggle the product form visibility and pre-fill fields when editing.
  • handleSubmit uploads the image to the product-images storage bucket, gets the public URL, then inserts or updates the product row in the database.
  • handleDelete removes the image from storage and deletes the product row from the products table.
  • router.refresh() re-runs the server component to fetch fresh data after each change.
  • Save app/admin/AdminDashboard.js.

Now you need an admin account to test with. You will create one in the Supabase dashboard.

  • Return to your Supabase dashboard in the browser.
  • Click Authentication in the left sidebar.
  • Click the Add user button in the top right.
  • Select Create new user.
  • Enter an email address and a password you will remember (this is for testing only).
  • Make sure Auto Confirm User is checked so you can sign in immediately.
  • Click Create user.

Why create the user in the dashboard?

Your login form uses signInWithPassword, not signUp. This means only pre-existing users can sign in. Creating the user manually in the dashboard gives you a test account without building a separate registration page.

  • Go to http://localhost:3000/admin/login in your browser.
  • Enter the email and password you just created.
  • Click Sign In.
  • You should be redirected to the admin dashboard showing an empty product list with the message "No products yet" and a + Add Product button.

Login fails with an error message?

  • Double check the email and password match what you entered in the Supabase dashboard exactly.
  • Make sure you checked Auto Confirm User when creating the user. Without this, the account requires email verification before it can sign in.
  • Verify that your .env.local file has the correct NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY values. If you changed them, restart your dev server.

Why does my admin login keep failing?

✔️ Awesome, I've got everything!

Great. Make sure all three admin files are saved and your login works.

ⓧ I'd like to double check the full code

Here is app/admin/page.js:

import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import AdminDashboard from './AdminDashboard'

export default async function AdminPage() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    redirect('/admin/login')
  }

  const { data: products } = await supabase
    .from('products')
    .select()
    .order('created_at', { ascending: false })

  return <AdminDashboard products={products || []} />
}

Here is app/admin/AdminDashboard.js:

'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'

export default function AdminDashboard({ products: initialProducts }) {
  const [products, setProducts] = useState(initialProducts)
  const [showForm, setShowForm] = useState(false)
  const [editingProduct, setEditingProduct] = useState(null)
  const [name, setName] = useState('')
  const [price, setPrice] = useState('')
  const [image, setImage] = useState(null)
  const [loading, setLoading] = useState(false)
  const router = useRouter()
  const supabase = createClient()

  async function handleSignOut() {
    await supabase.auth.signOut()
    router.push('/admin/login')
  }

  function openAddForm() {
    setEditingProduct(null)
    setName('')
    setPrice('')
    setImage(null)
    setShowForm(true)
  }

  function openEditForm(product) {
    setEditingProduct(product)
    setName(product.name)
    setPrice(product.price.toString())
    setImage(null)
    setShowForm(true)
  }

  async function handleSubmit(e) {
    e.preventDefault()
    setLoading(true)

    let imageUrl = editingProduct?.image_url || ''

    if (image) {
      const fileExt = image.name.split('.').pop()
      const fileName = `${Date.now()}.${fileExt}`

      const { error: uploadError } = await supabase.storage
        .from('product-images')
        .upload(fileName, image)

      if (uploadError) {
        alert('Error uploading image: ' + uploadError.message)
        setLoading(false)
        return
      }

      const { data: urlData } = supabase.storage
        .from('product-images')
        .getPublicUrl(fileName)

      imageUrl = urlData.publicUrl
    }

    if (editingProduct) {
      const { error } = await supabase
        .from('products')
        .update({ name, price: parseFloat(price), image_url: imageUrl })
        .eq('id', editingProduct.id)

      if (error) {
        alert('Error updating product: ' + error.message)
        setLoading(false)
        return
      }
    } else {
      const { error } = await supabase
        .from('products')
        .insert({ name, price: parseFloat(price), image_url: imageUrl })

      if (error) {
        alert('Error adding product: ' + error.message)
        setLoading(false)
        return
      }
    }

    setShowForm(false)
    setLoading(false)
    router.refresh()
  }

  async function handleDelete(product) {
    if (!confirm('Are you sure you want to delete this product?')) return

    const fileName = product.image_url.split('/').pop()
    await supabase.storage.from('product-images').remove([fileName])

    const { error } = await supabase
      .from('products')
      .delete()
      .eq('id', product.id)

    if (error) {
      alert('Error deleting product: ' + error.message)
      return
    }

    router.refresh()
  }

  return (
    <div className="min-h-screen bg-gradient-to-b from-orange-50 to-white">
      <header className="bg-gradient-to-r from-red-600 via-orange-500 to-yellow-500 shadow-lg">
        <div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
          <h1 className="text-2xl font-bold text-white">Admin Dashboard</h1>
          <button
            onClick={handleSignOut}
            className="px-4 py-2 bg-white text-red-600 font-medium rounded-lg hover:bg-red-50 transition-colors"
          >
            Sign Out
          </button>
        </div>
      </header>

      <main className="max-w-7xl mx-auto px-4 py-8">
        <div className="flex justify-between items-center mb-6">
          <h2 className="text-xl font-bold text-gray-800">Products ({products.length})</h2>
          <button
            onClick={openAddForm}
            className="px-6 py-2 bg-gradient-to-r from-red-600 to-orange-500 text-white font-bold rounded-lg hover:from-red-700 hover:to-orange-600 transition-all"
          >
            + Add Product
          </button>
        </div>

        {showForm && (
          <div className="bg-white rounded-xl shadow-lg p-6 mb-6 border border-orange-100">
            <h3 className="text-lg font-bold text-gray-800 mb-4">
              {editingProduct ? 'Edit Product' : 'Add New Product'}
            </h3>
            <form onSubmit={handleSubmit} className="space-y-4">
              <div>
                <label className="block text-sm font-medium text-gray-700 mb-1">Product Name</label>
                <input
                  type="text"
                  value={name}
                  onChange={(e) => setName(e.target.value)}
                  required
                  className="w-full px-4 py-2 border border-orange-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
                />
              </div>
              <div>
                <label className="block text-sm font-medium text-gray-700 mb-1">Price ($)</label>
                <input
                  type="number"
                  step="0.01"
                  value={price}
                  onChange={(e) => setPrice(e.target.value)}
                  required
                  className="w-full px-4 py-2 border border-orange-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500"
                />
              </div>
              <div>
                <label className="block text-sm font-medium text-gray-700 mb-1">
                  Product Image {editingProduct && '(leave empty to keep current)'}
                </label>
                <input
                  type="file"
                  accept="image/*"
                  onChange={(e) => setImage(e.target.files[0])}
                  required={!editingProduct}
                  className="w-full px-4 py-2 border border-orange-200 rounded-lg"
                />
              </div>
              <div className="flex gap-3">
                <button
                  type="submit"
                  disabled={loading}
                  className="px-6 py-2 bg-gradient-to-r from-red-600 to-orange-500 text-white font-bold rounded-lg hover:from-red-700 hover:to-orange-600 transition-all disabled:opacity-50"
                >
                  {loading ? 'Saving...' : editingProduct ? 'Update Product' : 'Add Product'}
                </button>
                <button
                  type="button"
                  onClick={() => setShowForm(false)}
                  className="px-6 py-2 bg-gray-200 text-gray-700 font-medium rounded-lg hover:bg-gray-300 transition-colors"
                >
                  Cancel
                </button>
              </div>
            </form>
          </div>
        )}

        <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
          {products.map((product) => (
            <div key={product.id} className="bg-white rounded-lg shadow-md p-4 border border-orange-100">
              <img
                src={product.image_url}
                alt={product.name}
                className="w-full h-48 object-cover rounded-lg mb-3"
              />
              <h3 className="font-medium text-gray-800">{product.name}</h3>
              <p className="text-red-600 font-bold">${Number(product.price).toFixed(2)}</p>
              <div className="flex gap-2 mt-3">
                <button
                  onClick={() => openEditForm(product)}
                  className="flex-1 px-3 py-1 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 transition-colors text-sm font-medium"
                >
                  Edit
                </button>
                <button
                  onClick={() => handleDelete(product)}
                  className="flex-1 px-3 py-1 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors text-sm font-medium"
                >
                  Delete
                </button>
              </div>
            </div>
          ))}
        </div>

        {products.length === 0 && (
          <p className="text-center text-gray-500 py-10">No products yet. Click "Add Product" to get started!</p>
        )}
      </main>
    </div>
  )
}

Your admin panel is fully functional. You can add products with images, edit their details, and delete them. Next up, you will deploy everything to a live URL so anyone can visit your catalog.

Deploy to Vercel

Your product catalog works perfectly on your local machine. Now it is time to make it accessible to the world.

In this step, you will push your code to GitHub and deploy it to Vercel. Vercel connects directly to your GitHub repository and automatically rebuilds your site every time you push new code.

In this step, get ready to:
  • Push your project code to a GitHub repository.
  • Import your repository into Vercel and add environment variables.
  • Create an admin account and test the live deployment end-to-end.
Push your code to GitHub

Vercel deploys from a Git repository. You need to initialize a local Git repo, commit your files, and push them to a new GitHub repository.

  • Stop the dev server if it is still running by pressing Ctrl+C in your terminal.
  • Initialize a Git repository and commit all your files by running these commands:
git init
git add .
git commit -m "Initial commit"

What do these commands do?

  • git init creates a new Git repository in your project folder.
  • git add . stages all files for the commit. Your .gitignore file (created by Next.js) automatically excludes .env.local and node_modules so secrets and large folders stay out of your repository.
  • git commit -m "Initial commit" saves a snapshot of your code with a descriptive message.
  • Open your browser and go to github.com/new to create a new repository.
  • Enter product-catalog as the repository name.
  • Leave the repository set to Public.
  • Do not check any of the initialization options (no README, no .gitignore, no license).
  • Click Create repository.

GitHub shows you a set of commands for pushing an existing repository. Copy the commands from the section labeled "push an existing repository from the command line" and run them in your terminal. They will look similar to this:

git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/product-catalog.git
git branch -M main
git push -u origin main
  • Refresh your GitHub repository page. You should see all your project files listed.

Push failed or permission denied?

  • Make sure you are signed into GitHub in your browser and that the repository name matches what you created.
  • If you see a credentials error, you may need to set up a GitHub Personal Access Token or SSH key for authentication.

Help me push my code to GitHub

Import your repository into Vercel and deploy

Now that your code is on GitHub, Vercel can connect to it and deploy your site automatically.

  • Go to vercel.com/new in your browser.
  • Under the Import Git Repository section, find your product-catalog repository and click Import.

Don't see your repository?

If this is your first time connecting GitHub to Vercel, you will be prompted to install the Vercel app on your GitHub account. Follow the authorization prompts to grant access to your repositories.

  • On the configuration page, expand the Environment Variables section.
  • Add your first environment variable. In the Key field, enter NEXT_PUBLIC_SUPABASE_URL. In the Value field, paste your Supabase project URL (the same value from your .env.local file).
  • Add a second environment variable. In the Key field, enter NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY. In the Value field, paste your Supabase publishable key.
  • Click Deploy.

Vercel will build and deploy your project. This takes about 1-2 minutes. When it finishes, you will see a congratulations page with a link to your live site.

Why add environment variables here?

Your .env.local file is excluded from Git (by the .gitignore file). This keeps your keys out of your public repository. Vercel needs its own copy of these values to connect to Supabase at build time and runtime.

Deployment failed?

  • Check the build logs on the Vercel deployment page for specific error messages.
  • Make sure both environment variable keys are spelled exactly as shown, including the NEXT_PUBLIC_ prefix.
  • Confirm the values match what is in your .env.local file.

Help me fix my Vercel deployment error

Create an admin user and test the live site

Your site is live, but nobody can sign in yet. You need to create an admin user account through the Supabase dashboard so you can test the full flow on the deployed site.

  • Switch back to your Supabase dashboard.
  • In the left sidebar, click Authentication.
  • Click Add user in the top right area of the Users page.
  • Select Create new user.
  • Enter an email address you will remember (this will be your admin login).
  • Enter a strong password.
  • Make sure Auto Confirm User is checked so the account is immediately usable.
  • Click Create user.

Why create the user here instead of a signup page?

Your admin dashboard uses signInWithPassword but has no public signup form. This is intentional. Only you (the project owner) should be able to create admin accounts. Creating the user directly in Supabase ensures no one else can register as an admin.

  • Open your live Vercel URL (the .vercel.app link from the deployment page).
  • Confirm you see the public catalog page with the warm color scheme and dark mode toggle.
  • Navigate to your admin login by adding /admin/login to the end of your Vercel URL.
  • Sign in with the email and password you just created.
  • Click + Add Product in the admin dashboard.
  • Enter a product name, price, and upload an image.
  • Click Add Product to save it.
  • Open your public Vercel URL in a new tab (the root URL without /admin). You should see your new product displayed in the catalog grid.

Admin login not working on the live site?

  • Double check that you checked Auto Confirm User when creating the user. Without confirmation, the account cannot sign in.
  • Verify your Vercel environment variables are correct. If the Supabase URL or key is wrong, the login request will fail silently.
  • Try redeploying from the Vercel dashboard if you updated environment variables after the initial deploy.

Help me fix admin login on my deployed site

Secret mission

Add Search and Price Filter

Your catalog works great, but what happens when you have dozens of products? Visitors need a way to find what they're looking for. Add a real-time search bar and price range filter so users can narrow down the catalog instantly.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on free tiers, so there are no ongoing costs.

Resources you used:

  • Supabase project (products table, product-images storage bucket, admin user account).
  • Vercel deployment (live .vercel.app URL).
  • GitHub repository (project source code).
  • Local project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep your product catalog live.

  • Your Vercel deployment stays live at no cost on the Hobby plan.
  • Your Supabase project remains active as long as you visit the dashboard at least once a week. Free projects pause after 1 week of inactivity.
  • Your GitHub repository stays available for future edits.
  • Your local project folder stays on your Desktop for development.

Pause - I'll come back to this later

Shut down running processes to free up local memory. Your cloud resources will remain intact when you return.

  • Stop the local dev server by pressing Ctrl+C in your terminal.
  • Your Supabase free project will automatically pause after 1 week of inactivity. You can resume it later from the Supabase dashboard.
  • Your Vercel deployment stays live at no cost. No action needed.
  • Your GitHub repository and local folder remain unchanged.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Delete your Supabase project: Open the Supabase dashboard, select your project, go to Project Settings, scroll down to the General section, and click Delete project. This removes the products table, product-images bucket, and admin user account.
  • Delete your Vercel deployment: Go to vercel.com, open your project, click Settings, scroll to the bottom, and click Delete. This takes down the live URL.
  • Delete your GitHub repository: Go to your repository on GitHub, click Settings, scroll to the bottom under Danger Zone, and click Delete this repository.
  • Delete your local project folder by running the appropriate command for your system:

macOS

cd ~/Desktop
rm -rf product-catalog

Windows

cd ~\Desktop
rmdir /s /q product-catalog

Nice Work!

Nice Work!

You just built and deployed a full-stack product catalog website from scratch. Visitors can browse your products, and you can manage everything through a secure admin dashboard.

What you learned:

  • Built a Next.js full-stack application with a Supabase PostgreSQL database protected by Row Level Security for public reads and authenticated writes.
  • Implemented admin authentication with email and password using Supabase Auth, and built image upload functionality using Supabase Storage.
  • Deployed a live website to Vercel accessible via a public URL, with a Shopee-style responsive product grid and a dark mode toggle.
  • Secret Mission: Added real-time search and price-range filtering to the public catalog.

Ready to quiz yourself?