Direct answer
A base44 to nextjs supabase migration is a 4-to-14-week project that moves your application off the Base44 managed platform onto a self-controlled Next.js frontend and Supabase backend. The work breaks into six phases: validate the trigger conditions are real, decouple the Base44 SDK behind an abstraction layer, export and normalize the schema into Postgres, translate Base44 RLS rules into Supabase RLS policies, migrate OAuth and password users into Supabase Auth, port backend functions into Next.js API routes or Supabase Edge Functions, then run a parallel-write cutover with a rehearsed rollback path. The single highest-risk surface is RLS translation; the single biggest timeline driver is SDK coupling depth.
Base44 is the right platform for some apps and the wrong platform for others. The migration playbook below assumes you already know you need to leave — if you are still deciding, read when to leave Base44 first, and if the open question is whether Supabase alone could replace Base44 as your backend, our Base44 vs Supabase comparison sets the integrated builder against assembling your own frontend on a backend-as-a-service. What follows is the operational document we hand to clients on day one of a /migrate engagement.
When a Base44 to Next.js Supabase migration is justified: five triggers
Not every team that asks about migrating needs to, and a /migrate conversation can end with us recommending you stay on Base44 and fix the underlying issue inside the platform. The migration is justified only when one or more of the following is true.
Trigger 1: SEO is your primary acquisition channel. Base44 apps render client-side for visitors, but Base44 serves search engines and AI crawlers a prerendered version of each page — real headings, content, meta tags and structured data — refreshed when you publish (Base44 SEO docs, read 23 Sep 2026). So when a Base44 app does not show in Google, the cause is usually configuration first: the SEO toggle switched off (which disables prerendering), pages behind login or set to noindex, or a new domain Google has not crawled yet. We covered those diagnostics in base44 app not showing in Google. The limits that remain are real but narrower: per-page meta descriptions are manual, the automatic unique titles cover only pages without URL parameters, a page whose render is not ready yet falls back to a structured snapshot, and visitors still wait on client-side rendering. If SEO is your main growth lever and you need server rendering for every visitor plus full control of the HTML each page ships, that is what Next.js SSR buys.
Trigger 2: Your data model exceeds Base44 limits. Base44 returns at most 5,000 records per request, a limit its docs date to November 2025. The SDK now pages with a cursor and has deleteMany and updateMany, but full-table work still moves in 5,000-record pages inside a 5-minute function limit. If your app has tables past 5,000 rows that need full-table scans or batch operations, see how bulk deletes behave at scale. Either of these conditions alone justifies migration for data-heavy apps.
Trigger 3: You need observability and logs that survive past 24 hours. Base44 keeps function logs for a short window. Post-incident forensics on anything older than a day requires shipping logs out via fetch to an external service. Teams that need SOC 2 readiness, HIPAA-adjacent retention, or detailed incident review cannot operate inside the platform's observability boundary.
Trigger 4: Vendor risk has become unacceptable. Base44 was acquired by Wix in 2025. Pricing changed. Base44 publishes no uptime SLA for self-serve plans; Enterprise customers get SLA commitments in their contract (base44.com/security). For a side project this is fine. For a business with revenue tied to the app, the vendor lock-in via SDK dependency becomes a board-level risk that warrants migration before it becomes a forced migration.
Trigger 5: Backend complexity has outgrown the platform. Base44 backend functions run on Deno with a curated dependency list. Apps that need fine-grained scheduling, long-running jobs, queue-based processing, GPU inference, or specific Node libraries that Deno does not support hit a ceiling that the platform will not raise. Once we count more than three functions whose dependencies fail unsupported dependency rejection, migration is usually the cheaper path than continued workarounds.
If exactly one trigger applies and the app is small, a path-B partial migration is often the right answer — keep the app on Base44, move only the SEO-critical surface to Next.js. If two or more triggers apply, full migration is justified.
Phase 1: SDK decoupling pre-work
The work that determines whether the migration takes four weeks or fourteen happens before any Next.js code gets written. A Base44 app calls the SDK directly from React components: entities.Product.list(), entities.Order.create(), currentUser() scattered across every file that touches data. Migrating this directly to Supabase means rewriting every component.
The decoupling pattern is a single abstraction module that wraps the SDK. Every component imports from the abstraction; the abstraction imports from the Base44 SDK today and will import from a Supabase client tomorrow.
// src/data/client.ts — the abstraction layer
import { base44 } from "@/integrations/base44";
export type Product = {
id: string;
name: string;
priceCents: number;
ownerId: string;
};
export const productClient = {
async list(opts: { ownerId?: string; limit?: number } = {}): Promise<Product[]> {
const rows = await base44.entities.Product.list(opts);
return rows.map(normalizeProduct);
},
async get(id: string): Promise<Product | null> {
const row = await base44.entities.Product.get(id);
return row ? normalizeProduct(row) : null;
},
async create(input: Omit<Product, "id">): Promise<Product> {
const row = await base44.entities.Product.create(input);
return normalizeProduct(row);
},
};
function normalizeProduct(row: any): Product {
return {
id: String(row.id),
name: String(row.name ?? ""),
priceCents: Number(row.price_cents ?? 0),
ownerId: String(row.owner_id ?? ""),
};
}
The work in phase 1 is to grep every direct SDK call in the codebase, count the call sites by entity, build one client module per entity, and rewrite the call sites. On a 60-component app, budget 5 to 10 working days. The output is a Base44 app that runs unchanged but has a single seam where the data layer can be swapped.
The same pattern applies to auth, file storage, and integrations. Build an authClient, a storageClient, an integrationsClient. By the end of phase 1 the rest of the codebase has zero direct references to @base44/sdk.
Building the abstraction can also surface Base44-specific patterns, such as optimistic-update logic that depended on Base44's synchronous response shape, or implicit owner-scoping that the SDK did silently. Catching these in phase 1 prevents them from becoming production bugs in phase 6.
Phase 2: Schema export and normalization
The data layer is where a base44 to nextjs supabase migration most often goes wrong, so phase 2 gets disproportionate attention. Base44 exposes entity schemas through the IDE and through the export endpoint. The export gives you JSON dumps of each entity with field definitions and row data. The schema needs three transformations before it lands in Postgres.
Transformation 1: Type normalization. Base44 fields are loosely typed in practice. Export a numeric field and you will often find a mix of 42, "42", null, and occasionally "". Postgres will reject the import. The normalization script casts each field to its intended type, fills nulls where the column allows, and writes a report of rows that could not be normalized for manual review.
// scripts/normalize-products.ts
import fs from "node:fs";
import path from "node:path";
type RawProduct = Record<string, unknown>;
type CleanProduct = {
id: string;
name: string;
price_cents: number;
owner_id: string;
created_at: string;
};
const raw = JSON.parse(
fs.readFileSync(path.join("export", "products.json"), "utf8")
) as RawProduct[];
const problems: Array<{ id: unknown; issue: string }> = [];
const clean: CleanProduct[] = [];
for (const row of raw) {
const id = row.id == null ? null : String(row.id);
const name = row.name == null ? null : String(row.name);
const priceRaw = row.price_cents ?? row.priceCents;
const price = typeof priceRaw === "number" ? priceRaw : Number(priceRaw);
if (!id || !name || !Number.isFinite(price)) {
problems.push({ id, issue: "missing id/name/price" });
continue;
}
clean.push({
id,
name,
price_cents: Math.round(price),
owner_id: String(row.owner_id ?? row.ownerId ?? ""),
created_at: String(row.created_at ?? new Date().toISOString()),
});
}
fs.writeFileSync("import/products.json", JSON.stringify(clean, null, 2));
fs.writeFileSync("import/products.problems.json", JSON.stringify(problems, null, 2));
console.log(`Cleaned: ${clean.length}. Problems: ${problems.length}.`);
Transformation 2: Relationship rebuilding. Base44 stores references as raw IDs. Postgres benefits from foreign keys with cascade behavior defined. Write the schema migration with the foreign keys and indexes in place; do not import data and add constraints later.
Transformation 3: Timestamp normalization. Base44 stores created_at and updated_at in different shapes across entity types. Normalize everything to ISO 8601 in UTC during export. Postgres timestamptz accepts this directly.
The deliverable from phase 2 is a Supabase schema migration file and a set of newline-delimited JSON files ready for COPY import. Run the import into a Supabase staging project, then run assertions: row counts match, foreign keys resolve, unique constraints are not violated. Only after assertions pass do we promote the schema to the production Supabase project.
Phase 3: RLS translation to Supabase policies
Base44 RLS rules are JavaScript-flavored expressions attached to entities through the IDE. Supabase RLS is Postgres policies attached to tables. The translation is mechanical for common patterns and surgical for the rest.
The common patterns we translate automatically with a porting script:
-- Owner-only read pattern
-- Base44: row.owner_id == currentUser.id
create policy "owner can read"
on products
for select
using (auth.uid() = owner_id);
-- Owner-only write
create policy "owner can update"
on products
for update
using (auth.uid() = owner_id)
with check (auth.uid() = owner_id);
-- Role-gated read (admin or owner)
create policy "admin or owner can read"
on orders
for select
using (
auth.uid() = customer_id
or (auth.jwt() ->> 'role') = 'admin'
);
-- Organization-scoped (multi-tenant)
create policy "org members can read"
on documents
for select
using (
org_id in (
select org_id
from org_members
where user_id = auth.uid()
)
);
Rules that fit one of these four templates port automatically. Rules that involve joins to two or more tables, time-bounded access, or references to computed fields do not. Those get hand-written and reviewed by the engineer who wrote the original Base44 rule wherever possible.
Every translated policy ships with a verification harness. We create test users — one per role — and run a script that asserts read and write outcomes against a fixture dataset.
// scripts/verify-rls.ts
import { createClient } from "@supabase/supabase-js";
const url = process.env.SUPABASE_URL!;
const anon = process.env.SUPABASE_ANON_KEY!;
async function assertCannotRead(jwt: string, table: string, expectedRows: number) {
const supa = createClient(url, anon, {
global: { headers: { Authorization: `Bearer ${jwt}` } },
});
const { data, error } = await supa.from(table).select("id");
if (error) throw error;
if (data.length !== expectedRows) {
throw new Error(
`RLS leak on ${table}: expected ${expectedRows} rows for this role, got ${data.length}`
);
}
}
await assertCannotRead(process.env.JWT_CUSTOMER!, "orders", 3); // own orders only
await assertCannotRead(process.env.JWT_ADMIN!, "orders", 412); // all orders
await assertCannotRead(process.env.JWT_OUTSIDER!, "orders", 0); // no orders
Skipping this harness is how RLS bugs ship to production. For example, if an organization-scoped Base44 rule is mistranslated as using (auth.uid() is not null) on org_members, any authenticated user can read every row. Without the verification step that bug ships and any authenticated user can enumerate every organization on the platform. With it, the bug surfaces on the harness's first run in phase 3, because the outsider test user gets rows back instead of none.
Phase 4: Auth migration — Base44 OAuth users to Supabase Auth
Auth is the phase that most often produces day-one support tickets in a base44 to nextjs supabase migration, so the plan splits cleanly into two distinct problems: OAuth users and password users. The strategies differ.
OAuth users. These users authenticated through Google, GitHub, Microsoft, or another provider. Base44 never held their password. In Supabase, configure the same provider with the same OAuth client ID and secret. On first login post-migration, the user re-authenticates through the provider, and Supabase creates their user row. We match the new Supabase user to the old Base44 user via email, then write a users table that holds the email-keyed mapping.
// supabase/migrations/20260524_users.sql
create table public.app_users (
id uuid primary key references auth.users(id) on delete cascade,
email text unique not null,
legacy_base44_id text unique,
display_name text,
created_at timestamptz not null default now()
);
-- Pre-populated from Base44 export so RLS can use legacy_base44_id during overlap
insert into public.app_users (id, email, legacy_base44_id, display_name)
select
gen_random_uuid(), -- placeholder; replaced on first login
email,
base44_id,
display_name
from json_populate_recordset(
null::public.app_users,
(pg_read_file('/tmp/users-export.json', 0, 200000000))::json
);
On first login post-cutover, a trigger updates the id field to match the real auth.users.id Supabase generated. Until that login happens, the user row exists with a placeholder ID so existing foreign keys do not break.
Password users. If Base44's export exposes the original bcrypt or argon2 hashes, Supabase's admin API accepts pre-hashed passwords via auth.admin.createUser({ password_hash: '...' }). The user logs in with their existing password and never knows the migration happened. If Base44 does not expose the hashes (the common case), we trigger a one-time forced password reset on first login post-cutover. The reset flow is pre-styled in the new Next.js app so the experience reads as a security upgrade, not a broken migration.
Sessions do not transfer. Every user re-authenticates once post-cutover regardless of method. Communicate this in advance via email so the support ticket volume on day one does not spike.
Phase 5: Function migration — Base44 functions to Next.js or Supabase Edge
Base44 backend functions run on Deno with a curated dependency list. They have two natural homes in the new stack: Next.js API routes (or Server Actions / Route Handlers) for request-scoped work tied to a user session, and Supabase Edge Functions for webhooks, scheduled jobs, and platform-level operations that do not need the Next.js render pipeline.
The split rule we use:
- Function calls user-scoped Supabase data and returns to the UI → Next.js Route Handler.
- Function is a third-party webhook receiver, runs on a schedule, or is invoked from another backend → Supabase Edge Function.
// app/api/orders/route.ts — Next.js 16 Route Handler example
import { NextRequest, NextResponse } from "next/server";
import { createServerClient } from "@/lib/supabase-server";
import { z } from "zod";
const CreateOrderInput = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
});
export async function POST(req: NextRequest) {
const supa = await createServerClient();
const { data: { user } } = await supa.auth.getUser();
if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const parsed = CreateOrderInput.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { data, error } = await supa
.from("orders")
.insert({ ...parsed.data, customer_id: user.id })
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ order: data });
}
For functions that were chained — function A called function B which called function C — port them in dependency order, leaf-first. Run each function locally with next dev or supabase functions serve and exercise it with the same payloads you logged from production Base44. We keep a fixture file per function with at least three representative payloads so the test surface is not theoretical.
Scheduled jobs that worked unreliably under Base44 (the webhooks-require-active-users failure mode) move cleanly to Supabase scheduled Edge Functions or Vercel Cron. Either is more reliable than the platform's built-in scheduler.
Phase 6: Cutover plan
The cutover is the riskiest hour of any base44 to nextjs supabase migration. Plan it as a sequence of named, reversible steps, not as a single switch.
The cutover sequence we use:
- T-14 days. Deploy the Next.js app to a staging Vercel project pointed at the staging Supabase project. Run the full QA suite. Fix what fails.
- T-7 days. Promote staging schema to production Supabase. Run one final dry-run import of the live Base44 export into production Supabase. Snapshot the production Supabase database.
- T-7 days through T-1 day. Enable dual-write in the SDK abstraction. Every write to Base44 also writes to Supabase. Reads still come from Base44. This is the parallel-run window. Monitor for write divergence — same data should appear in both stores. Resolve drift before T-0.
- T-1 day. Send the user email announcing the cutover window. Open a status page. Verify the rollback runbook has been tested in staging.
- T-0 hour: cutover. Set the read source to Supabase via the feature flag. Keep dual-write on. Watch error rates in real time.
- T+0 to T+2 hours. Smoke test every critical path manually: login, primary action, payment, file upload. Fix any production-only bugs as they surface.
- T+48 hours. If error rates are stable, turn off Base44 dual-write. The Base44 app is now read-only and we keep it running for two weeks as a final insurance policy.
- T+14 days. Decommission Base44. Cancel the subscription.
The feature flag for read source is a single environment variable, not a code change.
// src/data/client.ts — post-decoupling, now with switchable source
const READ_SOURCE = process.env.READ_SOURCE ?? "base44"; // "base44" | "supabase"
export const productClient = {
async list(opts: { ownerId?: string; limit?: number } = {}) {
if (READ_SOURCE === "supabase") {
return supabaseProductClient.list(opts);
}
return base44ProductClient.list(opts);
},
// ...
};
Flipping read source is vercel env add READ_SOURCE supabase production && vercel deploy --prod. Flipping it back is the same command with base44. Both take under five minutes including deploy.
Rollback plan
Every migration ships with a rehearsed rollback plan. Plans that have not been rehearsed are wishes.
Scope of rollback. The rollback restores Base44 as the read source. Writes made to Supabase after the cutover are replayed back to Base44 via the dual-write shim that ran in reverse. If dual-write was turned off when rollback is triggered, writes between T+48h and the rollback timestamp are not automatically replayed and require manual reconciliation.
Triggers for rollback. Error rate above 2 percent for more than 10 minutes. Critical user journey broken (login, payment). RLS leak confirmed in production. Data loss confirmed.
Rollback procedure. Steps in order, with the time budget for each:
- Flip the read flag to
base44. 5 minutes. - Verify the Base44 app serves correctly. 5 minutes.
- Re-enable dual-write toward Base44 if it was disabled. 10 minutes.
- Identify the root cause of the rollback trigger. As long as it takes.
- Communicate to users via the status page. 15 minutes.
- Plan the next cutover attempt with the fix applied.
Rollback is not a failure; a rolled-back cutover goes again with the fix applied, as step 6 says. The rollback path being real is what makes the cutover safe enough to attempt.
What moves the timeline and the cost
The gap between a short migration and a long one comes from a handful of variables, and they are the same ones that decide which fixed-price tier a migration lands in.
Cost drivers that consistently move the bill upward: more than 20 backend functions, more than 4 distinct user roles in RLS, custom integrations with Stripe Connect or other multi-party payment flows, scheduled jobs that need careful migration to avoid double-firing, and apps with significant historical data (over 1M rows) where export and import alone is a multi-day operation.
Cost drivers that surprise teams downward: well-organized Base44 codebases where the data layer was already loosely coupled, apps with only a single OAuth provider, and apps that have already shipped some pages on a separate SSR host (the prior partial-migration work usually applies directly).
FAQ
[FAQ entries render from frontmatter via the page template.]
Ready to start the migration?
Our /migrate engagement runs the full playbook above with a fixed-scope statement of work and a hard cutover date. Pricing is fixed in three tiers: $6,000 for a small migration, $12,000 for a medium one and $25,000+ for an enterprise one. Every engagement includes the SDK decoupling, schema port, RLS translation with verification harness, auth migration, function port, parallel-run instrumentation and cutover. Every migration carries the same terms: zero data loss, your Base44 app kept live until cutover, a 14-day rollback window and a 30-day defects warranty. Start a migrate engagement or book a free 15-minute call with the engineer who would lead the work.
Related
- When to leave Base44 — the decision framework that precedes this playbook.
- Base44 to Next.js + Supabase migration overview — the service-page summary of what a migration engagement includes.
- Vendor lock-in via SDK dependency — the decoupling work that the migration depends on and the reason most teams underestimate the timeline.