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
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
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.
1-- Run in Supabase SQL Editor23CREATE 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');78CREATE 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);1718CREATE 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);2425CREATE 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);3637CREATE 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);4849CREATE 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);5960CREATE 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);6869CREATE 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);7980-- Enable RLS on all tables81ALTER 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;8889-- Helper function to get current user's customer_id90CREATE 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$$;9495-- RLS policies using helper function96CREATE 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.
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.
1Build the customer portal authentication flow:231. 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 displayed9 - Error handling: show inline error message on failure10112. Portal route guard:12 - Create a ProtectedPortalRoute component that checks for active Supabase session13 - If no session: redirect to /portal/login14 - If session but no customer_users record: show "Account not found" error page with contact email15 - If valid session + customer: render children16173. 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 record19 - Store customer data in React context available throughout the portal20214. Logout button in portal header: call supabase.auth.signOut() and redirect to /portal/loginExpected 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.
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.
1Build the main customer portal layout at /portal:231. 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)892. Account summary card below header (shadcn Card, horizontal layout):10 - Open tickets count11 - Unpaid invoices count with total amount12 - Most recent order status13 - Account status badge (Active/Inactive)14153. Main content area with shadcn Tabs:16 - "Orders" tab17 - "Invoices" tab18 - "Support" tab19 - "Documents" tab20214. 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.22235. Empty states for each tab:24 - Orders: "No orders yet" with a simple illustration description25 - Invoices: "No invoices found"26 - Support: "No tickets — everything running smoothly!"27 - Documents: "No documents shared yet"2829All 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.
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.
1Build the content for each portal tab:23ORDERS tab:4- shadcn DataTable with columns: Order Number, Date, Status (Badge), Items (count), Total Amount5- Status badge colors: pending=gray, processing=blue, shipped=yellow, delivered=green, cancelled=red6- Click row: open a Dialog showing full order details including items JSONB as a line item list78INVOICES tab:9- shadcn DataTable: Invoice Number, Date, Due Date (red if overdue), Status (Badge), Amount10- Status badge: draft=gray, sent=blue, paid=green, overdue=red, cancelled=gray11- Download button for each row: if pdf_url exists, open in new tab. Otherwise show "PDF not available"1213SUPPORT 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 tables19- Tickets DataTable: Subject, Status Badge, Priority Badge, Created Date, Last Updated20- Click row: open a Sheet panel (right side, 500px wide) showing:21 - Ticket subject and status in header22 - Chronological message thread (staff messages on left, customer messages on right)23 - Reply textarea at the bottom with Send button24 - Replies insert into ticket_messages with is_staff=false2526DOCUMENTS tab:27- Card grid (not table) showing documents: icon based on file_type, name, size, date28- Click card: download the file using a signed URL from Supabase Storage29- 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.
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.
1Build an admin dashboard at /admin/portal accessible only to users with is_admin=true in customer_users:231. Route guard: check is_admin flag. Non-admin users see a 403 page.452. Customer list page (/admin/portal):6 - DataTable: Company Name, Contact, Email, Open Tickets, Unpaid Invoices, Status (active/inactive), Actions7 - Actions: "View Portal" (link to their account view), "Upload Document" button8 - Search input filtering by company name or email9103. Upload Document Dialog (opens from DataTable Actions):11 - Customer name shown at top (read-only)12 - File dropzone: accept PDF, PNG, JPG, DOCX13 - 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=true16 - Show success toast: "Document uploaded to [Company Name]'s portal"17184. Customer detail view (/admin/portal/[customerId]):19 - Same tabbed layout as the customer portal but showing all that customer's data20 - Ticket messages: show a Reply text area with "Send as Staff" button that inserts with is_staff=true21 - Status toggle switch to activate/deactivate customer portal access2223Admin 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
1import { createContext, useContext, useEffect, useState, ReactNode } from 'react';2import { supabase } from '@/integrations/supabase/client';34interface Customer {5 id: string;6 company_name: string;7 contact_name: string;8 email: string;9 is_active: boolean;10}1112interface PortalContextValue {13 customer: Customer | null;14 isLoading: boolean;15 isAdmin: boolean;16 error: string | null;17}1819const PortalContext = createContext<PortalContextValue>({20 customer: null,21 isLoading: true,22 isAdmin: false,23 error: null,24});2526export 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);3132 useEffect(() => {33 async function loadCustomer() {34 try {35 const { data: { user } } = await supabase.auth.getUser();36 if (!user) { setIsLoading(false); return; }3738 const { data: customerUser, error: cuError } = await supabase39 .from('customer_users')40 .select('customer_id, is_admin')41 .eq('user_id', user.id)42 .single();4344 if (cuError || !customerUser) {45 setError('No customer account linked to this email address.');46 setIsLoading(false);47 return;48 }4950 const { data: customerData, error: cError } = await supabase51 .from('customers')52 .select('*')53 .eq('id', customerUser.customer_id)54 .single();5556 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 }6869 loadCustomer();70 }, []);7172 return (73 <PortalContext.Provider value={{ customer, isLoading, isAdmin, error }}>74 {children}75 </PortalContext.Provider>76 );77}7879export 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation