Database
PostgreSQL hosted on Supabase. Run two SQL queries and you're done.
NoteDo this before setting up Google OAuth or Magic Link — both auth flows write to the
profiles table on first sign-in.1. Create the profiles table
In the Supabase SQL Editor, run this query. It creates the profiles table, auto-inserts a row on sign-up, and keeps updated_at in sync:
-- Create the profiles table
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT,
email TEXT,
image TEXT,
customer_id TEXT,
plan_id TEXT,
payment_provider TEXT,
has_access BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT (now() AT TIME ZONE 'UTC'),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT (now() AT TIME ZONE 'UTC')
);
-- Keep updated_at current on every row update
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = (now() AT TIME ZONE 'UTC');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Automatically update the updated_at column whenever a row is changed
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON public.profiles
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Auto-insert a profile row when a new user signs up
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, email, name, image, created_at, updated_at)
VALUES (
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'full_name', NEW.raw_user_meta_data->>'name'),
NEW.raw_user_meta_data->>'avatar_url',
(now() AT TIME ZONE 'UTC'),
(now() AT TIME ZONE 'UTC')
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Automatically create a profile row when a new user signs up
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();2. Add Row Level Security (optional, but highly recommended)
WarningWithout Row Level Security, any authenticated user could read or modify every other user's data. These policies make sure each user can only touch their own profile row — skip this only if you fully understand the risk.
Run this second query to lock down the table so users can only access their own row:
-- Enable Row Level Security so users can only access their own row
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- Allow users to read their own profile
CREATE POLICY read_own_profile ON public.profiles
FOR SELECT USING (auth.uid() = id);
-- Allow users to update their own profile
CREATE POLICY update_own_profile ON public.profiles
FOR UPDATE USING (auth.uid() = id);
-- Allow users to insert their own profile
CREATE POLICY insert_own_profile ON public.profiles
FOR INSERT WITH CHECK (auth.uid() = id);
-- Allow users to delete their own profile
CREATE POLICY delete_own_profile ON public.profiles
FOR DELETE USING (auth.uid() = id);Key columns
has_access—trueafter a successful purchase,falseafter cancellation. Gate your product features behind this flag.customer_id— the Stripe or Lemon Squeezy customer ID, stored on first purchase.plan_id— the active price ID or variant ID of the user's current plan.payment_provider—"stripe"or"lemonsqueezy", set by the webhook.
3. Leads table (optional)
Only needed if you use the LeadMagnet component to collect emails. Run this in the SQL Editor:
-- Create the leads table to store collected emails
create table public.leads (
id uuid default gen_random_uuid(),
email text unique,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
primary key (id)
);
-- Enable Row Level Security
alter table public.leads enable row level security;
-- Allow anyone to submit their email (no login required)
create policy insert_lead on public.leads
for insert
to public
with check (true);