A static site is wonderful right up to the moment you need it to remember something — a contact message, a saved snippet, a view count. The classic answer is "spin up a server," but I didn't want to maintain one. Supabase let me add a Postgres database, authentication, and instant APIs to my otherwise static portfolio without running any backend code of my own. Here is how I structured it and the mistakes I hit along the way.
Why Supabase for a Static Front-End
Supabase gives you a hosted Postgres database with an auto-generated REST and realtime API, plus a small JavaScript client you drop into the browser. For a static site that means:
- No server to deploy, patch, or pay for while idle.
- A real relational database, not a key-value toy — I can run joins and SQL.
- Security enforced at the database layer through Row Level Security, so the public anon key is safe to ship in the browser.
Connecting from the Browser
The client initializes with the project URL and the public anon key. These are meant to be public — the security boundary is the database, not the key:
import { createClient } from
'https://esm.sh/@supabase/supabase-js@2';
const supabase = createClient(
'https://YOUR-PROJECT.supabase.co',
'YOUR-ANON-PUBLIC-KEY'
);
Loading the client straight from an ESM CDN keeps me in the no-build-step world the rest of the site lives in.
A Contact Form That Persists
The contact form was the first feature. The table is intentionally simple:
create table messages (
id uuid primary key default gen_random_uuid(),
name text not null,
email text not null,
body text not null,
created_at timestamptz default now()
);
Inserting a row from the form handler is a single call:
const { error } = await supabase
.from('messages')
.insert({ name, email, body });
if (error) showError(error.message);
else showSuccess('Message sent!');
Row Level Security Is the Whole Game
This is the part people skip and then panic about. By default, with RLS enabled and no policies, nothing is allowed — which is the safe default. For the contact form I want anonymous visitors to be able to insert a message but never to read other people's messages:
alter table messages enable row level security;
-- anyone may submit a message
create policy "anon can insert"
on messages for insert
to anon
with check (true);
-- but nobody anonymous may read them back
-- (no SELECT policy = no read access)
The mental shift is important: with RLS, you don't hide the API, you describe exactly who can do what at the row level. The anon key being public no longer matters because the policy, not the key, decides access.
A Read-Only Content Vault
I also wanted a "vault" of public snippets and prompts that anyone can browse but only I can edit. That's the mirror image of the contact form — public SELECT, restricted writes:
create policy "public can read vault"
on vault_items for select
to anon
using (is_published = true);
create policy "owner can write"
on vault_items for all
to authenticated
using (auth.uid() = owner_id);
The is_published flag means I can save drafts that stay invisible until I flip them live — a tiny CMS, enforced entirely in SQL.
Lessons From the Migrations
I keep every schema change in a versioned .sql file in the repo rather than clicking around the dashboard. A few things I learned:
- Enable RLS first, then write policies. Forgetting to enable RLS leaves a table wide open even if you wrote policies — Supabase warns you, listen to it.
- Test as the anon role. What works in the SQL editor (which runs as an admin) can fail in the browser. Always verify with the real anon key.
- Add an index on
created_atif you sort by it. Cheap, and it keeps the messages list fast as it grows. - Validate on the client and constrain in SQL. A
checkconstraint on email format or anot nullis the last line of defense if the front-end validation is bypassed.
Rate Limiting and Spam
A public insert policy invites spam. I added two defenses without a server: a hidden honeypot field that real users never fill, and a Postgres function that rejects more than a few inserts per minute from the same source. For anything heavier, Supabase Edge Functions can sit in front of the table — but for a portfolio, the simple measures held up fine.
The Takeaway
"Static site" and "has a database" are no longer contradictory. With Supabase I got persistence, auth, and proper authorization while keeping a zero-build, zero-server front end. The single most important concept to internalize is Row Level Security — once you think of access control as SQL policies instead of hidden endpoints, the whole architecture clicks into place.
The contact form on this site runs on exactly this setup — try sending a message.