This guide walks through moving a Lovable app onto a stack you own and control — using an AI coding agent (Claude Code, Cursor, or similar) to do the heavy lifting safely. It's the clean, repeatable path distilled from a real migration, including the steps where the defaults will trip you up.

---

What You're Setting Up

Lovable gets you to a working app fast, but it bundles your backend. When you want production-grade control — your own database, your own AI provider, your own storage and cron — you move to a stack you own.

text
Before:  Lovable app  →  Lovable-bundled Supabase  (+ Lovable AI calls)

After:   Your front end
            →  Supabase   (database, auth, cron, row-level security)
            →  Cloudflare (R2 storage, optional Workers/Pages)
            →  OpenRouter (one API for many AI models)

In plain terms: Supabase holds your data and rules, Cloudflare holds your files and can serve your site, and OpenRouter is a single doorway to many AI models so you're not locked to one vendor.

---

Before You Start

text
1. A Supabase account and organisation (pick your region — keep it close to users)
2. A Cloudflare account (for R2 storage and/or hosting)
3. An OpenRouter account and API key (for AI generation)
4. Your AI agent connected to Supabase via MCP, plus the Supabase CLI installed
5. CSV exports of your reference tables from the old Lovable project
6. Your schema written down — tables, functions, policies — as SQL or a spec
The golden rule for working with an AI agent on production: the agent plans and prepares; *you* approve anything irreversible. Creating a project, applying a migration, inserting a secret — those wait for your explicit "go."

---

Important Safety Note

Treat every database mutation as one-way until proven otherwise. An API key or a CRON secret is a password — never paste it into a public repo, a screenshot, or a blog post. If one ever appears in your agent's log, rotate it before you publish anything.

---

Step 1: Point the Agent at the Right Place First

Before creating anything, have the agent list your existing Supabase projects.

text
"List my Supabase projects."

You should see: your current projects. Confirm the target project does not already exist, and that the agent isn't about to build into an unrelated one. This thirty-second check prevents the worst-case mistake.

---

Step 2: Make the Agent Read the Plan and Stop

Don't let the agent improvise your backend. Give it the schema, any module specs, and a build runbook, then ask it to produce a numbered plan and do nothing yet.

text
"Read the schema and the runbook. Tell me, step by step, exactly what
you'll do, what you need from me, and where the first human gate is.
Do not create anything yet."

You should see: a numbered plan and a clear statement of the first approval gate. If the agent starts building instead, stop it. Planning before mutating is the whole discipline.

---

Step 3: Run the Cost Check, Then Approve the Project (Human Gate)

Creating a project is the first irreversible action. Have the agent run the cost check and present it before anything runs.

text
get_cost  →  confirm_cost

You should see: project name, organisation, region, and a confirmed cost (often $0/month on a free tier). Review it. Only then say go, and let the agent run create_project.

This is the most important pause in the whole process. Everything before it is reversible. Everything after it touches a live database.

---

Step 4: Apply the Schema in Dependency Order

Have the agent apply migrations in sequence — never all at once, so each is reviewable:

text
1. Extensions + custom types   (e.g. citext, pg_cron, pg_net, role enums)
2. Tables                      (in dependency order — parents before children)
3. Indexes
4. Grants                      (anon / authenticated / service_role)
5. Functions + triggers
6. Row-Level Security policies (the big one)

You should see: each migration applied and confirmed. After the tables step, ask the agent to count them and check the number matches your spec.

text
"List the tables and confirm the count is N."

---

Step 5: Store Secrets in the Vault (Human Gate)

Anything secret — a CRON secret, an API key the database needs — goes into Supabase Vault, not into a table or a policy.

text
"Generate a secure CRON secret and show me the Vault insert SQL.
I'll approve before it runs."

You should see: the SQL, for your approval, before execution. Approve, then have the agent verify the secret is readable from decrypted_secrets (the path your cron jobs will use).

---

Step 6: Schedule Only the Cron Jobs This Phase Needs

When moving off Lovable, some bundled background jobs won't apply to your new architecture. Tell the agent which to keep, drop, or defer — don't let it schedule everything blindly.

text
Keep:    jobs that call SQL functions or live edge functions
Drop:    jobs tied to the old app's behaviour you're removing
Defer:   jobs whose edge functions you'll rebuild in a later phase

You should see: only the relevant jobs scheduled, with any YOUR_SUPABASE_URL placeholders replaced by your real new project URL.

---

Step 7: Decide What's Worth Migrating

Not every row in your old database needs to come across. The cleanest migration is the one that brings what's reusable and leaves the rest. A useful triage:

text
ALWAYS MIGRATE   → reference / lookup tables (countries, categories,
                   taxonomies, calendars — anything the front end
                   filters or groups on)

OFTEN MIGRATE    → published content (articles, pages, completed records)

CAREFULLY        → user accounts and PII (only if you're keeping the same
                   auth provider AND the user_ids stay stable)

USUALLY DROP     → drafts, half-built test rows, cached/derived data
                   (it's faster to regenerate than to clean)

If you're switching auth providers or starting fresh on users, leave the user table behind. Reference data and published content almost always travel. Drafts and cache rarely do — and trying to bring them across is how you import the mess you wanted to leave.

---

Step 8: Clean the Data as You Seed It

This is your one cheap chance to fix dirty data. Before importing, run a distinct-value check on every column the front end filters or groups by.

sql
SELECT DISTINCT status_label FROM old_table;
-- spot the three spellings of the same thing

You should see: the inconsistencies (the same field labelled three different ways, casing drift, typos that propagated). Fix them in your seed-generation script so the corrected values land on first insert, then verify with a count after loading.

text
"After seeding, count the corrected values and confirm zero of the old ones remain."

---

Step 9: Load Seed Data with the CLI, Not the High-Level Tool

Large seed files (hundreds of rows) often exceed what an MCP tool or agent can push in one call. Use the Supabase CLI's file flag instead.

bash
supabase link
supabase db query --linked -f path/to/seed.sql

You should see: the full file loaded in one go. After all seeds run, verify every table's row count matches what you exported.

---

Step 10: Run the Security Audit

The penultimate check. Have the agent run the security advisor.

text
get_advisors(security)

You should see: a list of warnings. Triage every one — many are intentional (public signup forms allow open INSERT; helper functions used by RLS must be callable). The dangerous ones are trigger-only or cron-only functions that are reachable over the public API. Step 11 handles those.

---

Step 11: Harden Function Access — Revoke from PUBLIC, Not Just the Role

This is the single most-missed step in any Postgres hardening pass, including on Supabase. If the advisor flags functions as RPC-callable when they shouldn't be, the natural instinct is to REVOKE EXECUTE from anon and authenticated. That often does nothing.

The reason: in Postgres, every function gets EXECUTE granted to the PUBLIC role by default. anon and authenticated inherit it. Revoking from those roles doesn't touch the inheritance.

The fix is to revoke from PUBLIC first, then grant back only where needed:

sql
-- Strip the default
REVOKE EXECUTE ON FUNCTION my_trigger_only_fn() FROM PUBLIC;

-- For functions that legitimately need it (e.g. RLS helpers), grant back narrowly:
GRANT EXECUTE ON FUNCTION has_role(user_id uuid, required_role text)
  TO anon, authenticated;

You should see: the advisor warnings clear on re-run. Functions intended to be trigger-only or cron-only show no EXECUTE for anon/authenticated. Helpers used by RLS policies still work.

---

Common Problems and Fixes

Problem: Anonymous users can see draft content

Check that your content-table anon SELECT policy is scoped to published rows only.

text
articles anon SELECT  →  WHERE status = 'published'

If you forget this, every draft article is one bad URL away from being public.

Problem: The seed file won't load through the agent

It's a size limit, not a data problem. Drop to the CLI with supabase db query --linked -f.

Problem: A secret showed up in the agent's log

Treat it as exposed. Rotate it before publishing or sharing anything.

Problem: The PUBLIC revoke "did nothing" twice in a row

If a function still shows as RPC-callable after a REVOKE, you almost certainly revoked from the wrong role. Re-read Step 11 — the access is inherited from PUBLIC, not granted directly to anon.

---

Quick Checklist

text
[ ] Confirmed the target project doesn't already exist
[ ] Agent produced a plan and stopped before mutating
[ ] Cost checked and project creation approved at the gate
[ ] Schema applied in dependency order, table count verified
[ ] Secrets stored in Vault (approved before insert)
[ ] Only relevant cron jobs scheduled, URLs replaced
[ ] Migration scope decided (reference + published, not drafts/cache)
[ ] Data cleaned in the seed script, counts verified
[ ] Seed loaded via CLI, row counts match exports
[ ] Security advisor run and every warning triaged
[ ] PUBLIC revoke applied where needed; drafts hidden from anon

---

What's Happening Under the Hood

text
Layer 1 → Supabase (Postgres)     your data, auth, cron, and the RLS rules
                                  that decide who can read or write what
Layer 2 → Cloudflare R2 / Pages   your files (generated media, assets) and,
                                  optionally, where your site is served
Layer 3 → OpenRouter              one API key, many AI models — your
                                  generation layer, vendor-flexible
Layer 4 → The AI agent + MCP      executes the migration, but only mutates
                                  production after you approve each gate

The migration itself is mostly the Supabase layer. Cloudflare and OpenRouter wire in around it — storage and AI — once your data and rules are solid.

---

*All views are my own. This guide is shared for learning and experimentation.*