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.

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.jswith a compatibility client that exposes the same methods but communicates with your new backend.
This turns the project from a full application rewrite into:
- A backend migration.
- An authentication migration.
- A data migration.
- An integration migration.
- A replacement SDK client.
- 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.
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:
- Create a release tag.
- Export the current production code.
- Export the production data.
- Export a file manifest.
- Record environment variables.
- Record webhook endpoints.
- Capture critical user journeys.
- Record expected outputs for calculations.
- Create test users for each role.
- 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
_idautomatically.
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:
- Create your own provider application.
- Configure new callback URLs.
- Store client credentials securely.
- Request minimum scopes.
- Validate the state parameter.
- Validate nonce when applicable.
- Link accounts safely.
- Prevent account takeover through email collision.
- Handle provider token refresh.
- 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:
- Import users without usable passwords.
- Mark them as migration-required.
- Send a secure password-setup link.
- Require password creation at first login.
- 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.jsin 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:
- Transport
- Authentication
- Authorization
- Input validation
- Business logic
- Data access
- Provider calls
- Response serialization
- Logging
- 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
- 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
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
- 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:
- Store an object key, not a permanent public URL.
- Verify authorization server-side.
- Generate a short-lived signed URL.
- 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:
- Receive product, booking, or quote ID.
- Load authoritative records.
- Calculate the amount server-side.
- Compare with existing payment state.
- Create the provider request.
- Store the provider ID.
- 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:
- Export all file references.
- Download each file.
- Calculate checksum.
- Upload to destination storage.
- Preserve content type.
- Set access policy.
- Update database references.
- Verify destination checksum.
- Record failures.
- Retry failures.
- 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:
- Users
- Organizations
- Parent entities
- Child entities
- Join tables
- Transactions
- Logs and history
- Settings
- File references
For cyclic relationships:
- Insert records with nullable references.
- Build ID map.
- 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
base44variable 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:
- Keep Base44 production unchanged during final testing.
- Freeze Base44 writes during cutover.
- Migrate final delta.
- Switch traffic.
- Keep old system read-only.
- Monitor.
- If rollback is required, export new-system writes before reversing traffic.
- 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:
- Inventory every Base44 dependency.
- Preserve the working React frontend.
- Replace
base44Client.js
with a compatible client. - Build a standalone API and database.
- Recreate authentication.
- Port backend functions by domain.
- Replace integrations and connectors.
- Rebuild realtime, automations, agents, and payments where used.
- Migrate data and files with verification.
- Test permissions and complete user journeys.
- 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.
