Will's Birthday Sale — 86% off all products. Ends October 19 at 11:59 AM.
KodeBaseKODEBASE
Back to blog
Guides 26 min read

How to Migrate a Base44 App to a Standalone Backend: The Complete Technical Guide

Exporting to GitHub is not a migration. This free 26-phase technical guide shows exactly how to replace Base44 auth, database, functions, files, realtime, payments, and agents with infrastructure you own — without rewriting your frontend.

Will Kode July 13, 2026
Share
Migrating a Base44 app from platform infrastructure to a standalone backend you control

I have seen a lot of confusion around what it actually means to “migrate a Base44 app.”

Exporting the application to GitHub is not the complete migration.

Your GitHub repository may contain the React frontend, entity schemas, backend function source, agent definitions, and workflow definitions, but the running application can still depend on Base44 for:

  • Authentication
  • Database storage
  • Entity CRUD operations
  • Backend function execution
  • File storage
  • Realtime subscriptions
  • AI integrations
  • Email delivery
  • OAuth connectors
  • Workflows
  • User invitations
  • Payments
  • Analytics
  • Application logs
  • Hosting and environment variables

A complete migration means replacing those runtime dependencies with infrastructure you control.

This guide explains how to migrate one existing Base44 application. It is not a guide to recreating the entire Base44 platform or building a generic Base44-compatible backend-as-a-service.

Those are very different projects.


The Core Migration Strategy

Most Base44 applications communicate with the platform through one centralized SDK client:

import { base44 } from "@/api/base44Client";

The rest of the application then calls a relatively consistent interface:

base44.auth.me();
base44.auth.updateMe(data);
base44.auth.isAuthenticated();

base44.entities.Task.list();
base44.entities.Task.filter(query);
base44.entities.Task.create(data);
base44.entities.Task.update(id, data);
base44.entities.Task.delete(id);

base44.functions.invoke("sendNotification", payload);

base44.integrations.Core.SendEmail(data);
base44.integrations.Core.InvokeLLM(data);
base44.integrations.Core.UploadFile(data);

base44.agents.createConversation(data);
base44.agents.addMessage(conversation, message);

base44.analytics.track(event);
base44.appLogs.logUserInApp(pageName);

That creates the highest-leverage opportunity in the entire migration:

Preserve the frontend API contract, replace the implementation behind it.

Instead of rewriting hundreds of pages and components, replace

src/api/base44Client.js
with a compatibility client that exposes the same methods but communicates with your new backend.

This turns the project from a full application rewrite into:

  1. A backend migration.
  2. An authentication migration.
  3. A data migration.
  4. An integration migration.
  5. A replacement SDK client.
  6. A controlled frontend cleanup.

This approach only works when the current frontend behavior is understood and the replacement API matches the argument and response shapes the frontend already expects.



🚀 Skip the guesswork — scan your app first

Before you write a single line of migration code, run your repository through the free Base44 Migration Planner. It deterministically scans your codebase, counts every entity, function, integration, and security finding, and gives you a free readiness score in minutes.

Get your free readiness preview →


What You Need Before Starting

Do not begin by writing database models or API routes.

Begin with discovery.

At minimum, obtain:

  • The complete GitHub export
  • Access to the current Base44 application
  • Administrative access to the application
  • Entity schemas
  • Backend function source
  • Workflow definitions
  • Agent definitions
  • Connector inventory
  • Integration credentials
  • Payment provider credentials
  • A complete data export
  • User export, when available
  • File inventory
  • DNS access
  • Hosting access
  • A list of critical user journeys
  • At least one administrator test account
  • At least one normal-user test account

You also need explicit decisions about:

  • Target backend stack
  • Database
  • Authentication provider
  • File-storage provider
  • Email provider
  • SMS provider
  • LLM provider
  • Hosting
  • Realtime architecture
  • Background jobs
  • Logging
  • Monitoring
  • Backup retention
  • Deployment environments
  • Rollback process

Do not assume these decisions can be made later without consequences. They affect the schema, client contract, deployment architecture, and migration order.


Recommended Repository Structure

A practical standalone repository may look like this:

project-root/
├── src/
│   ├── api/
│   │   └── base44Client.js
│   ├── components/
│   ├── hooks/
│   ├── lib/
│   ├── pages/
│   ├── App.jsx
│   ├── main.jsx
│   └── index.css
│
├── server/
│   ├── src/
│   │   ├── auth/
│   │   ├── config/
│   │   ├── controllers/
│   │   ├── functions/
│   │   ├── integrations/
│   │   ├── jobs/
│   │   ├── middleware/
│   │   ├── realtime/
│   │   ├── routes/
│   │   ├── services/
│   │   ├── storage/
│   │   ├── utils/
│   │   ├── app.js
│   │   └── server.js
│   ├── prisma/
│   │   ├── schema.prisma
│   │   ├── migrations/
│   │   └── seed.js
│   ├── tests/
│   ├── package.json
│   └── .env.example
│
├── scripts/
│   ├── export-base44-data.js
│   ├── transform-data.js
│   ├── import-data.js
│   ├── verify-migration.js
│   └── reconcile-files.js
│
├── docker-compose.yml
├── vite.config.js
├── package.json
├── .env.example
└── README.md

You can use a monorepo, separate frontend and backend repositories, serverless functions, or a framework such as Next.js. The important point is to establish clear boundaries between:

  • Frontend
  • API
  • Business logic
  • Database
  • External integrations
  • Background processing
  • Infrastructure configuration

Phase 0: Build a Complete Dependency Inventory

The discovery phase should produce a machine-readable inventory, not just informal notes.

Create a migration manifest:

{
  "entities": [],
  "functions": [],
  "workflows": [],
  "agents": [],
  "integrations": [],
  "connectors": [],
  "authFlows": [],
  "realtimeSubscriptions": [],
  "files": [],
  "environmentVariables": [],
  "externalUrls": [],
  "frontendCallSites": [],
  "securityFindings": []
}

0.1 Inventory the Base44 files

Inspect:

base44/entities/*.jsonc
base44/functions/*/entry.ts
base44/workflows/*.jsonc
base44/agents/*.jsonc
src/api/base44Client.js
src/App.jsx
src/lib/*
src/hooks/*
src/pages/*
src/components/*
package.json
vite.config.js

Also search the entire repository for:

@base44/sdk
@base44/vite-plugin
base44.auth
base44.entities
base44.functions
base44.integrations
base44.connectors
base44.agents
base44.analytics
base44.appLogs
base44.asServiceRole
.subscribe(
Deno.env.get
Deno.serve
base44.com
api/functions

Example with ripgrep:

rg "@base44|base44\.auth|base44\.entities|base44\.functions|base44\.integrations|base44\.agents|base44\.connectors|\.subscribe\(" src base44

Search for secrets:

rg -n \
  "sk_live_|sk_test_|rk_live_|AIza|AKIA|Bearer [A-Za-z0-9._-]+|api[_-]?key|client[_-]?secret|webhook[_-]?secret" \
  src base44

Any hardcoded production secret should be considered compromised and rotated.

Do not wait until the end of the migration.

0.2 Inventory frontend SDK usage

Generate a list of every call shape used by the frontend.

For example:

base44.auth.me()
base44.auth.updateMe(payload)
base44.auth.isAuthenticated()
base44.auth.loginViaEmailPassword(email, password)
base44.auth.loginWithProvider(provider, returnUrl)
base44.auth.register(payload)
base44.auth.verifyOtp(payload)
base44.auth.resendOtp(email)
base44.auth.resetPasswordRequest(email)
base44.auth.resetPassword(payload)
base44.auth.logout()

For entities:

base44.entities.Task.list(sort, limit)
base44.entities.Task.filter(query, sort, limit)
base44.entities.Task.get(id)
base44.entities.Task.create(payload)
base44.entities.Task.bulkCreate(payload)
base44.entities.Task.update(id, payload)
base44.entities.Task.bulkUpdate(payload)
base44.entities.Task.updateMany(query, operators)
base44.entities.Task.delete(id)
base44.entities.Task.deleteMany(query)
base44.entities.Task.schema()
base44.entities.Task.subscribe(callback)

Do not implement only the methods you remember. Implement the methods the application actually calls.

0.3 Build an entity matrix

For every entity, record:

| Property | Meaning | | ---------------- | -------------------------------------------- | | Name | Base44 entity name | | Fields | Declared schema fields | | Required | Required fields | | Enums | Allowed values | | Defaults | Default values | | Relationships | Probable references to other entities | | Owner fields |

user_id
,
owner_id
,
created_by_id
, etc. | | Public fields | Data readable without authentication | | Sensitive fields | PII, financial, health, private notes | | Read rules | Who can retrieve records | | Create rules | Who can create records | | Update rules | Who can update records | | Delete rules | Who can delete records | | Implicit fields | Fields used in code but absent from schema | | Indexes | Filters and sort fields needing indexes | | Record count | Existing rows | | Migration order | Parent before child, or vice versa |

Base44 records commonly include implicit fields:

id
created_date
updated_date
created_by_id

Do not redeclare these in the original Base44 schema, but do account for them in the destination database.

0.4 Find undeclared fields

Entity schemas are not always the full truth.

Search frontend and function code for property access:

rg -o "user\.[A-Za-z_][A-Za-z0-9_]*" src base44/functions | sort -u

Repeat for important entities.

A field may have been written through:

base44.auth.updateMe({
  stripe_account_id: accountId,
  onboarding_complete: true
});

even when the field is absent from

User.jsonc
.

If you only migrate declared fields, those values can disappear silently.

0.5 Classify backend functions

Classify every function as one of:

  • Frontend-invoked RPC
  • Internal service function
  • Administrative function
  • Public webhook
  • Scheduled job
  • Entity-change automation
  • Payment function
  • AI function
  • File-processing function
  • Notification function
  • Data-import function
  • Connector function

Record:

  • Function name
  • Caller
  • Authentication requirement
  • Role requirement
  • Entity reads
  • Entity writes
  • Service-role use
  • External APIs
  • Secrets
  • Idempotency requirements
  • Webhook signatures
  • Retry behavior
  • Expected response shape
  • Error behavior
  • Side effects

Never infer function behavior from the filename alone. Read the source.

0.6 Inventory hosted authentication dependencies

Check whether the exported frontend includes actual:

  • Login form
  • Registration form
  • OTP screen
  • Forgot-password form
  • Reset-password form
  • OAuth callback handling

Some applications only redirect to hosted authentication.

In those cases, the migration requires building the missing user interface and backend behavior from scratch.

0.7 Inventory workflows

For every workflow, document:

  • Trigger type
  • Schedule
  • Entity event
  • Connector event
  • Manual trigger
  • Steps
  • Conditions
  • Branches
  • Waits
  • Called functions
  • Input shape
  • Output shape
  • Retry behavior
  • Failure behavior
  • Version behavior

Pay special attention to durable waits.

This is not durable:

setTimeout(() => {
  sendReminder();
}, 72 * 60 * 60 * 1000);

The timer disappears when the process restarts.

Long waits require persisted state.

0.8 Inventory agents

For each AI agent, document:

  • Agent name
  • Instructions
  • Allowed entities
  • Allowed operations
  • Allowed functions
  • Connected services
  • Conversation storage
  • Message structure
  • File support
  • Channel support
  • Realtime behavior
  • User scoping
  • Service-role access

AI agents should not automatically receive unrestricted database access in the replacement architecture.


Phase 1: Choose the Destination Architecture

There are three common migration paths.

Option A: Supabase

Typical stack:

React + Vite
Supabase PostgreSQL
Supabase Auth
Supabase Storage
Supabase Realtime
Supabase Edge Functions or separate Node API

Best for:

  • Small and medium applications
  • Fast migrations
  • Standard CRUD
  • File storage
  • Realtime
  • Applications benefiting from PostgreSQL RLS
  • Teams that do not want to operate a complete backend stack

Tradeoff:

You are replacing Base44 with another managed backend platform. Supabase is based on common technologies and can be self-hosted, but it is still a platform dependency.

Option B: Custom Node.js Backend

Typical stack:

React + Vite
Node.js
Express or Fastify
PostgreSQL
Prisma or Drizzle
Socket.IO
S3 or Cloudflare R2
Redis or PostgreSQL-backed jobs

Best for:

  • Complex business logic
  • Payment-heavy applications
  • Multi-tenant SaaS
  • Advanced integrations
  • Specialized authorization
  • Infrastructure ownership
  • Predictable application-specific behavior

Tradeoff:

You must implement and operate more infrastructure.

Option C: Hybrid

Example:

React + Vite
Supabase Auth
Supabase PostgreSQL
Supabase Storage
Custom Node API
Socket.IO
External queue workers

Best for:

  • Applications needing fast migration but custom business logic
  • Teams wanting managed auth and storage without placing all logic in a BaaS
  • Gradual migrations

Firebase

Firebase can work, but most Base44 applications eventually develop relational behavior:

User -> Organization
Organization -> Project
Project -> Task
Task -> Comment
Payment -> Booking
Booking -> Customer

These relationships are generally easier to model and query in PostgreSQL.


Phase 2: Freeze and Baseline the Existing Application

Before changing architecture:

  1. Create a release tag.
  2. Export the current production code.
  3. Export the production data.
  4. Export a file manifest.
  5. Record environment variables.
  6. Record webhook endpoints.
  7. Capture critical user journeys.
  8. Record expected outputs for calculations.
  9. Create test users for each role.
  10. Take screenshots or videos of important workflows.

Create a Git tag:

git tag base44-production-baseline
git push origin base44-production-baseline

Record known-good outputs for:

  • Fee calculations
  • Discounts
  • Tax calculations
  • Subscription statuses
  • Date calculations
  • Eligibility decisions
  • Permission decisions
  • AI JSON output formats
  • Reporting totals

Migration testing is much stronger when you can compare the old and new systems against the same fixtures.


Phase 3: Design the Destination Database

For a single known application, use real database models.

Do not build a dynamic generic entity engine unless your actual product needs users to define new entities at runtime.

A normal migration knows its entities in advance.

3.1 Preserve Base44 IDs initially

During migration, preserving existing IDs reduces complexity.

Example Prisma model:

model Task {
  id          String   @id
  title       String
  description String?
  status      String   @default("todo")
  priority    String   @default("medium")
  dueDate     DateTime?

  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  createdById String?

  @@index([status])
  @@index([createdById])
  @@index([dueDate])
}

If you generate new destination IDs, create an ID map:

old_base44_id -> new_database_id

Every relationship must then be transformed using that map.

Preserving IDs avoids that translation for most migrations.

3.2 Handle Base44 built-in fields

Map:

created_date   -> createdAt
updated_date   -> updatedAt
created_by_id  -> createdById

Your API should still return the field names the frontend expects unless you update every frontend call site.

3.3 Preserve the wire format

The database can use camelCase:

createdAt
updatedAt
createdById
pricePerDay

The frontend can continue using snake_case:

created_date
updated_date
created_by_id
price_per_day

Create a conversion layer at the API boundary.

const SPECIAL_TO_INTERNAL = {
  created_date: "createdAt",
  updated_date: "updatedAt",
  created_by_id: "createdById"
};

const SPECIAL_TO_EXTERNAL = {
  createdAt: "created_date",
  updatedAt: "updated_date",
  createdById: "created_by_id"
};

function snakeToCamel(value) {
  return value.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
}

function camelToSnake(value) {
  return value.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
}

function keysToCamelCase(input) {
  if (Array.isArray(input)) {
    return input.map(keysToCamelCase);
  }

  if (!input || typeof input !== "object" || input instanceof Date) {
    return input;
  }

  return Object.fromEntries(
    Object.entries(input).map(([key, value]) => {
      const convertedKey = SPECIAL_TO_INTERNAL[key] || snakeToCamel(key);
      return [convertedKey, keysToCamelCase(value)];
    })
  );
}

function keysToSnakeCase(input) {
  if (Array.isArray(input)) {
    return input.map(keysToSnakeCase);
  }

  if (!input || typeof input !== "object" || input instanceof Date) {
    return input;
  }

  return Object.fromEntries(
    Object.entries(input).map(([key, value]) => {
      const convertedKey = SPECIAL_TO_EXTERNAL[key] || camelToSnake(key);
      return [convertedKey, keysToSnakeCase(value)];
    })
  );
}

Do not blindly convert:

  • Date objects
  • Buffers
  • File streams
  • ORM metadata
  • JWT payload internals
  • Provider webhook payloads

Convert only at controlled application boundaries.

3.4 Relationships

Base44 commonly represents relationships using string fields:

{
  "host_id": "user_123",
  "booking_id": "booking_456"
}

You can convert these into foreign keys:

model Booking {
  id      String @id
  hostId  String
  host    User   @relation(fields: [hostId], references: [id])

  @@index([hostId])
}

However, do not convert every string ending in

_id
automatically.

Some fields may refer to:

  • External provider records
  • Historical records no longer present
  • Records in another environment
  • Polymorphic resources
  • Deleted users
  • Denormalized snapshots

Validate relationships against real production data first.

3.5 Keep intentional denormalization

Fields such as:

customer_email
customer_name
provider_name
price_at_purchase
tax_amount
shipping_address_snapshot

may be intentional historical snapshots.

Do not normalize them away merely because the same data exists on another table.

A transaction should often preserve what was true at the time it occurred.

3.6 JSON fields and arrays

Use JSONB for:

  • Flexible content blocks
  • AI output
  • Provider metadata
  • Form definitions
  • Feature configuration
  • Historical payloads
  • Webhook payload fragments

Use child tables when array items:

  • Need independent updates
  • Need filtering
  • Need indexes
  • Need relations
  • Have their own lifecycle

3.7 Settings entities

Base44 applications often use a singleton settings entity:

const settings = await base44.entities.AppSettings.list();
const currentSettings = settings[0];

Preserve this behavior initially.

You can use a one-row table with a fixed key:

model AppSettings {
  id        String   @id @default("global")
  appName   String?
  updatedAt DateTime @updatedAt
}

Phase 4: Build the Generic Entity API

For a single application, you can still avoid creating repetitive CRUD controllers.

Create one entity configuration:

export const entityConfig = {
  Task: {
    model: "task",
    publicRead: false,
    ownerFields: ["createdById"],
    selfOwnerField: "createdById",
    adminDeleteOnly: false
  },

  Product: {
    model: "product",
    publicRead: true,
    publicReadWhere: {
      status: "published"
    },
    ownerFields: ["sellerId"],
    selfOwnerField: "sellerId"
  },

  Payment: {
    model: "payment",
    publicRead: false,
    ownerFields: ["customerId", "merchantId"],
    adminManagedWrite: true
  }
};

Mount a generic router:

GET    /api/entities/:entityName
GET    /api/entities/:entityName/:id
POST   /api/entities/:entityName
POST   /api/entities/:entityName/bulk
PATCH  /api/entities/:entityName/:id
PATCH  /api/entities/:entityName/bulk
PATCH  /api/entities/:entityName/update-many
DELETE /api/entities/:entityName/:id
DELETE /api/entities/:entityName
GET    /api/entities/:entityName/schema

4.1 Validate entity names

Never dynamically call arbitrary ORM properties supplied by the user.

Bad:

const model = prisma[req.params.entityName];

Better:

const config = entityConfig[req.params.entityName];

if (!config) {
  return res.status(404).json({
    error: "Unknown entity"
  });
}

const model = prisma[config.model];

4.2 Enforce authorization server-side

A query such as:

base44.entities.Project.filter({
  owner_id: user.id
});

is not authorization.

It is a frontend filter.

An attacker can call the endpoint directly with a different filter.

The backend must inject ownership constraints:

function scopeWhereForUser(config, user, requestedWhere) {
  if (user.role === "admin") {
    return requestedWhere;
  }

  if (!config.ownerFields?.length) {
    throw new ForbiddenError();
  }

  return {
    AND: [
      requestedWhere,
      {
        OR: config.ownerFields.map(field => ({
          [field]: user.id
        }))
      }
    ]
  };
}

Never trust:

  • user_id
  • owner_id
  • role
  • is_admin
  • organization_id
  • Payment amounts
  • Subscription status

when those values come from the frontend.

Derive trusted identity from the authenticated session.

4.3 Implement the methods actually used

Your entity client should support the Base44 methods found in discovery:

function createEntityClient(entityName) {
  return {
    list(sort, limit) {
      return request(`/api/entities/${entityName}`, {
        query: { sort, limit }
      });
    },

    filter(where, sort, limit) {
      return request(`/api/entities/${entityName}`, {
        query: {
          where: JSON.stringify(where || {}),
          sort,
          limit
        }
      });
    },

    get(id) {
      return request(`/api/entities/${entityName}/${id}`);
    },

    create(data) {
      return request(`/api/entities/${entityName}`, {
        method: "POST",
        body: data
      });
    },

    bulkCreate(records) {
      return request(`/api/entities/${entityName}/bulk`, {
        method: "POST",
        body: { records }
      });
    },

    update(id, data) {
      return request(`/api/entities/${entityName}/${id}`, {
        method: "PATCH",
        body: data
      });
    },

    bulkUpdate(records) {
      return request(`/api/entities/${entityName}/bulk`, {
        method: "PATCH",
        body: { records }
      });
    },

    updateMany(where, update) {
      return request(`/api/entities/${entityName}/update-many`, {
        method: "PATCH",
        body: { where, update }
      });
    },

    delete(id) {
      return request(`/api/entities/${entityName}/${id}`, {
        method: "DELETE"
      });
    },

    deleteMany(where) {
      return request(`/api/entities/${entityName}`, {
        method: "DELETE",
        body: { where }
      });
    },

    schema() {
      return request(`/api/entities/${entityName}/schema`);
    },

    subscribe(callback) {
      return subscribeToEntity(entityName, callback);
    }
  };
}

4.4 Match return shapes

A migration can be logically correct and still break because return shapes changed.

Examples:

const result = await base44.entities.Task.list();

The frontend may expect an array directly.

But function calls may expect:

const response = await base44.functions.invoke("sendEmail", payload);
console.log(response.data.success);

Your replacement must preserve that distinction:

functions: {
  async invoke(name, payload) {
    const data = await request(`/api/functions/${name}`, {
      method: "POST",
      body: payload
    });

    return {
      data,
      status: 200,
      headers: {}
    };
  }
}

Search actual call sites before deciding response formats.


Phase 5: Replace Authentication

Authentication is usually one of the most dangerous migration areas because application identity touches nearly every data-access decision.

5.1 Recommended session architecture

A common standalone pattern:

  • Short-lived access token
  • Long-lived rotating refresh token
  • Refresh token in a secure, HttpOnly cookie
  • Access token in memory
  • CSRF protection where applicable
  • Session records stored server-side
  • Token revocation support
  • Password hashing with Argon2id or bcrypt
  • Rate limiting
  • OTP expiration and attempt limits

Avoid storing long-lived refresh tokens in

localStorage
.

5.2 Required endpoints

Typical endpoints:

POST /api/auth/register
POST /api/auth/verify-otp
POST /api/auth/resend-otp
POST /api/auth/login
POST /api/auth/refresh
POST /api/auth/logout
GET  /api/auth/me
PATCH /api/auth/me
POST /api/auth/forgot-password
POST /api/auth/reset-password
GET  /api/auth/oauth/:provider
GET  /api/auth/oauth/:provider/callback

5.3 Registration flow

Preserve the application’s expected flow:

Register
-> send OTP
-> verify OTP
-> create session
-> initialize application

Example:

async function register({ email, password }) {
  const normalizedEmail = email.trim().toLowerCase();

  const existingUser = await prisma.user.findUnique({
    where: { email: normalizedEmail }
  });

  if (existingUser) {
    throw new ConflictError("Account already exists");
  }

  const passwordHash = await argon2.hash(password);

  const user = await prisma.user.create({
    data: {
      email: normalizedEmail,
      passwordHash,
      verified: false
    }
  });

  await issueOtp(user);

  return {
    verification_required: true
  };
}

5.4 OTP security

Do not store raw OTP codes.

Store:

  • OTP hash
  • User ID
  • Purpose
  • Created time
  • Expiration time
  • Attempt count
  • Used time

Recommended controls:

  • Five-to-ten-minute expiration
  • Attempt limit
  • Resend cooldown
  • IP rate limit
  • User rate limit
  • One-time consumption
  • Invalidate old OTP when a new one is issued

5.5 Password reset

Always return a generic response:

{
  "success": true,
  "message": "If an account exists, reset instructions were sent."
}

Do not reveal whether an email address exists.

5.6 OAuth

For each OAuth provider:

  1. Create your own provider application.
  2. Configure new callback URLs.
  3. Store client credentials securely.
  4. Request minimum scopes.
  5. Validate the state parameter.
  6. Validate nonce when applicable.
  7. Link accounts safely.
  8. Prevent account takeover through email collision.
  9. Handle provider token refresh.
  10. Update privacy and data-deletion documentation.

Do not assume that every OAuth button shown in the Base44 application was fully configured or actively used.

5.7 Existing-user migration

You may be able to migrate:

  • User IDs
  • Email addresses
  • Names
  • Roles
  • Profile fields
  • Preferences
  • Verification state
  • Provider identifiers
  • Creation timestamps

You may not be able to migrate compatible password hashes.

When password hashes cannot be exported or are incompatible:

  1. Import users without usable passwords.
  2. Mark them as migration-required.
  3. Send a secure password-setup link.
  4. Require password creation at first login.
  5. Preserve the old user ID for relationship mapping.

Never email temporary plaintext passwords.

5.8 Match the frontend auth context

If the current application expects:

const {
  user,
  isAuthenticated,
  isLoadingAuth,
  logout,
  navigateToLogin
} = useAuth();

provide that shape.

Do not force a full application rewrite merely because the new authentication provider exposes different hooks.


Phase 6: Replace the Base44 Client

Rewrite

src/api/base44Client.js
in place.

Keep:

export const base44 = ...

and preserve the methods used throughout the application.

Example skeleton:

import { io } from "socket.io-client";

const API_URL = import.meta.env.VITE_API_URL;

let accessToken = null;
let refreshPromise = null;

async function refreshAccessToken() {
  if (!refreshPromise) {
    refreshPromise = fetch(`${API_URL}/api/auth/refresh`, {
      method: "POST",
      credentials: "include"
    })
      .then(async response => {
        if (!response.ok) {
          throw new Error("Session expired");
        }

        const data = await response.json();
        accessToken = data.access_token;
        return accessToken;
      })
      .finally(() => {
        refreshPromise = null;
      });
  }

  return refreshPromise;
}

async function request(path, options = {}) {
  const {
    method = "GET",
    body,
    query
  } = options;

  const url = new URL(`${API_URL}${path}`);

  for (const [key, value] of Object.entries(query || {})) {
    if (value !== undefined && value !== null && value !== "") {
      url.searchParams.set(key, String(value));
    }
  }

  async function execute() {
    return fetch(url, {
      method,
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
        ...(accessToken
          ? { Authorization: `Bearer ${accessToken}` }
          : {})
      },
      body: body === undefined
        ? undefined
        : JSON.stringify(body)
    });
  }

  let response = await execute();

  if (response.status === 401) {
    await refreshAccessToken();
    response = await execute();
  }

  const data = await response.json().catch(() => null);

  if (!response.ok) {
    const error = new Error(
      data?.error || data?.message || "Request failed"
    );

    error.status = response.status;
    error.data = data;

    throw error;
  }

  return data;
}

Create the entity map:

const ENTITY_NAMES = [
  "Task",
  "Project",
  "Notification",
  "AppSettings"
];

const entities = Object.fromEntries(
  ENTITY_NAMES.map(name => [
    name,
    createEntityClient(name)
  ])
);

Then expose the compatible client:

export const base44 = {
  auth,
  entities,

  functions: {
    async invoke(name, payload) {
      const data = await request(`/api/functions/${name}`, {
        method: "POST",
        body: payload
      });

      return {
        data,
        status: 200,
        headers: {}
      };
    }
  },

  integrations: {
    Core: {
      SendEmail(payload) {
        return request("/api/integrations/email/send", {
          method: "POST",
          body: payload
        });
      },

      InvokeLLM(payload) {
        return request("/api/integrations/llm/invoke", {
          method: "POST",
          body: payload
        });
      }
    }
  },

  agents,
  analytics,
  appLogs
};

Phase 7: Port Backend Functions

Base44 functions commonly use:

Deno.serve(async req => {
  const base44 = createClientFromRequest(req);
  const user = await base44.auth.me();

  // Logic
});

Do not mechanically copy this into Node and hope it works.

Separate each function into:

  1. Transport
  2. Authentication
  3. Authorization
  4. Input validation
  5. Business logic
  6. Data access
  7. Provider calls
  8. Response serialization
  9. Logging
  10. Error handling

7.1 Convert functions into services

Instead of putting all logic inside an Express handler:

app.post("/api/functions/calculateQuote", async (req, res) => {
  // 400 lines
});

create:

routes/functions.js
controllers/functionController.js
services/quoteService.js
repositories/quoteRepository.js
schemas/quoteSchemas.js

Example:

export async function calculateQuoteHandler(req, res) {
  const input = calculateQuoteSchema.parse(req.body);

  const result = await calculateQuote({
    user: req.user,
    input
  });

  res.json(result);
}

7.2 Dispatch by function name

To preserve:

base44.functions.invoke("calculateQuote", payload);

use a controlled registry:

const functionRegistry = {
  calculateQuote: calculateQuoteHandler,
  sendReminder: sendReminderHandler,
  createCheckout: createCheckoutHandler
};

router.post("/:name", async (req, res, next) => {
  const handler = functionRegistry[req.params.name];

  if (!handler) {
    return res.status(404).json({
      error: "Function not found"
    });
  }

  try {
    await handler(req, res);
  } catch (error) {
    next(error);
  }
});

Never dynamically import or execute arbitrary filenames based on an untrusted URL parameter.

7.3 Port by domain

Port related functions together:

Authentication and verification

  • OTP
  • Account verification
  • Invitations
  • Password recovery
  • User onboarding

Payments

  • Checkout
  • Subscriptions
  • Refunds
  • Connect accounts
  • Payment status
  • Invoices
  • Webhooks
  • Payouts

Messaging

  • Email
  • SMS
  • Notifications
  • Chat
  • Push notifications

AI

  • Completion
  • Extraction
  • Classification
  • Image generation
  • Agent orchestration

Administration

  • User management
  • Reporting
  • Data exports
  • Moderation
  • Configuration

This makes duplicated business logic easier to identify.

7.4 Consolidate duplicated calculations

AI-generated applications often contain slightly different versions of the same logic.

Example:

calculateServiceFee
calculateBookingTotal
createCheckout
capturePayment
refundPayment

may all contain their own copy of the fee formula.

Create one canonical module:

export function calculatePlatformFees({
  subtotal,
  serviceRate,
  processingRate,
  fixedProcessingFee
}) {
  const serviceFee = roundCurrency(subtotal * serviceRate);
  const processingFee = roundCurrency(
    subtotal * processingRate + fixedProcessingFee
  );

  return {
    subtotal,
    serviceFee,
    processingFee,
    total: roundCurrency(
      subtotal + serviceFee + processingFee
    )
  };
}

Test it against known Base44 outputs before using it everywhere.

7.5 Currency handling

Never use floating-point arithmetic for authoritative financial values.

Bad:

const total = 19.99 + 4.99;

Prefer integer minor units:

const subtotalCents = 1999;
const feeCents = 499;
const totalCents = subtotalCents + feeCents;

Or use a decimal library and PostgreSQL

numeric
.

7.6 Idempotency

Functions that cause external side effects must be idempotent.

Examples:

  • Create payment
  • Capture payment
  • Send payout
  • Send email
  • Process webhook
  • Create subscription
  • Generate invoice
  • Import file

Store an idempotency record:

model IdempotencyRecord {
  key          String   @id
  operation    String
  userId       String?
  requestHash  String
  responseJson Json?
  status       String
  createdAt    DateTime @default(now())
  expiresAt    DateTime
}

Phase 8: Port Webhooks

Webhooks are publicly reachable endpoints.

They cannot rely on normal frontend authentication.

Every webhook must validate authenticity before processing data.

Example Stripe pattern:

app.post(
  "/api/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.headers["stripe-signature"];

    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        signature,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch {
      return res.status(400).send("Invalid signature");
    }

    await processStripeEvent(event);

    res.json({ received: true });
  }
);

Important:

  • Preserve the raw request body.
  • Verify the provider signature.
  • Record the provider event ID.
  • Ignore duplicate events safely.
  • Process events transactionally where possible.
  • Return quickly.
  • Move heavy work into a queue.
  • Log failures.
  • Implement retries.
  • Do not expose stack traces.

Webhook table:

model WebhookEvent {
  provider       String
  providerEventId String
  type           String
  status         String
  payload        Json?
  attempts       Int      @default(0)
  processedAt    DateTime?
  createdAt      DateTime @default(now())

  @@id([provider, providerEventId])
}

Phase 9: Replace Entity Automations

Base44 entity automations may receive payloads similar to:

{
  event,
  data,
  old_data
}

For a single migrated application, do not build a generic workflow platform unless required.

Call the relevant business logic from the mutation that caused the state change.

Example:

await prisma.$transaction(async tx => {
  const oldBooking = await tx.booking.findUnique({
    where: { id: bookingId }
  });

  const updatedBooking = await tx.booking.update({
    where: { id: bookingId },
    data: {
      status: "approved"
    }
  });

  if (
    oldBooking.status !== "approved" &&
    updatedBooking.status === "approved"
  ) {
    await createOutboxEvent(tx, {
      type: "booking.approved",
      aggregateId: updatedBooking.id,
      payload: {
        bookingId: updatedBooking.id
      }
    });
  }
});

Then process the outbox event asynchronously.

The transactional outbox pattern prevents this failure:

Database update succeeds
Email trigger fails
System has no record that the email still needs to be sent

Phase 10: Replace Scheduled Jobs and Workflows

10.1 Simple scheduled jobs

For small deployments:

  • Node cron
  • Hosted cron
  • Cloud scheduler
  • Serverless scheduled functions

Example:

cron.schedule("0 * * * *", async () => {
  await processHourlyReminders();
});

This is only safe when:

  • Multiple application instances will not run the same job simultaneously, or
  • You use a distributed lock.

PostgreSQL advisory locks can prevent duplicates.

10.2 Durable delayed workflows

For workflows such as:

Wait three days
Check payment status
Send reminder
Wait two days
Escalate

use one of:

PostgreSQL state table

model WorkflowRun {
  id             String   @id @default(cuid())
  workflowName   String
  workflowVersion Int
  currentStep    Int
  status         String
  input          Json
  state          Json?
  resumeAt       DateTime?
  attempts       Int      @default(0)
  lastError      String?
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  @@index([status, resumeAt])
}

A worker periodically claims eligible runs.

Redis and BullMQ

Useful for:

  • Delayed jobs
  • Retries
  • Concurrency limits
  • Job monitoring
  • Faster execution

Temporal

Useful for:

  • Complex durable workflows
  • Long-running state
  • Replay
  • Versioning
  • Compensation
  • High operational requirements

Do not introduce Temporal only because it is technically impressive. Use it when workflow complexity justifies it.


Phase 11: Replace Built-in Integrations

Email

Map:

base44.integrations.Core.SendEmail(...)

to:

  • Resend
  • Postmark
  • SendGrid
  • Amazon SES
  • Mailgun

Create one interface:

export async function sendEmail({
  to,
  subject,
  html,
  text,
  fromName,
  idempotencyKey
}) {
  // Provider-specific implementation
}

Do not scatter provider calls through business logic.

LLM

Map:

base44.integrations.Core.InvokeLLM(...)

to a provider abstraction:

export async function invokeLLM({
  model,
  prompt,
  messages,
  files,
  responseJsonSchema
}) {
  // Provider adapter
}

Support both:

  • Plain-text response
  • Structured JSON response

Validate structured output after generation.

An LLM returning JSON does not mean the result is valid or safe.

Use JSON Schema, Zod, or another validator.

Image generation

Map to:

  • OpenAI
  • Google
  • Replicate
  • Stability
  • Another provider

Persist:

  • Provider
  • Model
  • Prompt
  • Output URL
  • Generation status
  • Cost metadata
  • Error
  • User ID
  • Created time

File uploads

Map:

UploadFile
UploadPrivateFile
CreateFileSignedUrl

to:

  • S3
  • Cloudflare R2
  • Supabase Storage
  • Google Cloud Storage
  • Azure Blob Storage

Public files and private files must remain separate concepts.

For private files:

  1. Store an object key, not a permanent public URL.
  2. Verify authorization server-side.
  3. Generate a short-lived signed URL.
  4. Log sensitive access when required.

Data extraction

For CSV, XLSX, JSON, PDF, and image extraction:

  • Validate file type
  • Validate file size
  • Virus scan when necessary
  • Store privately
  • Process asynchronously
  • Record extraction status
  • Validate output
  • Avoid loading large files entirely into memory
  • Return row-level errors for imports

SMS

Map to:

  • Twilio
  • Telnyx
  • Vonage
  • AWS SNS

Store opt-in and opt-out status. Do not assume that an existing phone number implies messaging consent.


Phase 12: Replace OAuth Connectors

Base44 connectors and built-in integrations are not the same thing.

An integration uses your provider account to deliver a capability.

A connector allows a user or workspace to connect its own third-party account through OAuth.

For each connector, determine the sharing model.

Shared connection

One connection for the whole application:

app_id
connector_type
access_token
refresh_token
expires_at

Per-user connection

One connection per user:

app_id
user_id
connector_type
access_token
refresh_token
expires_at

Workspace-owned OAuth application

Store OAuth application credentials separately from user authorization tokens.

Encrypt:

  • Client secrets
  • Access tokens
  • Refresh tokens
  • Signing secrets

Do not store OAuth tokens in normal frontend-readable entities.

Implement:

GET /api/connectors/:provider/authorize
GET /api/connectors/:provider/callback
POST /api/connectors/:provider/disconnect
GET /api/connectors/:provider/status

Refresh tokens before expiration and handle revoked authorization cleanly.


Phase 13: Replace Realtime Subscriptions

Base44 applications may call:

base44.entities.Message.subscribe(callback);

or:

base44.agents.subscribeToConversation(
  conversationId,
  callback
);

A replacement can use Socket.IO.

Server

function emitEntityEvent(entityName, event) {
  io.to(`entity:${entityName}`).emit(
    `entity:${entityName}`,
    event
  );
}

Expected event shape:

{
  id: record.id,
  type: "create",
  data: record
}

Frontend

function subscribeToEntity(entityName, callback) {
  const eventName = `entity:${entityName}`;

  socket.emit("join", {
    room: eventName
  });

  socket.on(eventName, callback);

  return () => {
    socket.off(eventName, callback);
    socket.emit("leave", {
      room: eventName
    });
  };
}

Security warning

Do not broadcast private entity changes globally and rely only on frontend filtering.

This is unsafe:

Broadcast every private message to every connected client
Let clients ignore messages not addressed to them

Frontend filtering is not access control.

Use authorized rooms:

user:{userId}
organization:{organizationId}
conversation:{conversationId}
project:{projectId}

Verify that the authenticated socket user is allowed to join each room.


Phase 14: Replace AI Agents

An agent generally consists of:

  • Instructions
  • Tool permissions
  • Conversation
  • Messages
  • LLM call loop
  • Tool execution
  • Realtime response delivery

Suggested schema:

model Agent {
  id           String   @id
  name         String   @unique
  instructions String
  toolConfig   Json
  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt
}

model Conversation {
  id        String    @id @default(cuid())
  agentId   String
  userId    String
  metadata  Json?
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  messages  Message[]

  @@index([userId, agentId])
}

model Message {
  id             String   @id @default(cuid())
  conversationId String
  role           String
  content        String?
  toolCalls      Json?
  createdAt      DateTime @default(now())

  @@index([conversationId, createdAt])
}

Tool execution must enforce:

  • Agent permission
  • User permission
  • Entity permission
  • Operation permission
  • Input validation
  • Output filtering
  • Rate limits
  • Audit logging

An agent acting for a normal user should not silently become a service-role administrator.



🤝 Rather have it done for you?

I'm Will Kode, and migrating Base44 apps to owner-controlled infrastructure is exactly what I do — same frontend, your backend, zero data loss, with a real cutover and rollback plan.

Hire me to migrate your app → or start with the free Migration Planner to get an instant scan and quote.


Phase 15: Migrate Payments

Payment migration requires more than replacing one API call.

Inventory:

  • Products
  • Prices
  • Customers
  • Payment methods
  • Subscriptions
  • Invoices
  • Payment intents
  • Checkout sessions
  • Connected accounts
  • Refunds
  • Disputes
  • Payouts
  • Webhooks
  • Internal transaction records

15.1 Preserve provider IDs

Store provider identifiers:

stripe_customer_id
stripe_subscription_id
stripe_payment_intent_id
square_customer_id
square_subscription_id

Do not replace provider IDs with internal IDs.

Use both.

15.2 Never trust frontend payment amounts

Bad:

const { amount } = req.body;

await stripe.paymentIntents.create({
  amount
});

Correct:

  1. Receive product, booking, or quote ID.
  2. Load authoritative records.
  3. Calculate the amount server-side.
  4. Compare with existing payment state.
  5. Create the provider request.
  6. Store the provider ID.
  7. Confirm final status through a verified webhook.

15.3 Subscription cutover

When keeping the same payment account, existing subscriptions may continue running.

The migration must:

  • Preserve provider customer IDs
  • Preserve subscription IDs
  • Reconfigure webhook URLs
  • Import internal subscription state
  • Reconcile provider status
  • Test renewals
  • Test cancellations
  • Test failed payments
  • Test upgrades and downgrades

Do not create duplicate subscriptions during cutover.


Phase 16: Migrate Files

Create a file manifest:

{
  "source_url": "https://...",
  "source_type": "public",
  "entity": "Project",
  "record_id": "project_123",
  "field": "cover_image",
  "destination_key": null,
  "status": "pending",
  "checksum": null
}

Migration process:

  1. Export all file references.
  2. Download each file.
  3. Calculate checksum.
  4. Upload to destination storage.
  5. Preserve content type.
  6. Set access policy.
  7. Update database references.
  8. Verify destination checksum.
  9. Record failures.
  10. Retry failures.
  11. Keep the source available during rollback window.

Do not only migrate files referenced by currently visible UI pages. Search all records and JSON fields.


Phase 17: Migrate Production Data

Use an extract-transform-load process.

Extract

  • Export every entity
  • Preserve IDs
  • Preserve timestamps
  • Preserve null values
  • Preserve unknown fields
  • Record row counts
  • Export in deterministic batches
  • Store raw export unchanged

Transform

  • Rename fields
  • Normalize dates
  • Convert booleans
  • Convert numbers
  • Map enums
  • Resolve references
  • Remove invalid duplicates
  • Split child records
  • Preserve source metadata

Load

Load in dependency order:

  1. Users
  2. Organizations
  3. Parent entities
  4. Child entities
  5. Join tables
  6. Transactions
  7. Logs and history
  8. Settings
  9. File references

For cyclic relationships:

  1. Insert records with nullable references.
  2. Build ID map.
  3. Update relationships in a second pass.

Verify

For every entity, compare:

  • Source count
  • Destination count
  • Distinct ID count
  • Null count by important field
  • Sum of financial fields
  • Min and max dates
  • Orphaned relationship count
  • Duplicate count
  • Checksum or aggregate hash

Example:

SELECT
  COUNT(*) AS total,
  COUNT(DISTINCT id) AS unique_ids,
  MIN(created_at) AS earliest,
  MAX(created_at) AS latest
FROM bookings;

For financial records:

SELECT
  COUNT(*) AS total,
  SUM(amount_cents) AS total_amount,
  SUM(refund_amount_cents) AS total_refunds
FROM payments;

Record-level sampling is not enough for financial data.


Phase 18: Migrate User Authorization

Do not simply reproduce insecure frontend filtering.

Create an authorization matrix:

| Resource | Public | Owner | Member | Manager | Admin | | --------------- | ------: | ----: | -------: | -------: | ----: | | Public product | Read | Read | Read | Read | All | | Private project | No | All | Assigned | All org | All | | Payment | No | Own | No | Org read | All | | User profile | Limited | Own | Limited | Limited | All | | Admin settings | No | No | No | No | All |

Implement authorization server-side.

For multi-tenant applications, every relevant query should be scoped by a trusted tenant ID:

const project = await prisma.project.findFirst({
  where: {
    id: projectId,
    organizationId: req.user.organizationId
  }
});

Do not query by ID first and check tenant ownership later when you can scope the query itself.


Phase 19: Analytics and Application Logs

Replace:

base44.analytics.track({
  eventName,
  properties
});

with:

POST /api/analytics/events

Suggested table:

model AnalyticsEvent {
  id         String   @id @default(cuid())
  userId     String?
  sessionId  String?
  eventName  String
  properties Json?
  path       String?
  createdAt  DateTime @default(now())

  @@index([eventName, createdAt])
  @@index([userId, createdAt])
}

Avoid sending:

  • Passwords
  • Access tokens
  • Full payment details
  • Medical information
  • Sensitive documents
  • Unnecessary PII

Application logging should include:

  • Request ID
  • User ID
  • Route
  • Function name
  • Duration
  • Status
  • Error class
  • Provider request ID
  • Job ID

Never log secrets or complete authorization headers.


Phase 20: Remove Base44 Runtime Dependencies

After the replacement backend works:

npm uninstall @base44/sdk @base44/vite-plugin

But inspect what the Vite plugin was doing first.

Removing it may also remove support for the

@/
alias.

Add the alias explicitly:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";

export default defineConfig({
  plugins: [react()],

  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src")
    }
  }
});

Search for remaining external dependencies:

rg -n "@base44/sdk|@base44/vite-plugin|base44\.com" .

You will still see many references to the

base44
variable if you intentionally kept the compatibility client.

That is acceptable.

The goal is not to remove the word

base44
.

The goal is to remove requests to Base44 infrastructure.

Also inspect:

  • Literal webhook URLs
  • Hosted-login redirects
  • Base44 file URLs
  • Base44 environment variables
  • Base44-specific deployment scripts
  • Base44-specific CSP rules
  • Base44-specific callback URLs
  • Base44-specific documentation

Phase 21: Local Development Environment

A minimal PostgreSQL Docker configuration:

services:
  postgres:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Example environment file:

NODE_ENV=development
PORT=3001

DATABASE_URL=postgresql://app:app@localhost:5432/app

FRONTEND_URL=http://localhost:5173
API_URL=http://localhost:3001

JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=
COOKIE_SECRET=

EMAIL_PROVIDER=resend
RESEND_API_KEY=

STORAGE_PROVIDER=r2
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=
R2_PUBLIC_URL=

STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

OPENAI_API_KEY=

Commit

.env.example
, never
.env
.

If using:

.env.*

add:

!.env.example
!server/.env.example

A combined development command can use

concurrently
:

{
  "scripts": {
    "dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"",
    "dev:client": "vite",
    "dev:server": "npm --prefix server run dev"
  }
}

Phase 22: Testing Strategy

A migration is not complete because:

  • The frontend compiles
  • The backend starts
  • The homepage loads
  • A health endpoint returns 200

Test the complete system against a live database.

Unit tests

Test:

  • Fee calculations
  • Date calculations
  • Permission predicates
  • Case conversion
  • Data transformations
  • Webhook parsing
  • Signature verification
  • Token validation
  • Workflow conditions

Integration tests

Test:

  • Registration
  • OTP
  • Login
  • Refresh
  • Logout
  • Password reset
  • CRUD
  • Ownership
  • Admin access
  • Payments
  • File uploads
  • Signed URLs
  • Provider adapters
  • Background jobs

End-to-end tests

Test each major user journey:

Register
Verify
Complete onboarding
Create project
Invite collaborator
Upload file
Submit payment
Receive notification
Admin reviews activity
User returns after session expiration

Permission tests

For each sensitive endpoint, test:

  • Unauthenticated
  • Wrong user
  • Wrong organization
  • Normal user
  • Manager
  • Administrator
  • Suspended user
  • Deleted user
  • Expired session
  • Modified resource ID

Webhook tests

Test:

  • Valid signature
  • Invalid signature
  • Duplicate event
  • Out-of-order event
  • Provider retry
  • Processing failure
  • Unknown event
  • Missing record
  • Already completed action

Data verification tests

Verify:

  • Record counts
  • Relationship integrity
  • Financial totals
  • User-role distribution
  • File checksums
  • Date ranges
  • Enum values
  • Null rates
  • Duplicate IDs
  • Orphan records

Phase 23: Deployment Architecture

A practical production deployment may use:

Cloudflare
  -> static React frontend
  -> API domain

API load balancer
  -> Node API instances
  -> PostgreSQL
  -> Redis or job store
  -> background workers
  -> object storage

Recommended separation:

app.example.com
api.example.com
files.example.com

Production requirements:

  • TLS
  • Secure headers
  • CORS allowlist
  • CSP
  • Rate limiting
  • Request size limits
  • Database pooling
  • Health checks
  • Structured logs
  • Error monitoring
  • Metrics
  • Backups
  • Restore testing
  • Secret rotation
  • Dependency scanning
  • Automated deployments
  • Staging environment

Phase 24: Cutover Plan

Do not switch production traffic without a cutover plan.

Recommended sequence

1. Deploy destination infrastructure

Deploy:

  • Database
  • API
  • Workers
  • Storage
  • Authentication
  • Realtime
  • Monitoring

2. Perform rehearsal migration

Run a full data migration into staging.

Measure:

  • Export time
  • Transformation time
  • Import time
  • File-transfer time
  • Verification time
  • Downtime requirement

3. Run acceptance testing

Have real application stakeholders test staging.

4. Announce maintenance window

If writes cannot be synchronized, define a production freeze.

5. Take final backup

Export:

  • Entities
  • Users
  • Files
  • Payment state
  • Configuration

6. Run final delta migration

Import records created or updated since the rehearsal export.

7. Reconcile

Confirm:

  • Counts
  • Totals
  • Relationships
  • Files
  • Users
  • Payment state

8. Update external systems

Update:

  • OAuth callbacks
  • Webhook URLs
  • Email links
  • Password-reset URLs
  • DNS
  • Mobile deep links
  • CORS
  • CSP
  • Allowed origins
  • API base URL

9. Switch traffic

Update DNS or frontend configuration.

10. Monitor aggressively

Watch:

  • Error rate
  • Login failures
  • Database load
  • API latency
  • Payment events
  • Webhook failures
  • Job failures
  • File access
  • Realtime connections

Phase 25: Rollback Plan

Rollback is not “put the old DNS back.”

You must account for writes made after cutover.

Define:

  • Rollback decision owner
  • Rollback window
  • Maximum acceptable downtime
  • New-system write handling
  • Payment reconciliation
  • User-account reconciliation
  • File reconciliation
  • DNS TTL
  • Database restoration
  • Communication process

Possible strategy:

  1. Keep Base44 production unchanged during final testing.
  2. Freeze Base44 writes during cutover.
  3. Migrate final delta.
  4. Switch traffic.
  5. Keep old system read-only.
  6. Monitor.
  7. If rollback is required, export new-system writes before reversing traffic.
  8. Reconcile those writes into the old system or handle them manually.

Without write reconciliation, rollback can cause data loss.


Phase 26: Post-Migration Hardening

After successful cutover:

  • Rotate all migration credentials
  • Remove temporary access
  • Disable old webhooks
  • Remove unused OAuth callbacks
  • Revoke old API keys
  • Archive raw exports securely
  • Remove debug logging
  • Enable production rate limits
  • Verify backup jobs
  • Perform restore test
  • Review database indexes
  • Review slow queries
  • Review authorization logs
  • Review payment reconciliation
  • Review file-access rules
  • Run dependency audit
  • Run secret scan
  • Run penetration testing for high-risk apps

Do not immediately delete the old application.

Retain it according to the agreed rollback and compliance period.


Common Migration Mistakes

Mistake 1: Rewriting every frontend page

Replace the SDK boundary first.

Mistake 2: Treating frontend filters as security

Authorization belongs on the server.

Mistake 3: Migrating only declared entity fields

Search for fields used in code but missing from schemas.

Mistake 4: Creating a new ID system immediately

Preserve existing IDs when practical.

Mistake 5: Trusting payment amounts from the browser

Recalculate everything server-side.

Mistake 6: Replacing durable workflows with
setTimeout

Persist workflow state.

Mistake 7: Broadcasting private realtime data globally

Authorize subscriptions and rooms.

Mistake 8: Migrating code but not production data

A GitHub export is not a data export.

Mistake 9: Forgetting existing files

Build a file manifest and verify checksums.

Mistake 10: Assuming password hashes can be migrated

Plan a secure password-reset or account-activation flow.

Mistake 11: Removing the Base44 Vite plugin without restoring aliases

Add the

@/
alias explicitly.

Mistake 12: Testing only whether the server starts

Test live database transactions and complete user journeys.

Mistake 13: Rebuilding Base44 itself

A single-app migration does not require a generic dynamic entity engine, arbitrary code sandbox, universal connector catalog, or multi-tenant BaaS.

Build what the application needs.


Complete Migration Checklist

Discovery

  • [ ] Repository exported
  • [ ] Application version tagged
  • [ ] Entities cataloged
  • [ ] Undeclared fields cataloged
  • [ ] Functions cataloged
  • [ ] Workflows cataloged
  • [ ] Agents cataloged
  • [ ] Connectors cataloged
  • [ ] Integrations cataloged
  • [ ] Realtime usage cataloged
  • [ ] Authentication flows cataloged
  • [ ] Payment flows cataloged
  • [ ] Files cataloged
  • [ ] Secrets scanned
  • [ ] Critical journeys documented

Architecture

  • [ ] Backend selected
  • [ ] Database selected
  • [ ] Auth selected
  • [ ] Storage selected
  • [ ] Email provider selected
  • [ ] SMS provider selected
  • [ ] AI provider selected
  • [ ] Realtime selected
  • [ ] Job system selected
  • [ ] Hosting selected
  • [ ] Monitoring selected
  • [ ] Backup strategy selected

Database

  • [ ] Models created
  • [ ] IDs preserved or mapped
  • [ ] Built-in fields mapped
  • [ ] Relationships created
  • [ ] Indexes created
  • [ ] JSON fields handled
  • [ ] Financial values use safe numeric types
  • [ ] Authorization model defined
  • [ ] Migration scripts written
  • [ ] Verification scripts written

Backend

  • [ ] Generic entity router created
  • [ ] SDK method shapes preserved
  • [ ] Function router created
  • [ ] Business logic extracted
  • [ ] Input validation added
  • [ ] Authorization added
  • [ ] Idempotency added
  • [ ] Webhooks secured
  • [ ] Jobs made durable
  • [ ] Errors standardized
  • [ ] Logs structured

Authentication

  • [ ] Registration implemented
  • [ ] OTP implemented
  • [ ] Login implemented
  • [ ] Refresh implemented
  • [ ] Logout implemented
  • [ ] Password reset implemented
  • [ ] OAuth implemented
  • [ ] Existing users imported
  • [ ] Password migration plan implemented
  • [ ] Roles migrated
  • [ ] Session restoration tested

Integrations

  • [ ] Email replaced
  • [ ] SMS replaced
  • [ ] LLM replaced
  • [ ] Image generation replaced
  • [ ] File upload replaced
  • [ ] Private files replaced
  • [ ] Signed URLs replaced
  • [ ] Connectors replaced
  • [ ] Provider credentials secured
  • [ ] Failure behavior tested

Realtime and Agents

  • [ ] Entity subscriptions replaced
  • [ ] Private rooms authorized
  • [ ] Reconnect behavior tested
  • [ ] Agent conversations migrated
  • [ ] Agent permissions enforced
  • [ ] Tool calls validated
  • [ ] Streaming or message updates tested

Payments

  • [ ] Products inventoried
  • [ ] Customers inventoried
  • [ ] Subscriptions inventoried
  • [ ] Provider IDs preserved
  • [ ] Checkout migrated
  • [ ] Webhooks migrated
  • [ ] Refunds tested
  • [ ] Renewals tested
  • [ ] Failed payments tested
  • [ ] Reconciliation completed

Deployment

  • [ ] Staging deployed
  • [ ] Rehearsal migration completed
  • [ ] Acceptance testing completed
  • [ ] Final backup completed
  • [ ] Delta migration completed
  • [ ] DNS prepared
  • [ ] OAuth callbacks updated
  • [ ] Webhooks updated
  • [ ] Monitoring enabled
  • [ ] Rollback plan approved
  • [ ] Production cutover completed

Post-migration

  • [ ] Old secrets rotated
  • [ ] Temporary access removed
  • [ ] Old webhooks disabled
  • [ ] Backups verified
  • [ ] Restore test completed
  • [ ] Slow queries reviewed
  • [ ] Security review completed
  • [ ] Financial reconciliation completed
  • [ ] Old application retained for rollback window
  • [ ] Final documentation delivered

Final Takeaway

Migrating a Base44 application is tractable because the frontend usually depends on a relatively small SDK surface.

The most effective strategy is normally:

  1. Inventory every Base44 dependency.
  2. Preserve the working React frontend.
  3. Replace
    base44Client.js
    with a compatible client.
  4. Build a standalone API and database.
  5. Recreate authentication.
  6. Port backend functions by domain.
  7. Replace integrations and connectors.
  8. Rebuild realtime, automations, agents, and payments where used.
  9. Migrate data and files with verification.
  10. Test permissions and complete user journeys.
  11. Cut over with a real rollback plan.

The hard part is not generating CRUD routes.

The hard part is reproducing the application’s operational contract without losing data, changing business logic, weakening authorization, duplicating payments, breaking integrations, or forcing a complete frontend rewrite.

A GitHub export gives you the source.

A migration gives you an independently operating application.


🤝 Rather have it done for you?

I'm Will Kode, and migrating Base44 apps to owner-controlled infrastructure is exactly what I do — same frontend, your backend, zero data loss, with a real cutover and rollback plan.

Hire me to migrate your app → or start with the free Migration Planner to get an instant scan and quote.


Turn your idea into a build-ready blueprint

Generate your data model, roles, security rules, and copy-paste build prompts in minutes.