TL;DR: LingCode Cloud is fast to prototype on and forgiving in development โ but a few habits separate a demo from a backend you can ship. Model your data for one-table-at-a-time reads, paginate and count without scanning, index the columns you filter on, turn on row-level security everywhere the anon key can reach, keep private keys in the vault and server logic in functions, and stay inside your tier's quotas. Then run the pre-ship checklist.
Most of these rules fall out of one fact: the client SDK talks to a thin table-CRUD gateway, not to Postgres. That shapes how you model, how you read, and where security lives. Each section below is a short why plus concrete guidance, with the relevant guide linked so you can go deeper.
The runtime data API is one table at a time โ no JOINs, no upserts, and at most 200 rows per page. If you design a normalized schema and then try to stitch it back together with client-side fan-out, every screen costs a round-trip per relation. Model for your read paths instead.
select() from it exactly like a table. The JOIN runs in Postgres; the client sees one flat result.bigint generated always as identity or uuid โ never a mutable natural key.user_id column on every user-owned row so RLS has something to key off.-- a read-path view: join once in the DB, select() it like a table
create view order_lines_expanded as
select l.id, l.order_id, l.qty, p.name as product_name, p.price
from order_lines l
join products p on p.id = l.product_id;
See the Database guide for the full query-builder surface and the no-JOIN/upsert rule.
select() returns at most 200 rows. Reach for pagination early โ it's not an optimization, it's the ceiling. Order by an indexed column and page with .limit() and .range().
const { data } = await lingcode
.from('posts')
.order('created_at', { ascending: false }) // indexed column
.range(0, 49) // first 50
.select();
Counting is the trap. Don't count by fetching every page and summing lengths โ that's an O(n) scan over the whole table on every view. Instead:
select() the single aggregate row.More on filters, ordering, and ranges in the Database guide.
An unindexed filter or sort scans the table. That's invisible at 50 rows and fatal at 50,000. In a migration, add an index for every column you filter or order by at scale, and for every foreign-key column.
create index if not exists idx_posts_author on posts (author_id);
create index if not exists idx_posts_created_at on posts (created_at desc);
pgvector columns need a vector index once the table grows โ see the Vector search guide. And you don't have to find these by hand: the advisors scanner flags missing indexes and unindexed foreign keys for you. Run it before you ship โ see Scan your backend with advisors. Migrations basics are in the Database guide.
The anon key ships in your browser bundle, so the server is the only place security actually happens. Treat the database as hostile-by-default and let RLS gate every read and write.
The full policy model is in the Security guide.
If a call needs a private key, it can't run in the browser โ the key would be visible to anyone who opens dev tools. Vendor API calls and any key-bearing logic belong in functions that read ctx.secrets:
// function โ the secret never reaches the client
export default async (req, ctx) => {
const key = ctx.secrets.STRIPE_SECRET_KEY;
// ...call the vendor server-side, return only what's safe...
};
Functions are short-lived, though. Two cases belong in a hosted Worker route instead of a function:
See the Functions guide and the Hosting guide.
Every tier has caps, and hitting one in production is a worse surprise than planning for it. Know yours before launch:
3s / 10s / 30s depending on tier.The backend flags storage at 80% and 95% โ watch for those warnings rather than waiting for a hard stop. And because functions are short-lived (โค30s), anything longer must be offloaded โ split the work, queue it, or move it to a hosted Worker. Tier caps are listed on the pricing page.
Schema drift between environments is the quiet killer of "works on my backend." Make migrations the only way schema changes โ never click-edit structure ad hoc.
apply_migration.create table if not exists, create index if not exists โ so re-applying is safe.create table if not exists invites (
id uuid primary key default gen_random_uuid(),
email text not null,
created_at timestamptz not null default now()
);
create index if not exists idx_invites_email on invites (email);
Migration mechanics live in the Database guide.
Three habits keep an app responsive without extra infrastructure:
subscribe() to get row changes pushed over SSE instead of re-fetching on a timer โ fewer requests, lower latency. See the Realtime guide.Before you flip an app to production, confirm: