# How to Build a Customer Portal with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a branded customer self-service portal in Lovable where clients log in with magic links to view their orders, invoices, support tickets, and shared documents — all scoped to their account. Supabase Auth handles authentication, multi-tenant RLS keeps data isolated per customer, and an admin dashboard lets you manage all accounts from one place.

## Before you start

- A Lovable account (Pro plan recommended for multi-table setups)
- A Supabase project created with URL and anon key ready
- A Lovable project connected to Supabase via Cloud tab
- Your existing customer data structure (what orders/invoices look like) in mind
- A custom domain for the portal for a professional experience (Lovable Pro)

## Step-by-step guide

### 1. Set up the multi-tenant database schema

Create tables for customers, orders, invoices, tickets, and documents. The key design decision is that every table references a customer_id, and RLS policies on every table ensure users only see their own customer's data. A profiles table links auth users to their customer account.

```
-- Run in Supabase SQL Editor

CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled');
CREATE TYPE invoice_status AS ENUM ('draft', 'sent', 'paid', 'overdue', 'cancelled');
CREATE TYPE ticket_status AS ENUM ('open', 'in_progress', 'waiting_on_customer', 'resolved', 'closed');
CREATE TYPE ticket_priority AS ENUM ('low', 'medium', 'high', 'urgent');

CREATE TABLE customers (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  company_name TEXT NOT NULL,
  contact_name TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL,
  phone TEXT,
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE customer_users (
  user_id UUID REFERENCES auth.users PRIMARY KEY,
  customer_id UUID REFERENCES customers(id) NOT NULL,
  is_admin BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE orders (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  customer_id UUID REFERENCES customers(id) NOT NULL,
  order_number TEXT UNIQUE NOT NULL,
  status order_status DEFAULT 'pending',
  total_amount DECIMAL(12,2) NOT NULL,
  items JSONB DEFAULT '[]',
  notes TEXT,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE invoices (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  customer_id UUID REFERENCES customers(id) NOT NULL,
  invoice_number TEXT UNIQUE NOT NULL,
  status invoice_status DEFAULT 'draft',
  amount DECIMAL(12,2) NOT NULL,
  due_date DATE,
  paid_at TIMESTAMPTZ,
  pdf_url TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE tickets (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  customer_id UUID REFERENCES customers(id) NOT NULL,
  created_by UUID REFERENCES auth.users NOT NULL,
  subject TEXT NOT NULL,
  status ticket_status DEFAULT 'open',
  priority ticket_priority DEFAULT 'medium',
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE ticket_messages (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  ticket_id UUID REFERENCES tickets(id) ON DELETE CASCADE NOT NULL,
  author_id UUID REFERENCES auth.users NOT NULL,
  message TEXT NOT NULL,
  is_staff BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE customer_documents (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  customer_id UUID REFERENCES customers(id) NOT NULL,
  name TEXT NOT NULL,
  storage_path TEXT NOT NULL,
  file_type TEXT NOT NULL,
  file_size INTEGER,
  uploaded_by_staff BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Enable RLS on all tables
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE customer_users ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;
ALTER TABLE ticket_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE customer_documents ENABLE ROW LEVEL SECURITY;

-- Helper function to get current user's customer_id
CREATE OR REPLACE FUNCTION get_my_customer_id()
RETURNS UUID LANGUAGE sql STABLE SECURITY DEFINER AS $$
  SELECT customer_id FROM customer_users WHERE user_id = auth.uid() LIMIT 1;
$$;

-- RLS policies using helper function
CREATE POLICY "Customers see own profile" ON customers FOR SELECT USING (id = get_my_customer_id());
CREATE POLICY "Customers see own orders" ON orders FOR SELECT USING (customer_id = get_my_customer_id());
CREATE POLICY "Customers see own invoices" ON invoices FOR SELECT USING (customer_id = get_my_customer_id());
CREATE POLICY "Customers manage own tickets" ON tickets FOR ALL USING (customer_id = get_my_customer_id());
CREATE POLICY "Ticket participants see messages" ON ticket_messages FOR SELECT USING (
  EXISTS (SELECT 1 FROM tickets WHERE id = ticket_id AND customer_id = get_my_customer_id())
);
CREATE POLICY "Customers insert messages" ON ticket_messages FOR INSERT WITH CHECK (
  EXISTS (SELECT 1 FROM tickets WHERE id = ticket_id AND customer_id = get_my_customer_id())
);
CREATE POLICY "Customers see own documents" ON customer_documents FOR SELECT USING (customer_id = get_my_customer_id());
```

> Pro tip: The get_my_customer_id() function is key — it centralizes the lookup and makes policies readable. Without it, every policy would need a subquery on customer_users, which is harder to maintain and debug.

**Expected result:** All tables created in Supabase with RLS enabled. The SQL function get_my_customer_id() appears in the Functions section of the Supabase Dashboard under Database.

### 2. Configure magic link authentication and portal routing

Set up Supabase Auth for magic links. In Supabase Dashboard, configure the magic link email template and redirect URL. In Lovable, build the login page with just an email input, and set up the route guard that redirects to the portal after authentication.

```
Build the customer portal authentication flow:

1. Login page at /portal/login:
   - Centered card with company logo placeholder (img tag with /logo.png)
   - Email input with label "Enter your email to receive a sign-in link"
   - "Send Magic Link" button (shadcn Button)
   - On submit: call supabase.auth.signInWithOtp({ email, options: { emailRedirectTo: window.location.origin + '/portal' } })
   - Success state: show message "Check your email — we sent you a sign-in link" with the email address displayed
   - Error handling: show inline error message on failure

2. Portal route guard:
   - Create a ProtectedPortalRoute component that checks for active Supabase session
   - If no session: redirect to /portal/login
   - If session but no customer_users record: show "Account not found" error page with contact email
   - If valid session + customer: render children

3. After sign-in redirect to /portal:
   - Fetch customer data using get_my_customer_id() logic: query customer_users for current user's customer_id, then fetch the customer record
   - Store customer data in React context available throughout the portal

4. Logout button in portal header: call supabase.auth.signOut() and redirect to /portal/login
```

**Expected result:** Entering an email on the login page shows the success message. Clicking the magic link in the email redirects to /portal with the user authenticated. Visiting /portal without a session redirects to /portal/login.

### 3. Build the portal layout with tabbed sections

The main portal interface is a branded layout with the customer's company name in the header and tabs for each section. Each tab loads its data lazily when first activated. Include a welcome card showing the customer's account summary.

```
Build the main customer portal layout at /portal:

1. Portal header:
   - Company logo (left)
   - Welcome message: "Welcome back, [contact_name]" (center or left)
   - Company name badge (right)
   - Logout button (right)

2. Account summary card below header (shadcn Card, horizontal layout):
   - Open tickets count
   - Unpaid invoices count with total amount
   - Most recent order status
   - Account status badge (Active/Inactive)

3. Main content area with shadcn Tabs:
   - "Orders" tab
   - "Invoices" tab
   - "Support" tab
   - "Documents" tab

4. Each tab uses lazy loading: data is only fetched when the tab is first clicked. Use a boolean state (hasLoaded) per tab to avoid refetching on tab switch.

5. Empty states for each tab:
   - Orders: "No orders yet" with a simple illustration description
   - Invoices: "No invoices found"
   - Support: "No tickets — everything running smoothly!"
   - Documents: "No documents shared yet"

All data fetches filter by the customer_id from the portal context.
```

> Pro tip: The account summary card is the most important part of the portal above the fold. Make sure the counts are accurate by using Supabase .select('id', { count: 'exact' }) queries rather than fetching all rows just to count them.

**Expected result:** The portal loads with the customer's name and summary stats. Switching tabs loads the respective data. Empty state messages show correctly for customers with no data.

### 4. Add DataTable views and ticket Sheet

Each tab gets a DataTable showing the relevant history. The Support tab additionally has a new ticket form and a Sheet panel for viewing and replying to ticket threads.

```
Build the content for each portal tab:

ORDERS tab:
- shadcn DataTable with columns: Order Number, Date, Status (Badge), Items (count), Total Amount
- Status badge colors: pending=gray, processing=blue, shipped=yellow, delivered=green, cancelled=red
- Click row: open a Dialog showing full order details including items JSONB as a line item list

INVOICES tab:
- shadcn DataTable: Invoice Number, Date, Due Date (red if overdue), Status (Badge), Amount
- Status badge: draft=gray, sent=blue, paid=green, overdue=red, cancelled=gray
- Download button for each row: if pdf_url exists, open in new tab. Otherwise show "PDF not available"

SUPPORT tab:
- "New Ticket" button at top right opens a Dialog with:
  - Subject input (required, min 10 chars)
  - Message textarea (required, min 20 chars)
  - Priority select (low/medium/high)
  - Submit inserts into tickets and ticket_messages tables
- Tickets DataTable: Subject, Status Badge, Priority Badge, Created Date, Last Updated
- Click row: open a Sheet panel (right side, 500px wide) showing:
  - Ticket subject and status in header
  - Chronological message thread (staff messages on left, customer messages on right)
  - Reply textarea at the bottom with Send button
  - Replies insert into ticket_messages with is_staff=false

DOCUMENTS tab:
- Card grid (not table) showing documents: icon based on file_type, name, size, date
- Click card: download the file using a signed URL from Supabase Storage
- Signed URL generation: call supabase.storage.from('customer-documents').createSignedUrl(path, 3600)
```

**Expected result:** All four tabs render with correct data. The new ticket dialog submits successfully and the new ticket appears in the support table. Clicking a ticket opens the Sheet with the message thread. Document cards generate download links on click.

### 5. Build the admin dashboard for managing customer accounts

Staff members need a separate admin interface to see all customers, view their portal activity, upload documents to customer accounts, and respond to tickets. Protect this behind an is_admin flag, not just an authenticated session.

```
Build an admin dashboard at /admin/portal accessible only to users with is_admin=true in customer_users:

1. Route guard: check is_admin flag. Non-admin users see a 403 page.

2. Customer list page (/admin/portal):
   - DataTable: Company Name, Contact, Email, Open Tickets, Unpaid Invoices, Status (active/inactive), Actions
   - Actions: "View Portal" (link to their account view), "Upload Document" button
   - Search input filtering by company name or email

3. Upload Document Dialog (opens from DataTable Actions):
   - Customer name shown at top (read-only)
   - File dropzone: accept PDF, PNG, JPG, DOCX
   - Document name input (pre-filled with filename)
   - Upload to Supabase Storage at path: customer-documents/{customer_id}/{timestamp}-{filename}
   - Insert into customer_documents table with uploaded_by_staff=true
   - Show success toast: "Document uploaded to [Company Name]'s portal"

4. Customer detail view (/admin/portal/[customerId]):
   - Same tabbed layout as the customer portal but showing all that customer's data
   - Ticket messages: show a Reply text area with "Send as Staff" button that inserts with is_staff=true
   - Status toggle switch to activate/deactivate customer portal access

Admin access to all tables: create a separate set of RLS policies with an is_staff check, OR use the service role key via an Edge Function for admin reads (safer for production).
```

> Pro tip: For the admin view, rather than creating complex admin RLS policies, consider having admin actions call a Supabase Edge Function with the service role key. This keeps RLS simpler and admin operations more auditable.

**Expected result:** Admin users can view the full customer list with stats. Uploading a document for a customer immediately makes it visible in that customer's Documents tab. Replying to a ticket as staff shows the reply in the customer's ticket thread.

## Complete code example

File: `src/components/portal/PortalContext.tsx`

```typescript
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { supabase } from '@/integrations/supabase/client';

interface Customer {
  id: string;
  company_name: string;
  contact_name: string;
  email: string;
  is_active: boolean;
}

interface PortalContextValue {
  customer: Customer | null;
  isLoading: boolean;
  isAdmin: boolean;
  error: string | null;
}

const PortalContext = createContext<PortalContextValue>({
  customer: null,
  isLoading: true,
  isAdmin: false,
  error: null,
});

export function PortalProvider({ children }: { children: ReactNode }) {
  const [customer, setCustomer] = useState<Customer | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [isAdmin, setIsAdmin] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function loadCustomer() {
      try {
        const { data: { user } } = await supabase.auth.getUser();
        if (!user) { setIsLoading(false); return; }

        const { data: customerUser, error: cuError } = await supabase
          .from('customer_users')
          .select('customer_id, is_admin')
          .eq('user_id', user.id)
          .single();

        if (cuError || !customerUser) {
          setError('No customer account linked to this email address.');
          setIsLoading(false);
          return;
        }

        const { data: customerData, error: cError } = await supabase
          .from('customers')
          .select('*')
          .eq('id', customerUser.customer_id)
          .single();

        if (cError || !customerData) {
          setError('Customer account not found.');
        } else {
          setCustomer(customerData);
          setIsAdmin(customerUser.is_admin);
        }
      } catch (e) {
        setError('Failed to load account.');
      } finally {
        setIsLoading(false);
      }
    }

    loadCustomer();
  }, []);

  return (
    <PortalContext.Provider value={{ customer, isLoading, isAdmin, error }}>
      {children}
    </PortalContext.Provider>
  );
}

export function usePortal() {
  return useContext(PortalContext);
}
```

## Common mistakes

- **Not linking auth users to customer accounts before testing magic links** — When a customer clicks the magic link and lands on the portal, the route guard queries customer_users for their user ID. If no record exists, they see an error page instead of the portal Fix: After creating a customer record, manually insert a row in customer_users linking the customer's email to their customer_id. The user_id comes from auth.users after they first sign in.
- **Using the customer's email as the multi-tenant identifier instead of customer_id** — Emails can change, and using email as a foreign key creates update anomalies across all related tables Fix: Always use the UUID customer_id from the customers table. The customer_users table is the bridge between auth.users (identified by UUID) and customers (also UUID).
- **Generating Supabase Storage signed URLs on page load for all documents** — Signed URLs expire (default 1 hour) and generating dozens of them on page load creates unnecessary load and shows expired links if the user waits before downloading Fix: Generate signed URLs only when the user clicks a document to download. Use a loading state on the card while the URL is being generated.
- **Allowing customers to see all ticket_messages including staff-only notes** — If your ticket system has internal staff notes, the current RLS policy might expose them to customers Fix: Add an is_internal column to ticket_messages and create an RLS policy: customers can only SELECT where is_internal = false OR is_staff = false. Staff notes are visible only to admin users.

## Best practices

- Use the get_my_customer_id() SQL function as a centralized helper for all RLS policies — easier to audit and modify
- Set Supabase magic link expiry to 1 hour (default) and guide customers to check spam folders
- Always test the portal logged in as a real customer user, not an admin — RLS bugs are only visible from the right role
- Use lazy loading per tab so the portal feels fast on first load, only fetching data when each section is opened
- Generate Supabase Storage signed URLs on demand (on click) rather than on page load to avoid expiry issues
- Add a last_seen_at timestamp to customer_users updated on each portal visit for activity tracking
- Keep ticket replies under 2000 characters with a character counter in the textarea to prevent overly long messages
- Test the magic link flow end-to-end on a mobile device since many customers check email on their phones

## Frequently asked questions

### Can customers reset their own password in a magic link portal?

With magic links, there's no password to reset — that's the benefit. Every time a customer needs to log in, they request a new magic link. If they lose access to their email, you'll need to update their email in Supabase Auth manually via the Dashboard under Authentication → Users.

### How do I add new customers to the portal?

Create a customer record in the customers table via the admin dashboard. Then when the customer first signs in via magic link, their auth.uid() is added to customer_users linking them to the customer account. You can pre-create the customer_users record using the customer's email if you know it in advance.

### Can I deploy this portal on my own domain (like portal.mycompany.com)?

Yes. Publish the Lovable project, then in Lovable's publish settings connect a subdomain. You'll also need to update the Supabase Auth redirect URL in Dashboard → Authentication → URL Configuration to include your custom domain for magic links to work correctly.

### What if a customer's company has multiple users who should all see the same data?

The schema already handles this. Multiple rows in customer_users can reference the same customer_id with different user_ids. All users linked to the same customer see the same orders, invoices, and documents. The get_my_customer_id() function returns the same customer_id for all of them.

### How do I prevent customers from seeing each other's documents in Storage?

In Supabase Dashboard under Storage → Policies, add a policy on the customer-documents bucket: allow SELECT only when the storage path starts with the user's customer_id. Since documents are stored at {customer_id}/{filename}, this means each customer can only read their own folder.

### Can staff reply to tickets without logging into the customer portal?

Yes — build a separate /admin/tickets view for staff that uses service-role-key-backed Edge Functions to fetch and insert ticket messages. Staff don't need portal accounts; they manage everything through the admin interface. The is_staff=true flag differentiates staff messages from customer messages.

### How much does it cost to run this portal for 100 customers?

Supabase's free tier supports up to 50,000 database rows and 1GB storage, which is plenty for 100 customers. Lovable Pro at $25/month gives you the custom domain and Edge Functions. For 100 active customers with moderate usage, you'd likely stay on Supabase free tier and Lovable Pro only.

### Can RapidDev help integrate an existing CRM into this portal?

Yes — syncing CRM data (like orders or contacts) into the Supabase tables that power the portal is a common integration project. RapidDev has experience connecting HubSpot, Salesforce, and custom ERP systems to Lovable-built portals via Edge Functions.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/customer-portal
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/customer-portal
