Add a Supabase Backend to Your Catalog

Connect a product catalog to a live PostgreSQL database with admin CRUD.

Introduction

30 Second Summary

Every online store needs a way to update its products without touching code. Hardcoding product data into HTML works for a demo, but it falls apart the moment you need to add, edit, or remove items.

In this project, you will connect your product catalog to a Supabase PostgreSQL database and build a secured admin panel with full CRUD (Create, Read, Update, Delete) functionality.

What You'll Build

A full-stack product catalog where product data lives in a Supabase database, with a role-secured admin dashboard for managing products through the browser.

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

  • A Supabase project with a products table and Row Level Security policies that control who can read and write data.
  • A public-facing product catalog that dynamically loads products from your database instead of hardcoded HTML.
  • An admin login page with email/password authentication and a CRUD dashboard for creating, editing, and deleting products.
  • Secret Mission: Add search and category filtering to your public catalog so visitors can find products instantly.

Are there any prerequisites?

This project builds on an existing product catalog deployed on Vercel. You should have basic HTML, CSS, and JavaScript knowledge.

You will also need VS Code installed and a browser like Chrome or Edge.

Before We Start

Before We Start...

Set Up Supabase

Your product catalog currently has all its product data hardcoded directly in the HTML. That works for a demo, but it means any change to your products requires editing code and redeploying.

In this step, you will set up Supabase as your project's database. Supabase gives you a full PostgreSQL database with a built-in API, so your catalog can pull product data dynamically instead of relying on static HTML.

In this step, get ready to:
  • Create a free Supabase account and project.
  • Create a products table in the SQL Editor.
  • Insert sample product data into your table.
Create a Supabase account and project

Supabase is a backend-as-a-service that gives you a PostgreSQL database, authentication, and auto-generated APIs. The free tier includes 500 MB of database storage and unlimited API requests.

  • Open your browser and go to database.new.
  • Sign up for a free account using your email or GitHub.
  • After signing in, Supabase will prompt you to create a new project. Enter a project name (e.g. product-catalog).
  • Choose a database password and save it somewhere safe. You will not need it again for this project, but it is good practice to keep it.
  • Select a region close to your location.
  • Click Create new project.
  • Wait for the database to finish launching. This usually takes about 30 seconds.

What is Supabase?

Supabase is an open-source alternative to Firebase built on top of PostgreSQL. It provides a database, authentication, and auto-generated REST APIs out of the box.

For this project, you will use the database to store products and the authentication system to protect your admin panel. The free tier is more than enough for a learning project.

Create the products table

Now that your project is ready, you need a table to store product data. You will use the SQL Editor to create it with the exact columns your catalog needs.

  • In the left sidebar of your Supabase dashboard, click SQL Editor.
  • Click New Query to open a blank editor.
  • Paste the following SQL to create your products table:
-- Create the products table with all required columns
create table products (
  id int8 primary key generated always as identity,
  name text not null,
  price numeric not null,
  category text not null,
  created_at timestamptz default now()
);
  • Click Run or press Cmd+Enter (macOS) / Ctrl+Enter (Windows) to execute the query.

What does this SQL do?

  • id int8 primary key generated always as identity creates an auto-incrementing unique identifier for each product.
  • name text not null stores the product name and ensures it cannot be empty.
  • price numeric not null stores decimal prices like 9.99.
  • category text not null groups products for filtering (e.g. Cleaning, Food).
  • created_at timestamptz default now() automatically records when each product was added.

You should see a "Success. No rows returned" message at the bottom of the editor. This confirms the table was created.

Seeing an error?

  • Make sure you copied the entire SQL block, including the semicolon at the end.
  • Check that your project has fully launched (the dashboard should not show a loading spinner).

Help me fix my SQL error in the Supabase SQL Editor.

Insert sample product data

Your table exists but is empty. Next you will populate it with the same products from your existing catalog so you can verify everything works when you connect the frontend later.

  • In the same SQL Editor tab, clear the previous query and paste the following:
-- Insert the sample products from your existing catalog
insert into products (name, price, category) values
  ('Herbal Shampoo', 9.99, 'Cleaning'),
  ('Fresh Detergent', 12.49, 'Cleaning'),
  ('Lemon Dish Soap', 4.99, 'Cleaning'),
  ('Margherita Pizza', 8.99, 'Food'),
  ('Dark Roast Coffee', 14.99, 'Food'),
  ('Penne Pasta', 3.49, 'Food');
  • Click Run or press Cmd+Enter (macOS) / Ctrl+Enter (Windows) to execute the insert.

What does this SQL do?

This inserts six products into your table. Three are in the Cleaning category (Herbal Shampoo, Fresh Detergent, Lemon Dish Soap) and three are in the Food category (Margherita Pizza, Dark Roast Coffee, Penne Pasta). These match the products already in your static HTML catalog.

  • Verify the data was inserted by running this query:
select * from products;

You should see a table with 6 rows showing all your products, their prices, categories, and timestamps.

  • Now locate your project URL and anon key. In the left sidebar, click Project Settings, then click API Keys.
  • Copy the Project URL and the anon key (under Legacy API Keys). You will need both in the next step when connecting your frontend.

Why is the anon key safe to use in browser code?

The anon key is designed to be public. It only grants access that your Row Level Security policies allow. Without policies, it cannot read or write any data. You will configure these policies in the next step.

Don't see 6 rows?

  • Make sure you ran the insert query before running the select query.
  • Check that you did not accidentally run the create table query again (that would cause an "already exists" error, not insert data).

Help me verify my products table has the correct data.

Your database is set up with a products table and sample data. Next, you will lock down access with Row Level Security so only authenticated admins can modify products.

Configure Security and Authentication

Your products table is live in Supabase, but right now there are no access controls in place. Anyone with your project URL and anon key could potentially modify data through the API.

In this step, you'll lock down the table using Row Level Security (RLS) policies. These policies define exactly who can read and who can write. You'll also create an admin user account so that only authenticated users can make changes.

In this step, get ready to:
  • Enable Row Level Security and create access policies on the products table.
  • Grant database-level privileges to the anon and authenticated roles.
  • Create an admin user account for authentication.
Enable Row Level Security and create access policies

RLS works like a bouncer for your database table. Once enabled, it blocks all access by default. You then create policies that selectively allow specific operations for specific roles.

  • Return to the SQL Editor in your Supabase dashboard (the same place you created your table in the previous step).
  • Click New query to open a fresh editor.
  • Set up your security policies by pasting the following SQL:
-- Enable Row Level Security on the products table
alter table "products" enable row level security;

-- Allow anyone (including non-logged-in visitors) to read products
create policy "Allow public read access" on products for select to anon using (true);

-- Allow logged-in users to add new products
create policy "Allow authenticated insert" on products for insert to authenticated with check (true);

-- Allow logged-in users to edit existing products
create policy "Allow authenticated update" on products for update to authenticated using (true) with check (true);

-- Allow logged-in users to delete products
create policy "Allow authenticated delete" on products for delete to authenticated using (true);

-- Grant database-level privileges to each role
grant select on products to anon;
grant select, insert, update, delete on products to authenticated;

What does this SQL do?

The first line enables RLS, which blocks all access to the table until you define explicit policies. Without this, your table is wide open.

The anon role represents unauthenticated visitors. The SELECT policy lets them read products freely. The authenticated role represents logged-in users. The INSERT, UPDATE, and DELETE policies let them modify products.

The GRANT statements work at a different layer. They give each role database-level permission to access the table at all. RLS policies then control which specific rows they can see or change.

  • Click Run to execute the query.
  • You should see a Success. No rows returned message at the bottom of the editor for each statement.

Why is the anon key safe to expose?

Your Supabase anon key is designed to be used in browser code. It identifies your project but does not grant unrestricted access. RLS policies are what actually control what each role can do. The key is safe to expose because the policies enforce the rules server-side.

  • Verify RLS is enabled by clicking Authentication in the left sidebar, then selecting Policies.
  • You should see the products table listed with your four new policies underneath it.

Don't see any policies listed?

Make sure you ran the entire SQL block in one go, not just part of it. If you see an error about the policy already existing, you may have run it twice. You can drop a policy with drop policy "policy_name" on products; and re-run the create statement.

Also confirm you're looking at the public schema in the Policies page, not a different schema.

Help me troubleshoot my RLS policies

Create an admin user

Your RLS policies reference the authenticated role, but no user account exists yet. You need to create one so you can log in to the admin panel later.

  • In the left sidebar, click Authentication.
  • Click the Users tab at the top (it should already be selected).
  • Click the Add user button in the top right area of the page.
  • Select Create new user.
  • Enter an email address you'll remember (this will be your admin login).
  • Enter a strong password (at least 6 characters).
  • Click Create user.

What is this user for?

This user account is your admin credential. When you sign in with this email and password from your app's login page, Supabase assigns the authenticated role to that session. Your RLS policies then allow that session to insert, update, and delete products.

  • After clicking Create user, you should see your new user appear in the users table with the email you entered.

User not appearing in the list?

Try refreshing the page. If you see a database error, check that you haven't added any custom constraints to the auth.users table. Make sure the password meets the minimum length requirement (6 characters).

Help me fix my user creation issue

Your database is now secured with proper access controls. Anyone can browse products, but only your admin account can make changes. Next up, you'll connect your public product catalog page to fetch data directly from Supabase.

Connect the Public Catalog to Supabase

Your products table is secured with RLS policies and loaded with sample data. Now it's time to connect your front-end catalog to this live database so products load dynamically instead of being hardcoded in HTML.

You'll load the Supabase JavaScript client via a CDN script tag and write a small app.js file that queries your database directly from the browser. Your RLS policies ensure only SELECT queries succeed for anonymous visitors.

In this step, get ready to:
  • Add the Supabase client library to your HTML via CDN.
  • Initialize the Supabase client in a new JavaScript file.
  • Fetch and render products dynamically with category filtering.
Update your HTML to load the Supabase client

Your existing index.html has product cards hardcoded directly in the HTML. You'll replace those with an empty container that JavaScript fills at runtime, and add script tags to load the Supabase library.

  • Switch back to VS Code.
  • Open index.html and replace the entire file contents with this code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Product Catalog</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <h1>Product Catalog</h1>
        <!-- Category filter buttons -->
        <div class="filters">
            <button class="filter-btn active" data-category="All">All</button>
            <button class="filter-btn" data-category="Cleaning">Cleaning</button>
            <button class="filter-btn" data-category="Food">Food</button>
        </div>
        <!-- Empty grid that JavaScript will populate -->
        <div class="product-grid" id="product-grid">
            <!-- Products loaded dynamically -->
        </div>
    </div>
    <!-- Load the Supabase JS client from CDN -->
    <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
    <!-- Load your application code -->
    <script src="app.js"></script>
</body>
</html>

What changed from the original file?

  • The hardcoded product card HTML is gone. The product-grid div is now empty because JavaScript will fill it with live data from your database.
  • The first <script> tag loads the Supabase JS client from a CDN. This gives your page access to window.supabase without needing a build tool.
  • The second <script> tag loads app.js, which you'll create next.
  • Save index.html.

Why a CDN instead of npm?

Your project uses plain HTML, CSS, and JavaScript with no build tools like Webpack or Vite. The CDN script tag loads the Supabase library directly in the browser, keeping your setup simple.

✔️ Awesome, I've got everything!

Great. Make sure you've saved index.html before moving on.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Product Catalog</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <h1>Product Catalog</h1>
        <div class="filters">
            <button class="filter-btn active" data-category="All">All</button>
            <button class="filter-btn" data-category="Cleaning">Cleaning</button>
            <button class="filter-btn" data-category="Food">Food</button>
        </div>
        <div class="product-grid" id="product-grid">
            <!-- Products loaded dynamically -->
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
    <script src="app.js"></script>
</body>
</html>
Create app.js and initialize the Supabase client

Every request to your Supabase database needs a client object that knows your project URL and public API key. You'll create a new file that sets this up.

  • In your Supabase dashboard, go to **Project Settings**, then select **API**.
  • Copy your Project URL and anon (public) key. Keep these handy.
  • Back in VS Code, create a new file called app.js in the same folder as index.html.
  • Add this initialization code to app.js:
// Initialize the Supabase client with your project credentials
const supabaseUrl = 'your-supabase-url-here';
const supabaseKey = 'your-anon-key-here';
const supabase = window.supabase.createClient(supabaseUrl, supabaseKey);

// Track loaded products and the currently active filter
let allProducts = [];
let activeCategory = 'All';
  • Replace 'your-supabase-url-here' with your actual Project URL from the Supabase dashboard.
  • Replace 'your-anon-key-here' with your actual anon key from the Supabase dashboard.

What does this code do?

  • window.supabase.createClient() creates a client object that handles all communication with your Supabase project.
  • The anon key is safe to include in browser code because your RLS policies control what each role can actually do. Without a matching policy, queries are blocked.
  • allProducts stores all fetched products so you can filter without re-querying the database.
  • activeCategory tracks which filter button is selected.

You won't see a change yet. The next chunk adds the functions that actually fetch and display products.

Not sure where to find your URL and key?

In your Supabase dashboard, click the gear icon on the left sidebar to open Project Settings. Then click API in the left menu. Your Project URL and anon key are displayed at the top of that page.

help me find my Supabase project URL and anon key

Fetch and render products dynamically

With the client ready, you need two functions. One fetches products from the database. The other renders them as HTML cards inside the grid.

  • Add the loadProducts and renderProducts functions below your initialization code in app.js:
// Fetch all products from the database, ordered by creation date
async function loadProducts() {
    const { data, error } = await supabase
        .from('products')
        .select('*')
        .order('created_at', { ascending: true });

    if (error) {
        console.error('Error loading products:', error);
        return;
    }

    allProducts = data;
    renderProducts();
}

// Render product cards into the grid, applying the active category filter
function renderProducts() {
    const grid = document.getElementById('product-grid');
    let filtered = allProducts;

    if (activeCategory !== 'All') {
        filtered = filtered.filter(p => p.category === activeCategory);
    }

    grid.innerHTML = filtered.map(product => `
        <div class="product-card">
            <h2>${product.name}</h2>
            <p class="price">$${Number(product.price).toFixed(2)}</p>
        </div>
    `).join('');
}

What does this code do?

  • loadProducts() queries your products table using the Supabase client. The .order('created_at', { ascending: true }) sorts products by when they were added.
  • If the query fails, the error is logged to the console and the function exits early.
  • renderProducts() takes the stored products, filters them by the active category, and builds HTML cards using template literals.
  • The Number(product.price).toFixed(2) ensures prices always show two decimal places (e.g., $4.50 instead of $4.5).

You won't see products yet because nothing calls these functions. The next chunk wires up the filter buttons and triggers the initial load.

  • Add the event listeners and the initial loadProducts() call at the bottom of app.js:
// Attach click listeners to each category filter button
document.querySelectorAll('.filter-btn').forEach(btn => {
    btn.addEventListener('click', () => {
        document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        activeCategory = btn.dataset.category;
        renderProducts();
    });
});

// Kick off the initial product load when the page opens
loadProducts();

What does this code do?

  • Each filter button gets a click listener that removes the active class from all buttons, adds it to the clicked one, updates activeCategory, and re-renders the product grid.
  • The final loadProducts() call runs as soon as the page loads, fetching products from Supabase and displaying them immediately.
  • Save app.js.
  • Open index.html in your browser (or refresh if it's already open).
  • You should see your product cards rendered on the page with data pulled live from Supabase.
  • Click the Cleaning and Food filter buttons to confirm filtering works.

Products not showing up?

  • Open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). Look for error messages about the Supabase URL or key being invalid.
  • Confirm you replaced 'your-supabase-url-here' and 'your-anon-key-here' with your actual values from the Supabase dashboard.
  • Make sure app.js is in the same folder as index.html. The browser needs to find it at the relative path.
  • Check that your RLS policy for anon SELECT is enabled. Without it, the query returns no data.

help me debug why my products aren't loading from Supabase

✔️ Awesome, I've got everything!

Great. Make sure you've saved app.js and confirmed products display in your browser.

ⓧ I'd like to double check the full code

const supabaseUrl = 'your-supabase-url-here';
const supabaseKey = 'your-anon-key-here';
const supabase = window.supabase.createClient(supabaseUrl, supabaseKey);

let allProducts = [];
let activeCategory = 'All';

async function loadProducts() {
    const { data, error } = await supabase
        .from('products')
        .select('*')
        .order('created_at', { ascending: true });

    if (error) {
        console.error('Error loading products:', error);
        return;
    }

    allProducts = data;
    renderProducts();
}

function renderProducts() {
    const grid = document.getElementById('product-grid');
    let filtered = allProducts;

    if (activeCategory !== 'All') {
        filtered = filtered.filter(p => p.category === activeCategory);
    }

    grid.innerHTML = filtered.map(product => `
        <div class="product-card">
            <h2>${product.name}</h2>
            <p class="price">$${Number(product.price).toFixed(2)}</p>
        </div>
    `).join('');
}

document.querySelectorAll('.filter-btn').forEach(btn => {
    btn.addEventListener('click', () => {
        document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
        btn.classList.add('active');
        activeCategory = btn.dataset.category;
        renderProducts();
    });
});

loadProducts();

Your public catalog is now powered by a live database. Next up, you'll build an admin login page so only authorized users can add, edit, or delete products.

Build the Admin Login Page

Your public catalog is pulling products straight from Supabase. Visitors can browse freely, but there's no way for you to manage those products from a browser yet.

In this step, you'll build a dedicated login page that gates access to an admin dashboard. Only users who authenticate with valid credentials will get through.

In this step, get ready to:
  • Create an admin-login.html page with an email/password login form.
  • Write authentication logic using Supabase's signInWithPassword method.
  • Add a session check that auto-redirects logged-in users to the dashboard.
Create the admin login HTML page

The login page follows the same structure as your index.html. It loads the Supabase CDN script, links to your shared stylesheet, and includes a simple form with email and password fields.

  • In VS Code, create a new file called admin-login.html in the same folder as your index.html.
  • Add the following code to admin-login.html:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Admin Login</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <h1>Admin Login</h1>
        <form id="login-form" class="admin-form">
            <div class="form-group">
                <label for="email">Email</label>
                <input type="email" id="email" required>
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <input type="password" id="password" required>
            </div>
            <button type="submit" class="btn-primary">Login</button>
            <p id="error-message" class="error-message"></p>
        </form>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
    <script src="admin-login.js"></script>
</body>
</html>

What does this code do?

  • The form uses id="login-form" so JavaScript can attach a submit handler to it later.
  • Each input has a type attribute (email and password) that enables built-in browser validation.
  • The error-message paragraph is empty by default. JavaScript fills it if login fails.
  • The Supabase CDN script and admin-login.js load at the bottom, following the same pattern as your public catalog page.
  • Save admin-login.html.
  • Open admin-login.html in your browser. You should see a centered login form with Email and Password fields and a Login button.

Don't see the form styled correctly?

  • Make sure style.css is in the same folder as admin-login.html.
  • Check that the <link rel="stylesheet" href="style.css"> tag is inside the <head> element.
  • help me debug why my admin login page isn't styled

✔️ Awesome, I've got everything!

Great. Make sure you've saved admin-login.html before moving on.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Admin Login</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <h1>Admin Login</h1>
        <form id="login-form" class="admin-form">
            <div class="form-group">
                <label for="email">Email</label>
                <input type="email" id="email" required>
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <input type="password" id="password" required>
            </div>
            <button type="submit" class="btn-primary">Login</button>
            <p id="error-message" class="error-message"></p>
        </form>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
    <script src="admin-login.js"></script>
</body>
</html>
Write the authentication logic

The login page needs JavaScript to handle form submissions, verify credentials with Supabase Auth, and redirect users on success or show errors on failure.

  • Create a new file called admin-login.js in the same folder and add the Supabase client initialization and session check:
const supabaseUrl = 'your-supabase-url-here';
const supabaseKey = 'your-anon-key-here';
const supabase = window.supabase.createClient(supabaseUrl, supabaseKey);

// Check if the user is already logged in
async function checkSession() {
    const { data: { session } } = await supabase.auth.getSession();
    if (session) {
        window.location.href = 'admin.html';
    }
}

What does this code do?

  • The first three lines initialize the Supabase client, identical to app.js. Replace the placeholder strings with your project URL and anon key from Project Settings > API in the Supabase dashboard.
  • getSession() checks if the browser already has an active login session stored. If it finds one, the page immediately redirects to admin.html so the user skips the login form entirely.
  • Save admin-login.js.
  • You should see admin-login.js appear in the VS Code Explorer sidebar alongside your other files.

File not appearing in the sidebar?

  • Make sure you created the file in the same folder as index.html and app.js.
  • Check that the filename is exactly admin-login.js with no extra spaces or different capitalization.
  • help me find where my admin-login.js file went
  • Below the checkSession function in admin-login.js, add the login form handler and the session check call:
// Handle the login form submission
document.getElementById('login-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const email = document.getElementById('email').value;
    const password = document.getElementById('password').value;
    const errorMessage = document.getElementById('error-message');

    // Attempt to sign in with Supabase Auth
    const { data, error } = await supabase.auth.signInWithPassword({
        email: email,
        password: password
    });

    if (error) {
        // Display the error message from Supabase
        errorMessage.textContent = error.message;
    } else {
        // Redirect to the admin dashboard on success
        window.location.href = 'admin.html';
    }
});

// Run session check when the page loads
checkSession();

What does this code do?

  • e.preventDefault() stops the form from doing a traditional page reload on submit.
  • The handler grabs the email and password values from the form inputs, then sends them to signInWithPassword.
  • If Supabase returns an error (wrong credentials, non-existent user), the error message displays below the form. If login succeeds, the browser redirects to admin.html.
  • The final checkSession() call runs immediately when the page loads. If the user already has an active session, they skip the form and go straight to the dashboard.
  • Save admin-login.js.
  • Refresh admin-login.html in your browser and enter the admin email and password you created in the Supabase Authentication dashboard.
  • Click Login. The page will attempt to redirect to admin.html. Since that page doesn't exist yet, you'll see a 404 error. That confirms your login logic is working.

Login not working?

  • Double-check that your supabaseUrl and supabaseKey values match the ones in your Supabase dashboard under Project Settings > API.
  • Make sure the email and password match the admin user you created in the Authentication section of your Supabase dashboard.
  • Open the browser console (F12 > Console) to check for JavaScript errors.
  • help me debug why my Supabase login form isn't authenticating

Why did I get a 404?

A 404 error after clicking Login is a good sign. It means signInWithPassword succeeded and the code tried to redirect to admin.html. You'll build that page in the next step.

✔️ Awesome, I've got everything!

Great. Make sure you've saved admin-login.js before moving on.

ⓧ I'd like to double check the full code

const supabaseUrl = 'your-supabase-url-here';
const supabaseKey = 'your-anon-key-here';
const supabase = window.supabase.createClient(supabaseUrl, supabaseKey);

async function checkSession() {
    const { data: { session } } = await supabase.auth.getSession();
    if (session) {
        window.location.href = 'admin.html';
    }
}

document.getElementById('login-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const email = document.getElementById('email').value;
    const password = document.getElementById('password').value;
    const errorMessage = document.getElementById('error-message');

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

    if (error) {
        errorMessage.textContent = error.message;
    } else {
        window.location.href = 'admin.html';
    }
});

checkSession();

Your login gate is in place. Next, you'll build the admin dashboard that authenticated users land on after logging in.

Build the Admin CRUD Dashboard

Your login page is working and authenticated users can sign in. Now it is time to build the page those users actually land on.

The admin dashboard is where product management happens. It needs to display all products, let admins add new ones, edit existing entries, and delete products they no longer want. Each of those actions maps to a CRUD operation (Create, Read, Update, Delete) against your Supabase database.

In this step, get ready to:
  • Create the admin dashboard HTML with a product form and product list.
  • Write JavaScript that authenticates, loads products, and handles add, edit, and delete operations.
  • Style the admin dashboard components.
Create the admin dashboard page

The dashboard needs a form for entering product details and a list that shows every product with action buttons. You will also include a logout button so admins can end their session.

  • In VS Code, create a new file called admin.html in the same folder as your other project files.
  • Add the full admin dashboard markup by pasting this code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Admin Dashboard</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <!-- Header with title and logout -->
        <div class="admin-header">
            <h1>Admin Dashboard</h1>
            <button id="logout-btn" class="btn-secondary">Logout</button>
        </div>
        <!-- Product form for adding/editing -->
        <form id="product-form" class="admin-form">
            <h2 id="form-title">Add Product</h2>
            <input type="hidden" id="product-id">
            <div class="form-group">
                <label for="product-name">Name</label>
                <input type="text" id="product-name" required>
            </div>
            <div class="form-group">
                <label for="product-price">Price</label>
                <input type="number" id="product-price" step="0.01" required>
            </div>
            <div class="form-group">
                <label for="product-category">Category</label>
                <select id="product-category" required>
                    <option value="Cleaning">Cleaning</option>
                    <option value="Food">Food</option>
                </select>
            </div>
            <div class="form-actions">
                <button type="submit" class="btn-primary">Save Product</button>
                <button type="button" id="cancel-btn" class="btn-secondary" style="display:none;">Cancel</button>
            </div>
        </form>
        <!-- Product list rendered by JavaScript -->
        <h2>Products</h2>
        <div id="admin-product-list" class="admin-product-list">
            <!-- Products loaded dynamically -->
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
    <script src="admin.js"></script>
</body>
</html>

What does this page contain?

  • An admin-header div with the page title and a logout button.
  • A product-form that doubles as both the "add" and "edit" form. A hidden product-id field tracks which product is being edited.
  • A form-title heading that switches between "Add Product" and "Edit Product" depending on the current action.
  • A cancel-btn that is hidden by default and only appears when editing, letting the admin back out of an edit.
  • An admin-product-list div where JavaScript will render each product with Edit and Delete buttons.
  • Save admin.html.

You will not see much yet since the page has no JavaScript or admin-specific styles. The next chunks handle that.

Now add the styles that the admin dashboard needs. These go at the bottom of your existing style.css file.

  • Open style.css in VS Code.
  • Scroll to the very bottom of the file and add these styles:
/* Admin dashboard header */
.admin-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20px;
}

.admin-header h1 {
    text-align: left;
}

/* Form action buttons row */
.form-actions {
    display: flex;
    gap: 10px;
}

/* Product list layout */
.admin-product-list {
    display: flex;
    flex-direction: column;
    gap: 12px;
}

.admin-product-item {
    background: white;
    padding: 16px;
    border-radius: 8px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}

.product-info {
    color: #333;
}

.product-actions {
    display: flex;
    gap: 8px;
}

/* Edit button */
.btn-edit {
    padding: 6px 14px;
    background: #3498db;
    color: white;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    font-size: 13px;
}

.btn-edit:hover {
    background: #2980b9;
}

/* Delete button */
.btn-delete {
    padding: 6px 14px;
    background: #e74c3c;
    color: white;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    font-size: 13px;
}

.btn-delete:hover {
    background: #c0392b;
}

What do these styles do?

The .admin-header uses flexbox to push the title left and the logout button right. The .admin-product-item class creates a card for each product row with action buttons aligned to the right.

The blue .btn-edit and red .btn-delete classes give clear visual signals for each action.

  • Save style.css.
  • Open admin.html directly in your browser. You should see the form with Name, Price, and Category fields, a Save Product button, and a "Products" heading below.

Page looks unstyled?

Make sure style.css is saved and is in the same folder as admin.html. Check that the <link rel="stylesheet" href="style.css"> tag is present in the <head> section.

Help me figure out why my admin page styles are not loading.

Add authentication and product loading

The admin page must only be accessible to logged-in users. If someone visits admin.html without a session, they should be redirected to the login page automatically.

  • Create a new file called admin.js in the same folder.
  • Add the Supabase client initialization, auth check, and product loading logic by pasting this code:
const supabaseUrl = 'your-supabase-url-here';
const supabaseKey = 'your-anon-key-here';
const supabase = window.supabase.createClient(supabaseUrl, supabaseKey);

// Track which product is currently being edited (null = adding new)
let editingId = null;

// Redirect to login if no active session exists
async function checkAuth() {
    const { data: { session } } = await supabase.auth.getSession();
    if (!session) {
        window.location.href = 'admin-login.html';
    }
}

// Fetch all products and render them with action buttons
async function loadProducts() {
    const { data, error } = await supabase
        .from('products')
        .select('*')
        .order('created_at', { ascending: true });

    if (error) {
        console.error('Error loading products:', error);
        return;
    }

    const list = document.getElementById('admin-product-list');
    list.innerHTML = data.map(product => `
        <div class="admin-product-item">
            <div class="product-info">
                <strong>${product.name}</strong> - $${Number(product.price).toFixed(2)} (${product.category})
            </div>
            <div class="product-actions">
                <button class="btn-edit" onclick="editProduct(${product.id}, '${product.name}', ${product.price}, '${product.category}')">Edit</button>
                <button class="btn-delete" onclick="deleteProduct(${product.id}, '${product.name}')">Delete</button>
            </div>
        </div>
    `).join('');
}

What does this code do?

  • The editingId variable tracks whether the form is in "add" mode (null) or "edit" mode (holds the product's ID).
  • The checkAuth() function uses getSession() to verify the user is logged in. If not, it redirects them to the login page.
  • The loadProducts() function queries all rows from the products table and renders each one as a card with Edit and Delete buttons wired to JavaScript functions.
  • Replace your-supabase-url-here and your-anon-key-here with the same project URL and anon key you used in app.js and admin-login.js.
  • Save admin.js.

You will not see the product list yet because the checkAuth() and loadProducts() calls have not been added to the bottom of the file. The next chunk completes the logic.

Not sure where to find your URL and key?

Return to the Supabase dashboard. Click **Settings** in the left sidebar, then **API**. Your Project URL and anon key are listed at the top of the page.

Help me find my Supabase project URL and anon key.

Add CRUD operations and logout

The dashboard can display products but cannot change them yet. This final piece adds the form submission handler (which handles both creating and updating), the edit and delete functions, a form reset utility, and the logout button.

  • In admin.js, add the form submission handler below the loadProducts function:
// Handle form submit for both adding and editing products
document.getElementById('product-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const name = document.getElementById('product-name').value;
    const price = parseFloat(document.getElementById('product-price').value);
    const category = document.getElementById('product-category').value;

    if (editingId) {
        // Update existing product
        const { error } = await supabase
            .from('products')
            .update({ name, price, category })
            .eq('id', editingId);

        if (error) {
            alert('Error updating product: ' + error.message);
            return;
        }
    } else {
        // Insert new product
        const { error } = await supabase
            .from('products')
            .insert([{ name, price, category }]);

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

    resetForm();
    loadProducts();
});

How does one form handle both add and edit?

The handler checks editingId. If it is null, the form is in "add" mode and calls .insert(). If editingId holds a product ID, it calls .update() with an .eq('id', editingId) filter to target that specific row.

After a successful operation, resetForm() clears the inputs and loadProducts() refreshes the list so the learner sees the change immediately.

  • Below the form handler, add the edit, delete, reset, and logout functions plus the initialization calls:
// Pre-fill the form with existing product data for editing
function editProduct(id, name, price, category) {
    editingId = id;
    document.getElementById('product-id').value = id;
    document.getElementById('product-name').value = name;
    document.getElementById('product-price').value = price;
    document.getElementById('product-category').value = category;
    document.getElementById('form-title').textContent = 'Edit Product';
    document.getElementById('cancel-btn').style.display = 'inline-block';
}

// Delete a product after user confirms
async function deleteProduct(id, name) {
    if (!confirm(`Are you sure you want to delete "${name}"?`)) return;

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

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

    loadProducts();
}

// Reset form back to "Add Product" mode
function resetForm() {
    editingId = null;
    document.getElementById('product-form').reset();
    document.getElementById('product-id').value = '';
    document.getElementById('form-title').textContent = 'Add Product';
    document.getElementById('cancel-btn').style.display = 'none';
}

// Wire up the cancel button to reset the form
document.getElementById('cancel-btn').addEventListener('click', resetForm);

// Wire up the logout button to end the session
document.getElementById('logout-btn').addEventListener('click', async () => {
    await supabase.auth.signOut();
    window.location.href = 'admin-login.html';
});

// Run auth check and load products on page load
checkAuth();
loadProducts();

What do these functions do?

  • editProduct() sets editingId and fills the form fields with the selected product's data. It also shows the Cancel button and changes the form title to "Edit Product".
  • deleteProduct() shows a browser confirmation dialog. If the user confirms, it calls .delete().eq('id', id) and refreshes the list.
  • resetForm() clears all fields, hides Cancel, resets the title to "Add Product", and sets editingId back to null.
  • The final two lines call checkAuth() and loadProducts() immediately when the page loads, ensuring only authenticated users see product data.
  • Save admin.js.
  • Open admin-login.html in your browser and log in with the admin email and password you created in Supabase Auth.
  • After logging in, you should be redirected to the admin dashboard and see your sample products listed with Edit and Delete buttons.

Try adding a new product using the form. Enter a name, price, and category, then click **Save Product**. The product should appear in the list below.

Products not appearing after login?

Check your browser console for errors. A common issue is mismatched Supabase URL or anon key values between admin.js and your other files. All three JS files must use the exact same URL and key.

If you see a Row Level Security error, confirm your RLS policies from Step 2 are still active. The authenticated role needs SELECT, INSERT, UPDATE, and DELETE policies on the products table.

Help me debug why my admin dashboard is not loading products.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: admin.html, admin.js, and style.css.

ⓧ I'd like to double check the full code

Here is the complete admin.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Admin Dashboard</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <div class="admin-header">
            <h1>Admin Dashboard</h1>
            <button id="logout-btn" class="btn-secondary">Logout</button>
        </div>
        <form id="product-form" class="admin-form">
            <h2 id="form-title">Add Product</h2>
            <input type="hidden" id="product-id">
            <div class="form-group">
                <label for="product-name">Name</label>
                <input type="text" id="product-name" required>
            </div>
            <div class="form-group">
                <label for="product-price">Price</label>
                <input type="number" id="product-price" step="0.01" required>
            </div>
            <div class="form-group">
                <label for="product-category">Category</label>
                <select id="product-category" required>
                    <option value="Cleaning">Cleaning</option>
                    <option value="Food">Food</option>
                </select>
            </div>
            <div class="form-actions">
                <button type="submit" class="btn-primary">Save Product</button>
                <button type="button" id="cancel-btn" class="btn-secondary" style="display:none;">Cancel</button>
            </div>
        </form>
        <h2>Products</h2>
        <div id="admin-product-list" class="admin-product-list">
            <!-- Products loaded dynamically -->
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
    <script src="admin.js"></script>
</body>
</html>

Here is the complete admin.js:

const supabaseUrl = 'your-supabase-url-here';
const supabaseKey = 'your-anon-key-here';
const supabase = window.supabase.createClient(supabaseUrl, supabaseKey);

let editingId = null;

async function checkAuth() {
    const { data: { session } } = await supabase.auth.getSession();
    if (!session) {
        window.location.href = 'admin-login.html';
    }
}

async function loadProducts() {
    const { data, error } = await supabase
        .from('products')
        .select('*')
        .order('created_at', { ascending: true });

    if (error) {
        console.error('Error loading products:', error);
        return;
    }

    const list = document.getElementById('admin-product-list');
    list.innerHTML = data.map(product => `
        <div class="admin-product-item">
            <div class="product-info">
                <strong>${product.name}</strong> - $${Number(product.price).toFixed(2)} (${product.category})
            </div>
            <div class="product-actions">
                <button class="btn-edit" onclick="editProduct(${product.id}, '${product.name}', ${product.price}, '${product.category}')">Edit</button>
                <button class="btn-delete" onclick="deleteProduct(${product.id}, '${product.name}')">Delete</button>
            </div>
        </div>
    `).join('');
}

document.getElementById('product-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const name = document.getElementById('product-name').value;
    const price = parseFloat(document.getElementById('product-price').value);
    const category = document.getElementById('product-category').value;

    if (editingId) {
        const { error } = await supabase
            .from('products')
            .update({ name, price, category })
            .eq('id', editingId);

        if (error) {
            alert('Error updating product: ' + error.message);
            return;
        }
    } else {
        const { error } = await supabase
            .from('products')
            .insert([{ name, price, category }]);

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

    resetForm();
    loadProducts();
});

function editProduct(id, name, price, category) {
    editingId = id;
    document.getElementById('product-id').value = id;
    document.getElementById('product-name').value = name;
    document.getElementById('product-price').value = price;
    document.getElementById('product-category').value = category;
    document.getElementById('form-title').textContent = 'Edit Product';
    document.getElementById('cancel-btn').style.display = 'inline-block';
}

async function deleteProduct(id, name) {
    if (!confirm(`Are you sure you want to delete "${name}"?`)) return;

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

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

    loadProducts();
}

function resetForm() {
    editingId = null;
    document.getElementById('product-form').reset();
    document.getElementById('product-id').value = '';
    document.getElementById('form-title').textContent = 'Add Product';
    document.getElementById('cancel-btn').style.display = 'none';
}

document.getElementById('cancel-btn').addEventListener('click', resetForm);

document.getElementById('logout-btn').addEventListener('click', async () => {
    await supabase.auth.signOut();
    window.location.href = 'admin-login.html';
});

checkAuth();
loadProducts();

Your admin dashboard is fully operational. You can add products, edit existing ones, delete items, and log out. All changes persist in your Supabase database and immediately appear on the public catalog too.

Secret mission

Add Search and Filtering to the Public Catalog

Your product catalog works great with category buttons, but what happens when a customer knows exactly what they want? Add a real-time search bar that works together with category filtering so visitors can find products instantly.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • Supabase project (includes the products table, RLS policies, and admin user).
  • Local project files (index.html, app.js, admin-login.html, admin-login.js, admin.html, admin.js, style.css).

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend your product catalog further.

  • Your Supabase project stays active on the free tier at no cost.
  • Free projects pause automatically after 1 week of inactivity. You can unpause them anytime from the Supabase dashboard.
  • Your local project files remain on your machine ready for future edits.

Pause - I'll come back to this later

Free up resources while keeping everything recoverable. Your Supabase project will pause on its own after 1 week of inactivity.

  • You do not need to do anything manually. Supabase free-tier projects pause automatically after 1 week without activity.
  • When you are ready to return, open the Supabase dashboard and click Restore project to bring it back online.
  • Your local files stay on disk. No cleanup required.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent and cannot be undone.

  • Open the Supabase dashboard and select your project.
  • Click Project Settings in the left sidebar.
  • Click General.
  • Scroll down and click Delete project. Confirm the deletion when prompted.

This is permanent

Deleting your Supabase project permanently removes all data, including the products table, RLS policies, authentication users, and any backups. You cannot undo this action.

  • To remove your local project files, delete the project folder from your machine using Finder (macOS) or File Explorer (Windows).

Nice Work!

Nice Work!

You just transformed a static product catalog into a full-stack application backed by a real database with authentication and role-based access control.

You've learned how to:

  • Connect a static product catalog to a live PostgreSQL database using Supabase.
  • Implement Row Level Security policies for role-based access control (public read, admin write).
  • Build email/password authentication and a full CRUD admin dashboard to create, read, update, and delete products.
  • Secret Mission: Added real-time search and category filtering that work together.

Ready to quiz yourself?