Moving a Base44 app to Supabase and Vercel splits into four jobs, and one carries the cost. The frontend is a Vite React build Vercel hosts almost as-is: 754 of 778 confirmed Base44 exports carry Vite. The rows copy across. The backend functions get rehomed. The data model has to be rebuilt by hand, because Base44 documents no relation type.
That last job is the part every page currently ranking for this question skips. Four separate products will sell it to you. A migration shop called base44devs quotes fixed prices from $6,000 for up to five tables and ten routes, and $12,000 for up to twenty tables (prices checked 15 August 2026). Upgrade44 and Staticbot sell it automated. A service called escapebase44 argues against the rebuild entirely and sells moving your app unchanged instead. None of them publishes what the mapping actually is. This page does.
Everything below is built from Base44’s, Supabase’s, PostgreSQL’s and Vite’s current documentation, read on 15 August 2026, plus a dataset of 778 confirmed Base44 exports on GitHub. This is a documented analysis rather than a report of a migration I ran end to end. Check every command against your own app before you trust it.
What you have to rebuild, and what just moves
Four things live in a Base44 app, and they move at four different costs.
| What it is | Comes out? | What that costs you |
|---|---|---|
| Frontend React code | Yes | A build setting on Vercel |
| Entity schemas (the shape of your data) | Yes, as JSON Schema files | The rebuild. This is the job |
| Your actual rows | Yes, as an export | An import script and one careful filter |
| Backend functions, agents, integrations | Partly, as source | They need somewhere new to run |
The documented way to get all of it locally is the CLI. Install it with npm install -g base44@latest, then run base44 eject, which the docs describe as downloading “your app’s frontend code and backend resources locally” and note that “your entity schemas are copied to the new project, but data is not.” Entity schemas land as .jsonc files under base44/entities/, functions under base44/functions/<name>/entry.ts, auth config at base44/auth/config.jsonc, connectors under base44/connectors/. One file, .app.jsonc, the docs say explicitly should not be committed to version control.
If you connected your app to GitHub instead of using the CLI, Base44 documents a different on-disk layout and a different answer. Its Code tab project-structure page puts entities/ and functions/ at the top level rather than under base44/, and carries this Note: “Entities: When using GitHub 2-way integration, entities are managed in Base44 and are not included in your local repository.” Base44’s GitHub integration page describes the sync itself and says there is no manual push, without listing directories. So take the thirty-second check rather than my word or anyone else’s: open your synced repository and look for entity schema files. If they are not there, run base44 eject. Nothing else in the move depends on which answer you get.
The backend is the part that does not come out, and the distinction is between the files and the thing that runs them. In the CLI layout the function source lands under base44/functions/<name>/entry.ts and the entity schemas under base44/entities/, so you can read every line. What stays on Base44 is the runtime those files were written against: the entity store they query, the auth context they read, and the platform services they call. The full telling of what those exports contain lives in the Base44 review built on all 778 repositories, and what you can and cannot export from Base44 covers the ownership question underneath it. A dedicated page on the export’s file-by-file contents is queued.
One line from that dataset is worth carrying into the Vercel section. Of the 778 exports, 773 carry react, 754 carry vite, 769 carry tailwindcss, 622 carry typescript, and 14 carry next. The top-ranking migration story for this query rebuilt into Next.js. On this evidence that is a rewrite fewer than two percent of Base44 apps have any reason to do.
Mapping Base44 entities to Postgres tables
Base44 documents its entity format as JSON Schema with six field types: string, integer, number, boolean, array and object. enum is a validation on a string, not a type of its own. There is no reference type, no relation type, and no foreign key anywhere in the format. Underneath, Base44’s own docs say the database “is MongoDB compatible” and that “schemas are not enforced, so you can update your data model at any point without running migrations.”
Postgres is the opposite of all three of those properties, which is why migrating Base44 to Supabase is work rather than a copy. Here is every construct, and what it becomes. (Why Supabase is the usual destination is a separate question with its own answer; this table assumes you have already decided.)
| Base44 construct | Postgres column | Constraint to add | Note |
|---|---|---|---|
"type": "string" | text | none | Postgres text has no length penalty |
"type": "string", "format": "date-time" | timestamptz | none | Store UTC, convert on read |
"type": "string", "format": "date" | date | none | |
"type": "integer" | integer or bigint | none | bigint if it is a count that can grow |
"type": "number" | numeric(p,s) | none | Never float for money |
"type": "boolean" | boolean | none | |
"type": "array" | jsonb or text[] | none | text[] only if the items are all strings |
"type": "object" | jsonb | none | |
"enum": ["a","b"] on a string | text | check (col in ('a','b')) | The check is new. Base44 did not enforce it |
"required": ["title"] | that column | not null | Existing rows may violate it. Backfill first |
"default": <value> | that column | default <value> | |
Built-in id | text primary key | Base44 ids are strings, not uuids. Keep them | |
Built-in created_date / updated_date | timestamptz | default now() | |
Built-in created_by | text | see below | Holds an email, not a user id |
Built-in created_by_id | text | Base44’s own user id, not a Supabase one | |
Built-in is_deleted / deleted_date | do not create | Filter on import, then drop | |
is_sample, entity_name, app_id, environment | do not create | Platform bookkeeping. Drop them | |
| A string field holding another record’s id | text | references other_table (id) | The relationship you have to add yourself |
The type names above are Postgres’s own: text, integer, bigint, numeric, boolean, timestamp with time zone (spelled timestamptz), jsonb, uuid.
Take a real pair of entities. A course with an enum, a default and a required field, and an enrollment that points at it by id:
// base44/entities/Course.jsonc
{
"name": "Course",
"type": "object",
"properties": {
"title": { "type": "string" },
"status": { "type": "string", "enum": ["draft", "open", "closed"], "default": "draft" },
"seats": { "type": "integer", "default": 20 },
"price": { "type": "number" },
"starts_at": { "type": "string", "format": "date-time" },
"is_public": { "type": "boolean", "default": false },
"tags": { "type": "array", "items": { "type": "string" } }
},
"required": ["title", "starts_at"],
"rls": {
"create": { "user_condition": { "role": "admin" } },
"read": true,
"update": { "created_by": "{{user.email}}" },
"delete": { "user_condition": { "role": "admin" } }
}
}
// base44/entities/Enrollment.jsonc
{
"name": "Enrollment",
"type": "object",
"properties": {
"course_id": { "type": "string" },
"student_email": { "type": "string" },
"completed": { "type": "boolean", "default": false }
},
"required": ["course_id", "student_email"],
"rls": {
"create": true,
"read": { "created_by": "{{user.email}}" },
"update": { "created_by": "{{user.email}}" },
"delete": { "user_condition": { "role": "admin" } }
}
}
Nothing in those two files says that course_id points at a course. A human reading it knows. Postgres does not, until you say so:
create table public.courses (
id text primary key,
title text not null,
status text not null default 'draft'
check (status in ('draft', 'open', 'closed')),
seats integer not null default 20,
price numeric(10,2),
starts_at timestamptz not null,
is_public boolean not null default false,
tags jsonb not null default '[]'::jsonb,
created_date timestamptz not null default now(),
updated_date timestamptz not null default now(),
created_by text not null,
owner_id uuid references auth.users (id)
);
create table public.enrollments (
id text primary key,
course_id text not null references public.courses (id) on delete restrict,
student_email text not null,
completed boolean not null default false,
created_date timestamptz not null default now(),
updated_date timestamptz not null default now(),
created_by text not null,
owner_id uuid references auth.users (id)
);
create index on public.enrollments (course_id);
create index on public.enrollments (owner_id);
Two things in that DDL have no equivalent in the Base44 files: the references clause on course_id, and the owner_id column. Both are the next section.
The three things that break in the mapping
Three traps are visible in Base44’s documentation before you import a single row. Every one of them will pass a build and fail in production.
created_by is an email address, not a user id
Base44’s security documentation is explicit that created_by stores the “Email of user who created the record”, and the ownership idiom in every Base44 app is written against it: {"created_by": "{{user.email}}"}.
Supabase’s convention runs on auth.uid(), which returns “the ID of the user making the request” as a uuid. So every ownership rule in your app needs a decision, and the two options fail differently.
Match on the email claim in the token, and the policy stays a one-line translation:
using (lower((select auth.jwt()) ->> 'email') = lower(created_by))
That works on day one and breaks the day a user changes their email address in Supabase Auth, because every row they own is still stamped with the old string. Add a real owner_id uuid column and backfill it instead, and the failure moves to import time where you can see it:
update public.courses c
set owner_id = u.id
from auth.users u
where lower(u.email) = lower(c.created_by)
and c.owner_id is null;
-- Expected after a clean backfill: 0.
select count(*) from public.courses where owner_id is null;
Any number above zero is a row whose creator has not signed up on the new stack yet, or an email that was changed inside Base44. Decide what those rows do before you switch traffic, because a null owner_id under a auth.uid() = owner_id policy makes the row invisible to everybody, including its owner.
There are no foreign keys to export, and adding them surfaces broken data
Because Base44’s format has no reference type, a relationship in a Base44 app is a plain string field holding another record’s id, unconstrained and unchecked. It can already be pointing at nothing. Nothing ever stopped somebody deleting a course while enrollments still pointed at it, so those enrollments are sitting in the export with a course_id that matches no course.
Run the check on your staging import before you create the constraint:
-- Expected output: 0 rows.
select e.id, e.course_id
from staging_enrollments e
left join staging_courses c on c.id = e.course_id
where c.id is null;
If that returns rows and you skip straight to the create table above, Postgres refuses the import with insert or update on table "enrollments" violates foreign key constraint. That error is the good outcome. It is the first time anything has told you the data was broken, and it is worth reading the returned ids rather than deleting the constraint to make the message go away.
is_deleted rows arrive alive
Every Base44 entity carries internal is_deleted and deleted_date fields. Records the app treats as gone are still in the export. A straight import brings them back, and they show up in the new app as orders that were cancelled, users who left, and drafts somebody deliberately threw away.
Filter at import, once:
insert into public.courses (id, title, status, seats, price, starts_at, is_public, tags,
created_date, updated_date, created_by)
select id, title, status, seats, price, starts_at, is_public, tags,
created_date, updated_date, created_by
from staging_courses
where is_deleted is not true;
Then compare the row count against what the Base44 dashboard shows for that entity. If the numbers differ, you know which direction to look.
Turning Base44 security rules into Supabase policies
Base44 row level security lives in an rls block on the entity. The keys are create, read, update and delete, and each takes true, false, or a condition object. Conditions support $or, $and, $nor, $in, $nin and $all, interpolate {{user.email}}, {{user.id}}, {{user.role}} and {{user.data.*}}, and check roles through {"user_condition": {"role": "admin"}}. Individual fields can carry their own rls block with read and write, which has no direct Postgres equivalent at all: field-level control becomes a view, a column grant, or a check inside the policy.
The Course entity above becomes three policies:
alter table public.courses enable row level security;
-- Base44: "read": true
create policy "signed-in users read courses"
on public.courses for select
to authenticated
using (true);
-- Base44: "update": {"created_by": "{{user.email}}"}
create policy "owners update their own courses"
on public.courses for update
to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);
-- Base44: "delete": {"user_condition": {"role": "admin"}}
create policy "admins delete courses"
on public.courses for delete
to authenticated
using (((select auth.jwt()) -> 'app_metadata' ->> 'role') = 'admin');
Three details in that block are easy to get wrong. The select wrapper around auth.uid() is Supabase’s own performance advice: it lets Postgres cache the result per statement instead of calling the function once per row. with check on the update policy is what stops a user handing their row to somebody else, and a policy with only using will let them. And the role lives in app_metadata, not user_metadata, because Supabase documents that user metadata can be edited by the end user while app metadata cannot.
One line in that block is a decision rather than a translation. Base44 documents "read": true as allow-all, and the policy above narrows it to to authenticated, which is deliberate: most Base44 apps put every screen behind a sign-in anyway, and a signed-in-only read is the safer default to land on. If your app really was meant to be readable by anyone, grant the policy to anon as well and mean it.
That last point is where Base44’s role model does not survive the trip. Base44’s built-in User entity carries full_name, email and a role that is “either admin or user”, and you can add your own fields to it. Supabase has no role column on auth.users waiting for you. You either put the role into the JWT’s app metadata, which requires setting it server-side, or you keep a profiles table keyed on auth.users(id) and join to it in the policy. Both work. Neither is a copy.
The other thing worth knowing before you start: a table with RLS enabled and no policy at all returns nothing through the API. That is a safe default and a confusing afternoon if you enable it on ten tables and write policies for six. Write the policies, then prove them with a second account rather than reading them, because a policy that looks right and a policy that holds are different claims. Testing Supabase RLS properly covers the second-account method and what a real test asserts.
Moving the frontend to Vercel
This is the shortest section on purpose, because the Base44 to Vercel half of the move is a build setting. A Base44 frontend is a Vite React single-page app in 754 of the 778 exports, and Vite’s build output goes to dist by default. On Vercel that is a build command of npm run build and an output directory of dist. That is the whole deployment.
Two things to set and one to check.
Environment variables come first. Vite exposes any variable prefixed VITE_ to the browser bundle, so VITE_SUPABASE_URL and your publishable key belong there and nothing else does. Supabase’s API keys documentation is direct about the other kind: secret and service_role keys “provide full access to your project’s data, bypassing Row Level Security”, and you should “never use in a browser, even on localhost”. A secret key behind a VITE_ prefix ships to every visitor in a JavaScript file. How environment variables actually work across build and runtime is the longer version of that rule.
Then replace the generated src/api/base44Client.js call surface. Base44’s SDK gives you get, list, filter, create, bulkCreate, update, delete, deleteMany, importEntities and subscribe, with list(sortField, limit, skip, fieldSelection) and filter(criteria, sortField, limit, skip), a - prefix for descending sort, and equality criteria in the documented filter examples. Every one has a supabase-js equivalent, and the query syntax is close enough that the swap is mechanical:
// Base44
const open = await base44.entities.Course.filter({ status: "open" }, "-starts_at", 20, 0);
// Supabase
const { data: open, error } = await supabase
.from("courses")
.select("*")
.eq("status", "open")
.order("starts_at", { ascending: false })
.range(0, 19);
The difference that bites is the destructuring. Base44’s SDK hands you the records. supabase-js hands you { data, error } and does not throw, so every call site that used to be one line now needs an error branch, and the ones you skip fail silently with an empty list instead of a message. Grep for .from( after the swap and confirm each one reads error.
The check: load a deep link directly, not by clicking through from the home page. A single-page app served without a fallback rewrite returns a 404 on any route the server does not have a file for, and clicking through from the root hides that completely.
Who rewrites the backend, and can Claude Code do it?
Claude Code can write the DDL, the policies and the import script for a Base44 exit. It cannot host anything. What it needs from you is the 18 mapping rows above and the three traps, in the prompt, alongside your entity files. Where the backend functions actually run stays a separate decision that no coding tool makes for you.
Moving from Base44 to Claude Code is really two moves, and in the Base44 community the ask usually arrives as a plea for steps. One post from July 2026: “I don’t like being tied to the Base44 database and machine availability, and I want to move my entire platform to Claude code. What is the best way to do this, and how should I go about it? I would appreciate it if someone could point me to a step-by-step procedure.” The same person mentions having over 100,000 records while only being able to work with a few thousand.
The mapping table above is the part of that procedure nobody had written down, and it is the part an assistant is good at once you hand it the constraints. Give it your base44/entities/*.jsonc files, the mapping table, and the three traps together. An assistant reading only the entity files will type created_by as a uuid and reference auth.users, because that is what every Supabase example on the internet looks like.
Supabase Edge Functions, a small server, and a Vercel function are all reasonable homes for what used to be a Base44 function, and all three are different from the runtime the code was written against.
What has no destination
Some of the app does not move. It gets rebuilt or it gets dropped, and knowing which before you start is the difference between a two-week move and a surprise.
Base44 backend functions come out as TypeScript under base44/functions/<name>/entry.ts, so the source survives. The runtime around them does not: the entity SDK they call, the auth context they read, and the platform services they reach are all Base44’s. Treat every function as a rewrite that happens to have a working reference implementation.
Built-in integrations are the sharper edge. If your app calls Base44’s LLM invocation, email sending, file upload, image generation or file data extraction, none of those exist on the other side. The open-source Ai-Automators Base44 to Supabase SDK is honest about this in its own README: InvokeLLM, SendEmail, UploadFile, GenerateImage, ExtractDataFromUploadedFile and verifyHcaptcha ship as what it calls “intelligent placeholders” that “return properly formatted responses” and “provide mock responses for development/testing”. Six functions that look like they work. Read that README before you adopt the SDK, not after.
The same SDK is worth understanding on the schema question, since it is the top organic result for this query. Its stated mechanism is proxy auto-discovery: “SDK automatically: BlogPost → creates CustomEntity(‘blog_posts’)”. That is a name transform. It creates a table when your code first asks for one, rather than from your entity JSON Schema, so the column types, check constraints, not-null rules and foreign keys in the table above do not arrive with it. It is MIT licensed and it will get your app talking to Supabase. It will not give you the data model. The same README also sells the human version of the job: “Migration services start at $2,500.”
File storage is the quiet one. Anything uploaded through Base44 has a URL pointing at Base44, and those URLs become dead links at whatever point your app is no longer a Base44 app. Move the files and rewrite the stored URLs in the same pass as the row import, or the images disappear a month later with no error anywhere.
Who should not do this
The most useful post I have read on leaving Base44 was written by somebody who did it and then wrote the second half.
The first half is the familiar one. An owner running a platform for students hit slow services, rate limits and trouble with simple table reads at real volume, and started a long migration because of it. The second half is the part nobody selling a migration will quote. A few weeks away from Base44 taught them the pains of running their own code, and Base44 shipped improvements while they were gone.
Three triggers send people to this page, and two are usually fixable without moving. Cost is the first, and it is worth pricing weeks of rebuilding against what staying on Base44 actually costs. Speed is the second: slow table reads and rate limits at a few hundred users are often a query problem that follows you to Postgres, and the reasons AI-built apps stall around 100 users are mostly reasons a new host does not fix. Control is the third, and it is the one that genuinely requires moving. If the answer to “why are you doing this” is a compliance requirement, a customer contract, or needing a database connection string, no amount of tuning inside Base44 gets you there.
Two boundaries worth stating plainly. Moving off Base44 does not by itself produce a phone app: the export is a web build, and getting it into the App Store or Google Play is a separate job with its own requirements. And if Supabase turns out to be wrong for your constraints, the Supabase alternatives worth considering covers that decision before you write any DDL. The same move off Lovable Cloud hits a different set of walls, which is worth reading if you are choosing between builders rather than leaving one.
A Base44 exit is a partial rebuild. The frontend and the rows are close to free. The data model, the security rules and the functions are the work, whether you do it, an assistant does it with you, or you pay somebody. Anyone quoting you a number without having read your entity files is quoting for a different app.
Common questions
What database does Base44 use?
Base44’s own developer documentation says the database “is MongoDB compatible, allowing you to use all MongoDB operators when querying through the SDK”, and that “schemas are not enforced, so you can update your data model at any point without running migrations.” Your entities are collections of documents rather than tables with columns, which is why moving to Postgres means writing a data model rather than exporting one.
Can Base44 connect to Supabase?
Not as its own database. Base44 stores records in its own hosted database and reaches them through its SDK and dashboard, so pointing a Base44 app at your Supabase project is not a setting. Connecting to Supabase means replacing the Base44 client in your frontend code with the Supabase client, after you have created the tables and policies yourself.
Can I export my Base44 app?
Yes: base44 eject downloads the frontend code and the backend resources locally, and the GitHub integration syncs the app to a repository. What each route contains, file by file, is a separate page.
Does Base44 export the database?
No: base44 eject copies entity schemas but, in Base44’s own words, “data is not” copied, so the rows come out separately through the dashboard’s data export. That export is the file you filter on is_deleted before importing anywhere.
Does the export include backend functions?
Yes, as source code. Functions land at base44/functions/<name>/entry.ts in an ejected project. What does not come with them is the runtime they were written for, including the entity SDK, the auth context, and Base44’s built-in integrations, so each function needs a new host and a rewritten client.
Do I have to rebuild in Next.js to deploy on Vercel?
Almost certainly not. Across 778 confirmed Base44 exports checked in August 2026, 754 carry Vite and 14 carry Next.js. Vercel hosts a Vite build with a build command and an output directory, so a framework rewrite is a choice somebody made, not a requirement of the destination.
How do I migrate from Base44 to Supabase?
A Base44 Supabase migration runs in this order: eject the app locally, translate each entity .jsonc file into Postgres DDL, create the tables without the foreign keys, load the rows with is_deleted filtered out, check for dangling references, add the foreign keys, write the RLS policies, then swap the frontend client. The order matters because foreign keys added before the data arrives will reject a load that a check would have explained.
Can I migrate away from Base44 without a developer?
The frontend move and the data export are within reach if you are comfortable with a terminal. The data model is not, and that is the honest answer rather than a sales one: writing check constraints, foreign keys and RLS policies means being able to tell a policy that works from a policy that only looks like it works. The failure mode of getting it wrong is quiet, because an over-permissive policy passes every test you would think to run.
Ready to move off the builder?
We move your working app off the builder, keep what works, and set up the hosting, data, and release path needed afterward.