Base44's entity layer is a managed, MongoDB-compatible NoSQL database with strong opinions: no JOINs across entities, no transactions, opaque indexing managed by the platform, and a documented 5,000-record cap per request since November 2025. Production-grade schema design on Base44 means working with the grain. Always include a created_by field for ownership filtering. Denormalize aggressively because there are no joins. Add an explicit tenant_id field on every entity in multi-tenant apps. Paginate every list query with the SDK's cursor. Use count() and aggregate() for totals and grouped sums, and pre-compute summary entities only where a dashboard needs them faster or over more groups than aggregate() returns. Mirror critical data to external Postgres for backup and complex queries. Never let the AI agent execute schema migrations.
Why this matters
The data layer is where most production-readiness questions become concrete. Schema decisions you make in week one shape what's possible in year three. On Base44, the platform's data-layer opinions push you toward certain patterns and away from others. Designing with the grain produces apps that scale cleanly. Designing against the grain produces apps that hit walls and require migration to fix.
This article, from the lead engineer at Base44Devs, covers the patterns we apply on every greenfield Base44 build and the patterns we recommend when auditing existing apps that have grown into pain.
What the entity layer is
Base44 stores each entity's documents in "Base44's NoSQL database", which its docs call "MongoDB-compatible" (entities overview, read 17 Oct 2026). The entity layer has these properties:
- Schema defined in the editor or as JSON files in
base44/entities/that version with your code; no migration log. - Per-entity CRUD via the SDK, plus
count()and groupedaggregate(); no JOINs. - MongoDB-style filter operators (
$gte,$in,$orand others), plusnullto match null fields. - Indexing managed by the platform; not user-configurable.
- No transactions across entities.
- 5,000-record-per-list cap as of November 2025.
- Access rules the AI sets up as it builds; checking that they enforce ownership and tenant isolation is your job.
Knowing this shape lets you design with it.
Pattern 1: every entity has created_by
Even if you don't think you need user ownership today, add the field. It's free if unused and essential if needed.
User entity:
- id (string, auto)
- email (string)
- full_name (string)
- role (string, default "user")
- created_date (timestamp, auto)
Todo entity:
- id (string, auto)
- title (string)
- done (boolean, default false)
- created_by (string, indexed-by-platform) // set automatically by the platform on create
- created_date (timestamp, auto)
Every list query filters by created_by. Every update checks that the calling user owns the record. ESLint rules flag any query missing the filter.
This isn't optional. Base44's AI sets up each entity's access rules as it builds, and its docs warn that if permissions are not set up correctly, "the wrong people could view or change data in your app." Pair the field with a Creator Only rule so the server enforces ownership even when a query forgets the filter. We covered the security implications in the SDK reference and security hardening checklist.
Pattern 2: denormalize aggressively
Without JOINs, the cost of fetching related data is high. Denormalize what you display together.
// Bad: every list of todos requires a second list of projects
Todo:
- project_id (string)
// Better: store the displayed fields on the Todo
Todo:
- project_id (string)
- project_name (string, denormalized)
- project_color (string, denormalized)
When the project changes, update the denormalized fields on every todo. This is fine for apps where projects rarely change; for apps where they do, write a backend function that propagates project changes to all dependent todos.
The trade: denormalization adds write complexity and storage cost; it dramatically reduces read complexity and latency. On Base44, where reads are the slow path, denormalization usually wins.
Pattern 3: tenant_id everywhere for multi-tenant apps
For B2B SaaS:
Organization entity:
- id
- name
- plan
- owner_user_id
User entity:
- id
- email
- tenant_id (string) // foreign key to Organization
- role
Todo entity (or any tenant-scoped entity):
- id
- title
- tenant_id (string)
- created_by
Every list call filters by both tenant_id and (where appropriate) created_by:
const myTeamTodos = await base44.entities.Todo.filter({
tenant_id: currentUser.tenant_id,
});
Back the filter with an access rule on each tenant-scoped entity, such as {"data.tenant_id": "{{user.data.tenant_id}}"} on read and update, so the server enforces isolation even when a query forgets it. Lock the User entity's tenant_id so users cannot change their own.
Test with two-account scenarios. Account A in tenant X should never see anything from tenant Y, ever. Rerun the test after any edit that touches a list query or an access rule.
Pattern 4: paginate every list
Since November 2025, list() caps at 5,000 records per call. Even below that cap, large unpaginated lists destroy performance.
const PAGE_SIZE = 50;
const page = await base44.entities.Todo.filter(
{ tenant_id, created_by: me.email },
{ sort: "-created_date", limit: PAGE_SIZE },
);
// page.items holds this page; page.has_more says whether another exists
For pages that need more than the first page, pass the page's next_cursor back as cursor:
async function loadMoreTodos(cursor: string) {
return base44.entities.Todo.filter(
{ tenant_id, created_by: me.email },
{ cursor, limit: PAGE_SIZE },
);
}
Prefer the cursor over skip. The positional form still pages with skip, but records added or deleted between calls shift that window; for cursors, Base44 says "Records added or deleted between pages never shift the boundary."
Pattern 5: aggregate on the server first
There's no SQL, but the SDK does the common aggregates on the server. count() "Counts the records that match a query" without fetching them, and aggregate() "Computes counts, sums, averages, minimums, maximums or distinct counts, grouped by fields", up to four group-by fields and 1,000 result rows (SDK reference, read 17 Oct 2026):
// Open todos in this tenant, without fetching them
const open = await base44.entities.Todo.count({ tenant_id, done: false });
// Completed todos per user, most first
const { rows } = await base44.entities.Todo.aggregate({
query: { tenant_id, done: true },
groupBy: "created_by",
sort: "-count",
});
// rows: [{ created_by: "a@example.com", count: 42 }, ...]
Pre-computing still pays when the same totals are read on every page load, when you need more groups than aggregate() returns, or when you want each day's numbers frozen. Then a scheduled automation runs a function nightly that stores one summary record per user per day:
TodoStats entity (one record per user per day):
- user_email
- date
- total_count
- completed_count
- tenant_id
// base44/functions/computeDailyStats/entry.ts (run nightly by a scheduled automation)
import { createClientFromRequest } from "npm:@base44/sdk";
Deno.serve(async (req) => {
// A scheduled run has no signed-in user, so read and write as the service role.
const db = createClientFromRequest(req).asServiceRole.entities;
const today = new Date().toISOString().split("T")[0];
const day = { created_date: { $gte: today + "T00:00:00Z", $lt: today + "T23:59:59Z" } };
const all = await db.Todo.aggregate({ query: day, groupBy: ["created_by", "tenant_id"] });
// Up to 1,000 groups per call; all.truncated is true when there are more.
const done = await db.Todo.aggregate({ query: { ...day, done: true }, groupBy: "created_by" });
const doneBy = new Map(done.rows.map((r) => [r.created_by, r.count]));
for (const row of all.rows) {
await db.TodoStats.create({
user_email: row.created_by,
date: today,
total_count: row.count,
completed_count: doneBy.get(row.created_by) ?? 0,
tenant_id: row.tenant_id,
});
}
return Response.json({ users: all.rows.length });
});
For live numbers (today's count up to this moment), call count() or aggregate() on demand; the nightly summary is for speed and history.
Pattern 6: avoid cross-entity transactions
Base44 has no transaction() primitive. A multi-entity update can fail halfway, leaving inconsistent state.
Solutions:
Idempotent operations. Design so that re-running the same update produces the same result. If the update fails halfway, you can re-run safely.
Compensating writes. If updating entity A succeeds and entity B fails, write a compensating update to A to roll back. Document this explicitly.
Single-entity sources of truth. If two entities have related state, make one canonical and derive the other. Stripe-as-source-of-truth for billing is a common example: the User entity stores a snapshot of the subscription state, but Stripe is canonical, and a reconciliation job catches drift.
Avoid the situation entirely. Often a "transaction across entities" is a sign of bad schema. If two entities always update together, they might be one entity.
Pattern 7: indexed query patterns
You can't create indexes, but the platform indexes some fields automatically. Empirically, fields that are reliably fast to filter on:
idcreated_datecreated_by- Any field with high cardinality that's frequently filtered on
Fields that may not be indexed:
- Boolean flags (low cardinality)
- Recently-added fields (platform may not have re-indexed)
- Fields used in
$oror complex predicates
Design queries to filter on indexed-likely fields first, with low-cardinality filters as secondary refinements:
// Good: filter by user (high-cardinality, indexed) first
await base44.entities.Todo.filter({
created_by: me.email,
done: false, // secondary refinement
});
// Worse: filter primarily by boolean
await base44.entities.Todo.filter({
done: false, // platform may scan more records
});
Test query latency in production. If a query is slow, restructure the filter.
Pattern 8: external Postgres mirror for production scale
For any app expected to grow past 100K records in a hot entity, mirror to Postgres — it's also the most direct way to confirm your data is protected from loss and exposure:
// base44/functions/mirrorTodoChange/entry.ts
// Run by an entity event automation on Todo (event_types: create, update, delete).
// Log one request body in Function Logs before you map its fields to columns.
Deno.serve(async (req) => {
const change = await req.json();
// Append the raw change to Supabase (a todo_changes table with one jsonb column, "body")
await fetch(`${Deno.env.get("SUPABASE_URL")}/rest/v1/todo_changes`, {
method: "POST",
headers: {
apikey: Deno.env.get("SUPABASE_SERVICE_KEY")!,
"content-type": "application/json",
},
body: JSON.stringify({ body: change }),
});
return new Response("OK", { status: 200 });
});
One gap to plan for: Base44 says "Entity automations only fire for single-record create, update, and delete calls made through the entities API", so writes made with bulkUpdate() or updateMany() never reach the mirror. Run a scheduled full sync as well to catch them.
The mirror gives you:
- SQL query capability (analytics, complex reports).
- Backups you control, beyond Base44's Backup & Restore window (7 days on Elite, 30 on Business and Enterprise, none below Elite).
- Disaster recovery if Base44 has an extended outage.
- Migration runway if you ever leave the platform.
- Real indexing control.
The trade: more moving parts, more cost ($25/month Supabase), more places where state can drift. Worth it past a certain scale.
Pattern 9: archive old data
Apps that retain data forever hit performance walls. Move old data out:
// base44/functions/archiveOldTodos/entry.ts (run monthly by a scheduled automation)
import { createClientFromRequest } from "npm:@base44/sdk";
Deno.serve(async (req) => {
const db = createClientFromRequest(req).asServiceRole.entities;
const sixMonthsAgo = new Date(Date.now() - 180 * 24 * 60 * 60 * 1000).toISOString();
const old = await db.Todo.filter(
{ done: true, completed_at: { $lt: sixMonthsAgo } },
{ sort: "completed_at", limit: 500 },
);
for (const todo of old.items) {
// Write to archive table (external Postgres or another entity)
await pushToArchive(todo);
// Delete from active entity
await db.Todo.delete(todo.id);
}
return Response.json({ archived: old.items.length, more: old.has_more });
});
Archiving keeps the active entity small enough to query fast. Archived data goes somewhere queryable for compliance/legal needs (regulatory retention, customer data export).
Pattern 10: never let the agent run schema migrations
The AI agent treats schema changes as cosmetic. It will rename a field, drop a column, or change a type without warning that the operation is destructive. Schema changes lose data.
Rule: every schema change is propose-then-apply. Have the agent suggest. You snapshot the entity. You apply manually. You verify. We covered this in detail in schema migration best practices.
Common database mistakes on Base44
Trusting Entity.list() defaults. Returns every record the entity's access rules allow. Always filter, and check the rules.
Skipping the second-account isolation test. Multi-tenant bugs hide from solo testing.
Designing schema as if JOINs exist. They don't. Denormalize.
Synchronous N+1 reads. Batch with $in.
Unbounded list queries. Always paginate.
Adding up records in the browser. Use count() and aggregate() on the server; store summaries only for totals every page load reads.
Cross-entity transaction assumptions. Design idempotent operations and compensating writes.
Hot entity above 500K records. Mirror to Postgres or archive.
Letting the AI agent migrate. Always propose-then-apply.
Forgetting tenant_id on a new entity. Every multi-tenant app drifts here as new entities get added.
Database design checklist
- Every entity has created_by.
- Multi-tenant entities have tenant_id.
- Every list call has appropriate ownership and tenant filters.
- Pagination on every list with realistic limits.
- Denormalization where related data is displayed together.
- Totals and grouped sums via
count()andaggregate(); summary entities only where reads need them. - Idempotent operations on every write path.
- No cross-entity transactional assumptions.
- External Postgres mirror for entities approaching 100K.
- Archive job for old data on entities approaching scale limits.
- Schema changes proposed by agent, applied manually with snapshot.
- Second-account test as part of every release.
Want us to audit your database design?
Our $497 audit reviews every entity for ownership filters, tenant isolation, query patterns, and scale readiness. For larger redesigns or external-Postgres migration, we run a dedicated database engagement. Order an audit or book a free 15-minute call.
Related reading
- Base44 Schema Migration Best Practices — the playbook for safely changing schemas after design.
- Base44 SDK Reference — the API surface that compounds with these design patterns.
- Base44 Performance Optimization Guide — query optimization that follows from the schema patterns above.