Skip to main content
RapidDev - Software Development Agency

How to Build a Customer Portal with Lovable

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.

What you'll build

  • Magic link authentication so customers log in without passwords
  • Tabbed portal interface with Orders, Invoices, Tickets, and Documents sections
  • DataTable for order and invoice history with status badges
  • Slide-out Sheet for full ticket details with reply thread
  • Secure document sharing where customers only see their own files
  • Multi-tenant architecture isolating each customer's data via RLS
  • Admin dashboard to view all customers and manage portal access
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate16 min read2-3 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
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.

What you're building

A customer portal is a dedicated interface where your clients can self-serve — checking order status, downloading invoices, submitting support tickets, and accessing shared documents — without needing to email you or call in. This eliminates a huge amount of repetitive customer communication.

The technical foundation is multi-tenancy: every customer can only see their own data. This is enforced at the database level using Supabase Row Level Security, so even if there's a bug in your application code, one customer can never accidentally see another customer's records. The policy is simple: every table has a customer_id column, and RLS restricts all queries to rows where customer_id matches the authenticated user's linked customer record.

Magic links (passwordless email authentication) are the right choice here because customers log in infrequently and don't want to remember a password for a portal they use once a month. Supabase Auth handles the full magic link flow — you just configure the email template and redirect URL.

Final result

A professional, branded customer portal where clients securely access all their account information and communicate with your team without any manual intervention.

Tech stack

LovableFrontend builder and code generation
Supabase AuthMagic link authentication and session management
Supabase PostgreSQLMulti-tenant database for customers, orders, invoices, tickets
Supabase StorageSecure document storage with per-customer access
shadcn/uiTabs, DataTable, Sheet, Badge, Card components
React Hook Form + ZodTicket submission form validation

Prerequisites

  • 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)

Build steps

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.

supabase-schema.sql
1-- Run in Supabase SQL Editor
2
3CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled');
4CREATE TYPE invoice_status AS ENUM ('draft', 'sent', 'paid', 'overdue', 'cancelled');
5CREATE TYPE ticket_status AS ENUM ('open', 'in_progress', 'waiting_on_customer', 'resolved', 'closed');
6CREATE TYPE ticket_priority AS ENUM ('low', 'medium', 'high', 'urgent');
7
8CREATE TABLE customers (
9 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
10 company_name TEXT NOT NULL,
11 contact_name TEXT NOT NULL,
12 email TEXT UNIQUE NOT NULL,
13 phone TEXT,
14 is_active BOOLEAN DEFAULT true,
15 created_at TIMESTAMPTZ DEFAULT now()
16);
17
18CREATE TABLE customer_users (
19 user_id UUID REFERENCES auth.users PRIMARY KEY,
20 customer_id UUID REFERENCES customers(id) NOT NULL,
21 is_admin BOOLEAN DEFAULT false,
22 created_at TIMESTAMPTZ DEFAULT now()
23);
24
25CREATE TABLE orders (
26 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
27 customer_id UUID REFERENCES customers(id) NOT NULL,
28 order_number TEXT UNIQUE NOT NULL,
29 status order_status DEFAULT 'pending',
30 total_amount DECIMAL(12,2) NOT NULL,
31 items JSONB DEFAULT '[]',
32 notes TEXT,
33 created_at TIMESTAMPTZ DEFAULT now(),
34 updated_at TIMESTAMPTZ DEFAULT now()
35);
36
37CREATE TABLE invoices (
38 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
39 customer_id UUID REFERENCES customers(id) NOT NULL,
40 invoice_number TEXT UNIQUE NOT NULL,
41 status invoice_status DEFAULT 'draft',
42 amount DECIMAL(12,2) NOT NULL,
43 due_date DATE,
44 paid_at TIMESTAMPTZ,
45 pdf_url TEXT,
46 created_at TIMESTAMPTZ DEFAULT now()
47);
48
49CREATE TABLE tickets (
50 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
51 customer_id UUID REFERENCES customers(id) NOT NULL,
52 created_by UUID REFERENCES auth.users NOT NULL,
53 subject TEXT NOT NULL,
54 status ticket_status DEFAULT 'open',
55 priority ticket_priority DEFAULT 'medium',
56 created_at TIMESTAMPTZ DEFAULT now(),
57 updated_at TIMESTAMPTZ DEFAULT now()
58);
59
60CREATE TABLE ticket_messages (
61 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
62 ticket_id UUID REFERENCES tickets(id) ON DELETE CASCADE NOT NULL,
63 author_id UUID REFERENCES auth.users NOT NULL,
64 message TEXT NOT NULL,
65 is_staff BOOLEAN DEFAULT false,
66 created_at TIMESTAMPTZ DEFAULT now()
67);
68
69CREATE TABLE customer_documents (
70 id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
71 customer_id UUID REFERENCES customers(id) NOT NULL,
72 name TEXT NOT NULL,
73 storage_path TEXT NOT NULL,
74 file_type TEXT NOT NULL,
75 file_size INTEGER,
76 uploaded_by_staff BOOLEAN DEFAULT true,
77 created_at TIMESTAMPTZ DEFAULT now()
78);
79
80-- Enable RLS on all tables
81ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
82ALTER TABLE customer_users ENABLE ROW LEVEL SECURITY;
83ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
84ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
85ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;
86ALTER TABLE ticket_messages ENABLE ROW LEVEL SECURITY;
87ALTER TABLE customer_documents ENABLE ROW LEVEL SECURITY;
88
89-- Helper function to get current user's customer_id
90CREATE OR REPLACE FUNCTION get_my_customer_id()
91RETURNS UUID LANGUAGE sql STABLE SECURITY DEFINER AS $$
92 SELECT customer_id FROM customer_users WHERE user_id = auth.uid() LIMIT 1;
93$$;
94
95-- RLS policies using helper function
96CREATE POLICY "Customers see own profile" ON customers FOR SELECT USING (id = get_my_customer_id());
97CREATE POLICY "Customers see own orders" ON orders FOR SELECT USING (customer_id = get_my_customer_id());
98CREATE POLICY "Customers see own invoices" ON invoices FOR SELECT USING (customer_id = get_my_customer_id());
99CREATE POLICY "Customers manage own tickets" ON tickets FOR ALL USING (customer_id = get_my_customer_id());
100CREATE POLICY "Ticket participants see messages" ON ticket_messages FOR SELECT USING (
101 EXISTS (SELECT 1 FROM tickets WHERE id = ticket_id AND customer_id = get_my_customer_id())
102);
103CREATE POLICY "Customers insert messages" ON ticket_messages FOR INSERT WITH CHECK (
104 EXISTS (SELECT 1 FROM tickets WHERE id = ticket_id AND customer_id = get_my_customer_id())
105);
106CREATE 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.

prompt.txt
1Build the customer portal authentication flow:
2
31. Login page at /portal/login:
4 - Centered card with company logo placeholder (img tag with /logo.png)
5 - Email input with label "Enter your email to receive a sign-in link"
6 - "Send Magic Link" button (shadcn Button)
7 - On submit: call supabase.auth.signInWithOtp({ email, options: { emailRedirectTo: window.location.origin + '/portal' } })
8 - Success state: show message "Check your email — we sent you a sign-in link" with the email address displayed
9 - Error handling: show inline error message on failure
10
112. Portal route guard:
12 - Create a ProtectedPortalRoute component that checks for active Supabase session
13 - If no session: redirect to /portal/login
14 - If session but no customer_users record: show "Account not found" error page with contact email
15 - If valid session + customer: render children
16
173. After sign-in redirect to /portal:
18 - Fetch customer data using get_my_customer_id() logic: query customer_users for current user's customer_id, then fetch the customer record
19 - Store customer data in React context available throughout the portal
20
214. 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.

prompt.txt
1Build the main customer portal layout at /portal:
2
31. Portal header:
4 - Company logo (left)
5 - Welcome message: "Welcome back, [contact_name]" (center or left)
6 - Company name badge (right)
7 - Logout button (right)
8
92. Account summary card below header (shadcn Card, horizontal layout):
10 - Open tickets count
11 - Unpaid invoices count with total amount
12 - Most recent order status
13 - Account status badge (Active/Inactive)
14
153. Main content area with shadcn Tabs:
16 - "Orders" tab
17 - "Invoices" tab
18 - "Support" tab
19 - "Documents" tab
20
214. 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.
22
235. Empty states for each tab:
24 - Orders: "No orders yet" with a simple illustration description
25 - Invoices: "No invoices found"
26 - Support: "No tickets — everything running smoothly!"
27 - Documents: "No documents shared yet"
28
29All 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.

prompt.txt
1Build the content for each portal tab:
2
3ORDERS tab:
4- shadcn DataTable with columns: Order Number, Date, Status (Badge), Items (count), Total Amount
5- Status badge colors: pending=gray, processing=blue, shipped=yellow, delivered=green, cancelled=red
6- Click row: open a Dialog showing full order details including items JSONB as a line item list
7
8INVOICES tab:
9- shadcn DataTable: Invoice Number, Date, Due Date (red if overdue), Status (Badge), Amount
10- Status badge: draft=gray, sent=blue, paid=green, overdue=red, cancelled=gray
11- Download button for each row: if pdf_url exists, open in new tab. Otherwise show "PDF not available"
12
13SUPPORT tab:
14- "New Ticket" button at top right opens a Dialog with:
15 - Subject input (required, min 10 chars)
16 - Message textarea (required, min 20 chars)
17 - Priority select (low/medium/high)
18 - Submit inserts into tickets and ticket_messages tables
19- Tickets DataTable: Subject, Status Badge, Priority Badge, Created Date, Last Updated
20- Click row: open a Sheet panel (right side, 500px wide) showing:
21 - Ticket subject and status in header
22 - Chronological message thread (staff messages on left, customer messages on right)
23 - Reply textarea at the bottom with Send button
24 - Replies insert into ticket_messages with is_staff=false
25
26DOCUMENTS tab:
27- Card grid (not table) showing documents: icon based on file_type, name, size, date
28- Click card: download the file using a signed URL from Supabase Storage
29- 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.

prompt.txt
1Build an admin dashboard at /admin/portal accessible only to users with is_admin=true in customer_users:
2
31. Route guard: check is_admin flag. Non-admin users see a 403 page.
4
52. Customer list page (/admin/portal):
6 - DataTable: Company Name, Contact, Email, Open Tickets, Unpaid Invoices, Status (active/inactive), Actions
7 - Actions: "View Portal" (link to their account view), "Upload Document" button
8 - Search input filtering by company name or email
9
103. Upload Document Dialog (opens from DataTable Actions):
11 - Customer name shown at top (read-only)
12 - File dropzone: accept PDF, PNG, JPG, DOCX
13 - Document name input (pre-filled with filename)
14 - Upload to Supabase Storage at path: customer-documents/{customer_id}/{timestamp}-{filename}
15 - Insert into customer_documents table with uploaded_by_staff=true
16 - Show success toast: "Document uploaded to [Company Name]'s portal"
17
184. Customer detail view (/admin/portal/[customerId]):
19 - Same tabbed layout as the customer portal but showing all that customer's data
20 - Ticket messages: show a Reply text area with "Send as Staff" button that inserts with is_staff=true
21 - Status toggle switch to activate/deactivate customer portal access
22
23Admin 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

src/components/portal/PortalContext.tsx
1import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
2import { supabase } from '@/integrations/supabase/client';
3
4interface Customer {
5 id: string;
6 company_name: string;
7 contact_name: string;
8 email: string;
9 is_active: boolean;
10}
11
12interface PortalContextValue {
13 customer: Customer | null;
14 isLoading: boolean;
15 isAdmin: boolean;
16 error: string | null;
17}
18
19const PortalContext = createContext<PortalContextValue>({
20 customer: null,
21 isLoading: true,
22 isAdmin: false,
23 error: null,
24});
25
26export function PortalProvider({ children }: { children: ReactNode }) {
27 const [customer, setCustomer] = useState<Customer | null>(null);
28 const [isLoading, setIsLoading] = useState(true);
29 const [isAdmin, setIsAdmin] = useState(false);
30 const [error, setError] = useState<string | null>(null);
31
32 useEffect(() => {
33 async function loadCustomer() {
34 try {
35 const { data: { user } } = await supabase.auth.getUser();
36 if (!user) { setIsLoading(false); return; }
37
38 const { data: customerUser, error: cuError } = await supabase
39 .from('customer_users')
40 .select('customer_id, is_admin')
41 .eq('user_id', user.id)
42 .single();
43
44 if (cuError || !customerUser) {
45 setError('No customer account linked to this email address.');
46 setIsLoading(false);
47 return;
48 }
49
50 const { data: customerData, error: cError } = await supabase
51 .from('customers')
52 .select('*')
53 .eq('id', customerUser.customer_id)
54 .single();
55
56 if (cError || !customerData) {
57 setError('Customer account not found.');
58 } else {
59 setCustomer(customerData);
60 setIsAdmin(customerUser.is_admin);
61 }
62 } catch (e) {
63 setError('Failed to load account.');
64 } finally {
65 setIsLoading(false);
66 }
67 }
68
69 loadCustomer();
70 }, []);
71
72 return (
73 <PortalContext.Provider value={{ customer, isLoading, isAdmin, error }}>
74 {children}
75 </PortalContext.Provider>
76 );
77}
78
79export function usePortal() {
80 return useContext(PortalContext);
81}

Customization ideas

Add a payment integration for invoices

Connect Stripe to allow customers to pay invoices directly in the portal. The Pay button on an unpaid invoice calls an Edge Function that creates a Stripe Checkout session and redirects the customer. Store the payment_intent_id in the invoices table.

Add a notification preferences page

Create a Settings tab in the portal where customers can choose which email notifications they receive (new invoice, ticket reply, order status change). Store preferences in a notification_preferences table and check them in your Edge Functions before sending emails.

Build a white-label portal with per-customer branding

Add a branding column to the customers table storing primary color, logo URL, and company tagline. Apply these dynamically using CSS variables set from the portal context, giving each customer a uniquely branded portal experience.

Add order tracking with a progress timeline

Build an order detail view with a visual progress timeline showing each order status stage. Store status change timestamps in an order_history table and render them as a vertical timeline using CSS.

Enable bulk document download

Add a select column to the Documents DataTable with checkboxes. When documents are selected, show a 'Download Selected' button that generates multiple signed URLs and triggers sequential downloads.

Add a customer satisfaction survey on ticket close

When a ticket status changes to 'closed', send an email via Edge Function with a one-click satisfaction rating (1-5). Store ratings in a ticket_ratings table and display averages in the admin dashboard.

Common pitfalls

Pitfall: Not linking auth users to customer accounts before testing magic links

How to avoid: 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.

Pitfall: Using the customer's email as the multi-tenant identifier instead of customer_id

How to avoid: 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).

Pitfall: Generating Supabase Storage signed URLs on page load for all documents

How to avoid: 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.

Pitfall: Allowing customers to see all ticket_messages including staff-only notes

How to avoid: 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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a multi-tenant customer portal where multiple companies each have their own users. I have a customers table, a customer_users table linking auth users to customers, and several tables (orders, invoices, tickets) with a customer_id foreign key. Write Supabase Row Level Security policies for all tables so each authenticated user can only see records belonging to their customer. Include a reusable SQL function that returns the current user's customer_id to avoid repeating the lookup in every policy.

Lovable Prompt

Add real-time updates to the Support tab in the customer portal. When a staff member replies to a ticket, the customer should see the new message appear in their open ticket Sheet without refreshing. Use Supabase Realtime to subscribe to new rows in the ticket_messages table filtered by the current ticket_id.

Build Prompt

In Lovable, create a magic link login page for a customer portal. The page should have a single email input and a submit button. On submit, call supabase.auth.signInWithOtp with the email and emailRedirectTo set to the portal URL. Show a confirmation message after submission. The page should also handle the case where the URL contains a Supabase auth token (after clicking the magic link) by detecting the session and redirecting to the portal dashboard.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.