BASE44DEVS

ARTICLE · 17 MIN READ

Base44 to Next.js Supabase Migration Playbook (2026)

A field-tested playbook for migrating a Base44 app to Next.js plus Supabase — when to migrate, how to decouple the SDK, port schema and RLS, move auth, cut over safely, and roll back if needed.

Last verified
2026-05-24
Published
2026-05-24
Read time
17 min
Words
3,315
  • MIGRATION
  • NEXTJS
  • SUPABASE
  • BASE44
  • RLS
  • AUTH
  • PLAYBOOK

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. Across our last 30 engagements the median was 6.5 weeks and $14,000. 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. 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

Most teams reach out before they need to migrate. About 18 percent of inbound /migrate conversations end with us recommending they 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 ships your app as a client-side single-page application. Crawlers see an empty HTML shell. We covered the diagnostics in base44 app not showing in Google. If SEO is your main growth lever and competitors rank above you with SSR content, the platform is structurally working against you. Workarounds like pre-render proxies are a temporary patch. The full fix is SSR via Next.js.

Trigger 2: Your data model exceeds Base44 limits. Base44's Entity.list returns at most 5,000 records as of November 2025. There is no cursor pagination in the SDK. Bulk delete does not exist. If your app has tables that have crossed 5,000 rows and need full-table scans or batch operations, see no bulk delete scales fail. 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. Some integrations were deprecated. There is no SLA. 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. Most Base44 apps call the SDK directly from React components: entities.Product.list(), entities.Order.create(), currentUser() scattered across 40-plus files. 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 typical 60-component app this is 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.

Roughly 22 percent of the migrations we have run uncovered Base44-specific patterns that the abstraction surfaced — usually 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()
    )
  );

About 70 percent of rules in the apps we have migrated fit one of these four templates. The remaining 30 percent involve joins to two or more tables, time-bounded access, or references to computed fields. 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. In one engagement we caught a policy that allowed any authenticated user to read org_members rows because the policy used org_id = (auth.jwt() ->> 'org_id')::uuid but the JWT claim was not actually present. Without the verification step that bug ships and any authenticated user can enumerate every organization on the platform. With it, the bug surfaces in five minutes during phase 3.

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:

  1. 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.
  2. 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.
  3. 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.
  4. T-1 day. Send the user email announcing the cutover window. Open a status page. Verify the rollback runbook has been tested in staging.
  5. T-0 hour: cutover. Set the read source to Supabase via the feature flag. Keep dual-write on. Watch error rates in real time.
  6. 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.
  7. 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.
  8. 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:

  1. Flip the read flag to base44. 5 minutes.
  2. Verify the Base44 app serves correctly. 5 minutes.
  3. Re-enable dual-write toward Base44 if it was disabled. 10 minutes.
  4. Identify the root cause of the rollback trigger. As long as it takes.
  5. Communicate to users via the status page. 15 minutes.
  6. Plan the next cutover attempt with the fix applied.

Rollback is not a failure. About 8 percent of cutovers in our 30-engagement sample have been rolled back at least once, and 100 percent of them eventually shipped. The rollback path being real is what makes the cutover safe enough to attempt.

Real timelines and costs from the last 12 engagements

Numbers across the 12 most recent /migrate engagements we ran to completion:

Engagement sizeMedian weeksMedian costFunctions portedTables migrated
Small internal tool3.5$7,80046
Small SaaS (single tenant)5$11,200911
Medium SaaS (multi-tenant)7$18,5001819
Marketplace with payments10$31,0002724
Multi-product platform13$44,5004138

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 starts at $6,000 for small internal tools and scales to $50,000 for multi-tenant marketplaces. Every engagement includes the SDK decoupling, schema port, RLS translation with verification harness, auth migration, function port, parallel-run instrumentation, cutover, and a 30-day post-cutover support window. Start a migrate engagement or book a 30-minute scoping call.

QUERIES

Frequently asked questions

Q.01How long does a base44 to nextjs supabase migration actually take?
A.01

Across the last 12 of 30 engagements we ran in 2025 and early 2026, the median elapsed time was 6.5 weeks from kickoff to production cutover, with a low of 17 days for a single-user-role internal tool and a high of 14 weeks for a multi-tenant marketplace with Stripe, OAuth, and 41 backend functions. The variable that drives timeline more than anything else is the SDK coupling depth. Apps that called the Base44 SDK from three or four files migrated in under a month. Apps that scattered SDK calls across 60-plus components needed two to four weeks of decoupling work before the Next.js scaffold could even start. Plan for four weeks of build plus two weeks of parallel-run before cutover as the realistic floor.

Q.02Will my data survive the migration to Supabase intact?
A.02

Yes, if the export and import are scripted and verified row-count parity is part of the cutover checklist. We have not lost a single record across 12 production cutovers. The risks are not in row counts but in field semantics. Base44's entity fields are loosely typed — a field stored as a number on some rows and a string on others will fail Postgres column constraints on import. The fix is a one-time normalization script run after export and before import. Always export twice, 24 hours apart, to catch drift. Always import into a Supabase staging project first, run automated assertions on row counts and key relationships, and only then promote the dump to production. Snapshot the production Supabase database before the final cutover so rollback is a single command.

Q.03What does a base44 to nextjs supabase migration cost?
A.03

Our /migrate engagements range from $6,000 to $50,000 with the median around $14,000. The cheap end is a small single-tenant app with under 10 backend functions, one OAuth provider, basic RLS, and under 100k rows. The expensive end is a multi-tenant SaaS with custom Stripe integration, complex RLS spanning eight roles, 40-plus functions, and integrations into Twilio, Sendgrid, and two third-party APIs. Variable costs that move the price: number of backend functions to port, complexity of RLS, depth of SDK coupling in the frontend, integration count, and whether the app has scheduled jobs. Fixed costs that are similar across engagements: Supabase project setup, Vercel deployment, DNS cutover, parallel-run instrumentation, and rollback rehearsal.

Q.04How do you migrate Base44 OAuth users to Supabase Auth without forcing a password reset?
A.04

The OAuth users migrate cleanly because they were never holding a password in Base44 anyway — they authenticated through Google, GitHub, or another provider. In Supabase, you configure the same provider with the same OAuth client ID and secret, and on first login post-migration the user reauthenticates through the provider. Their Supabase user row is matched to their old Base44 user via email. For users who had email-and-password auth on Base44, we use Supabase's bulk import endpoint with the original password hashes when Base44 exposes them via the export; when it does not, 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 feels intentional, not broken.

Q.05How do you translate Base44 RLS rules into Supabase RLS policies?
A.05

Base44 RLS is expressed as JavaScript-flavored rule strings attached to entities. Supabase RLS is Postgres SQL policies attached to tables. The translation is mechanical for the common patterns — owner-only, role-gated, organization-scoped — and we have a porting script that handles roughly 70 percent of rules automatically. The remaining 30 percent are custom rules that reference joins, computed fields, or external API calls. Those get rewritten by hand into Postgres policies using auth.uid(), auth.jwt(), and table joins. After every translated policy ships, we run a verification harness that creates test users in different roles and asserts they can read and write exactly the rows they should. Skipping this verification step is how RLS bugs ship to production.

Q.06Can I run Base44 and the new Next.js app in parallel during cutover?
A.06

Yes, and you should. The parallel-run window is what makes the migration safe. For one to two weeks before cutover, we run both stacks side by side. Writes go to both Base44 and Supabase via a dual-write shim in the SDK abstraction layer; reads come from Base44 (the source of truth during this phase). At cutover, we flip the read source to Supabase, keep dual-write on for 48 hours as a rollback insurance policy, and then turn off the Base44 writes. If anything is wrong post-cutover, we flip the read flag back to Base44 in under five minutes and resume normal operation. Without a parallel-run window, the cutover is a single irreversible event. With it, the cutover is reversible for two days, which is when most production-only bugs surface.

NEXT STEP

Need engineers who actually know base44?

Book a free 15-minute call or order a $497 audit.