Migrating a Base44 app to Supabase, step by step
Move a Base44 app to Supabase: turn entities into tables, import the data, move users, replace Base44 SDK calls, and port backend functions and integrations.
14 min read
Moving a Base44 app to Supabase means rebuilding the backend your frontend talks to. The frontend stays: it’s standard React. You turn each Base44 entity into a Postgres table, import your CSV data, move your users, then replace every Base44 SDK call with its Supabase equivalent. Backend functions port well, because both platforms run them on Deno. The size of the job is the number of SDK calls in your code.
This is level 4 of the Base44 migration guide. Do levels 1 and 2 first: you need the code and the data exported.
Before you start: size the job
Export the code (here’s how) and count what you’ll replace:
grep -rhoE 'base44\.(entities\.[A-Za-z]+\.[a-zA-Z]+|auth\.[a-zA-Z]+|integrations\.Core\.[A-Za-z]+|functions\.[a-zA-Z]+|agents\.[a-zA-Z]+)' src \
| sort | uniq -c | sort -rn
Some generated apps import helpers instead of calling base44.* directly, for example import { Product } from '@/api/entities' or import { SendEmail } from '@/api/integrations'. Check src/api/ and search for those imports too.
Make a list: entities and their operations, auth calls, integrations, functions, agents. That list is your migration plan.
Step 1: create the Supabase project and tables
Each entity has a JSON schema defining its fields. Get them with base44 eject (they land in the project’s entities folder) or read them in the Data page. A typical schema translates like this:
| Base44 field type | Postgres column |
|---|---|
string | text |
string with format: date / date-time | date / timestamptz |
number / integer | numeric / integer |
boolean | boolean |
array, object | jsonb (or a separate table if you query into it) |
enum | text with a check constraint |
| Reference to another entity | text column holding the other record’s ID |
Base44 adds built-in fields to every record: an id, created_date, updated_date and created_by. Keep them.
create table public.product (
id text primary key, -- keep Base44's IDs so URLs survive
name text not null,
price numeric,
status text check (status in ('draft', 'published')),
tags jsonb default '[]'::jsonb,
created_date timestamptz default now(),
updated_date timestamptz default now(),
created_by text
);
alter table public.product enable row level security;
Step 2: import the data
Export each table to CSV from Base44’s Data page (details), then import it in Supabase’s table editor or with psql’s \copy. Import tables that others reference first. Check row counts on both sides before moving on.
Array and object fields arrive as JSON text in the CSV. If Supabase’s importer doesn’t parse them into jsonb, import them into a text column and convert with alter table ... alter column ... type jsonb using col::jsonb.
Step 3: move your users
Export the User table from the Data page. Base44 documents no way to export password hashes, so you can’t carry passwords over. Pick one:
- Import users and send reset links. Create each user in Supabase Auth with the admin API, then send a password reset email.
- Switch to magic links or social login. Users sign in with their email address or Google account, and nobody needs a password reset.
Keep a mapping from each user’s old Base44 ID to their new Supabase user ID, and update created_by and any user-reference columns with it.
Step 4: replace the SDK calls
This is most of the work. The common entity operations map directly:
| Base44 SDK | Supabase JS |
|---|---|
base44.entities.Product.list('-created_date', 10) | supabase.from('product').select('*').order('created_date', { ascending: false }).limit(10) |
base44.entities.Product.filter({ status: 'published' }) | supabase.from('product').select('*').eq('status', 'published') |
base44.entities.Product.get(id) | supabase.from('product').select('*').eq('id', id).single() |
base44.entities.Product.create(data) | supabase.from('product').insert(data).select().single() |
base44.entities.Product.update(id, data) | supabase.from('product').update(data).eq('id', id) |
base44.entities.Product.delete(id) | supabase.from('product').delete().eq('id', id) |
base44.auth.me() | supabase.auth.getUser() |
base44.auth.logout() | supabase.auth.signOut() |
base44.auth.loginWithProvider('google') | supabase.auth.signInWithOAuth({ provider: 'google' }) |
base44.integrations.Core.UploadFile(...) | supabase.storage.from('uploads').upload(path, file) |
Supabase returns { data, error } where Base44 returns the records directly, so each call site needs a small change in how it reads the result. A thin wrapper module with the same method names as your Base44 entities keeps the rest of the code untouched:
// src/api/db.js
import { supabase } from './supabase'
const table = (name) => ({
list: async (order = '-created_date', limit = 50) => {
const col = order.replace(/^-/, '')
const { data, error } = await supabase.from(name).select('*')
.order(col, { ascending: !order.startsWith('-') }).limit(limit)
if (error) throw error
return data
},
filter: async (where) => {
const { data, error } = await supabase.from(name).select('*').match(where)
if (error) throw error
return data
},
get: async (id) => {
const { data, error } = await supabase.from(name).select('*').eq('id', id).single()
if (error) throw error
return data
},
})
export const Product = table('product')
Base44’s filter() also accepts MongoDB-style operators such as $in and $gte. Those need translating by hand (.in(), .gte()), so search for $ inside filter calls.
Step 5: replace permissions with row level security
Base44 entities have security rules that decide who can read and write each record. In Supabase that’s row level security. Recreate each rule as a policy, for example “users can read their own records”:
create policy "own rows" on public.product
for select using (created_by = auth.uid()::text);
Test every policy by signing in as an ordinary user. A table with RLS enabled and no policies returns nothing, which is safe but confusing. A table without RLS is open to anyone with your public key.
Step 6: port backend functions and integrations
Base44 backend functions run on Deno, and so do Supabase Edge Functions, so the logic usually ports with two changes: read data through a Supabase client instead of createClientFromRequest, and read secrets from Deno.env.get() instead of base44:runtime.
Built-in integrations need a provider of your own:
| Base44 built-in | Replacement |
|---|---|
| SendEmail | Resend, Postmark or SES, called from an Edge Function |
| InvokeLLM | OpenAI, Anthropic or another API, called from an Edge Function |
| UploadFile, CreateFileSignedUrl | Supabase Storage |
| GenerateImage | An image API |
| ExtractDataFromUploadedFile | An LLM with file input, or a document-parsing API |
| Automations (scheduled) | pg_cron or a scheduled Edge Function |
| Automations (on data change) | Database webhooks or triggers |
If your app used a contact form that emailed you through SendEmail, a hosted form backend can replace both the email and the storage with no function at all. See the contact form guide.
Step 7: move the files
Uploaded files live on Base44’s storage, and your records hold their URLs. Download each file, upload it to Supabase Storage, and rewrite the URL in the record. Do it before you close your Base44 account.
Step 8: host it and switch over
Build the frontend and deploy it (see self-hosting), point your domain at the new host, and recreate redirects. Remember that Base44’s SEO pre-rendering stays behind: a client-rendered React app on Vercel or Netlify serves crawlers an empty shell until you add pre-rendering or move to a framework with server rendering.
What to do next
- Run the SDK count and write the migration list.
- Create the Supabase tables with Base44’s IDs as primary keys.
- Migrate one entity end to end, from table to UI, before touching the rest.
Frequently asked questions
- Can Base44 use Supabase as its database?
- Not as a drop-in replacement for Base44's own database. You can call Supabase from backend functions or custom integrations, but Base44's entities, auth and built-in integrations run on Base44. A full move to Supabase means replacing the Base44 SDK calls in your code.
- How long does a Base44 to Supabase migration take?
- It depends on how many SDK calls your app makes. An app with a few entities and a few dozen calls can move in a weekend. One with hundreds of calls, AI agents, automations and many integrations is a multi-week project.
- Can I keep my users' passwords when leaving Base44?
- Base44 doesn't document a way to export password hashes. Import your users' records into Supabase and send each user a password reset link or switch them to magic-link or social login.
- Will my record URLs change when I move to Supabase?
- They don't have to. Base44 record IDs are strings. Keep them as the primary key in Supabase, as a text column, and your existing URLs like /Product?id=... keep working.
- What replaces Base44 backend functions in Supabase?
- Supabase Edge Functions. Both run on Deno and TypeScript, so most function logic moves across with changes only to how the function reads data and secrets.
Read next
Keep going
-
Leaving or extending Base44: the export and migration guide
What you can take out of Base44 and what stays: exporting code, GitHub sync, data export, and migrating the backend to Supabase or your own hosting, step by step.
-
How to export your Base44 code (ZIP, GitHub or CLI)
Three ways to export Base44 code: download a ZIP, sync to GitHub, or eject with the CLI. What each includes, which plan you need, and what the export can't take with it.
-
Exporting your Base44 data, users and files
Export Base44 data table by table to CSV, get your entity schemas and users out, download uploaded files, and use backups and recently deleted records.
-
Self-hosting a Base44 app on Vercel, Netlify or Cloudflare
Build an exported Base44 app and deploy it to Vercel, Netlify or Cloudflare. What keeps working on Base44's backend, what breaks, and the SEO step people forget.