Skip to content
Developer docs

Best practices

How to build an app that passes review the first time and that a business can trust with its customers: authorization first, then input, data, secrets, speed, and the checklist we review against.

Authorization first

Most leaks are one missing check. Decide who may call each route before you write it, and check it on the server, every time.

  • Deny by default. A route with no guard is public. Every route that isn't meant to be public starts with withViewer and requireAuth.
  • Keep sameOrigin and csrf on the file that mounts your routes. Browser code sends writes with siteFetch, which adds the CSRF token.
  • The owner is in the query. Ids are easy to guess. Every read, update and delete by id carries the owner (user_id = me) in the same query, and someone else's id answers 404, not 403, so it doesn't confirm the row exists.
  • Staff screens need a permission. Your app can't add permissions of its own: the site's permission list is not one of your files. Gate on one the site has, such as site:user:read for screens that show people's details. Hiding a button is not access control.
  • Never trust ownership from the client. user_id, role, status, prices and counters come from the server, never from the request body.
  • Use the site's sign-in. No login, password check, token or session of your own.
src/pages/site-api/apps/my-app/[...path].ts
import type {APIRoute} from 'astro';
import {Hono} from 'hono';

import {csrf, sameOrigin} from '../../../../platform/guards';
import {routes} from '../../../../server/apps/my-app/routes';

export const prerender = false;

// Keep both guards: they stop other websites from sending requests as your visitors.
const app = new Hono()
  .basePath('/site-api/apps/my-app')
  .use(sameOrigin)
  .use(csrf)
  .route('/', routes);

export const ALL: APIRoute = ({request}) => app.fetch(request);
src/server/apps/my-app/routes.ts
import {Hono} from 'hono';

import {problem, requireAuth, requirePermission, withDb, withViewer} from '../../../platform/guards';

export const routes = new Hono()
  // Public: anyone may call it. Say so in a comment, so a reviewer knows it is meant.
  .get('/services', withDb, async c => { … })

  // Signed in, own rows only: the owner is in every query.
  .get('/bookings', withViewer, requireAuth, withDb, async c => { … })

  // Staff only: a permission the site already has. Hiding the button is not enough.
  .get('/signups', withViewer, requireAuth, requirePermission('site:user:read'), withDb, async c => { … });

Prove it with four tests

For every route that reads or writes by id, write an integration test against a real Postgres (TEST_DATABASE_URL=… npm test):

Who callsExpected answer
Signed out401
Signed in, without the permission403
Signed in, another person's id404
The owner, or staff with the permission200

Validate every input

Check the type, the trimmed length, the allowed values and the format of every field, and reject what doesn't fit instead of repairing it. Integers go through Number.isSafeInteger. Lists in a body have a maximum length (100 or fewer). Request bodies are capped at 64 KB.

import {validator} from 'hono/validator';

const bookingBody = validator('json', (value, c) => {
  const v = (value ?? {}) as Record<string, unknown>;
  const serviceId = Number(v.serviceId);
  const startsAt = new Date(String(v.startsAt ?? ''));
  const note = typeof v.note === 'string' ? v.note.trim() : '';
  if (!Number.isSafeInteger(serviceId) || serviceId < 1) {
    return c.json(problem('invalid_service', 'Pick a service.'), 422);
  }
  if (Number.isNaN(startsAt.getTime())) return c.json(problem('invalid_time', 'Pick a time.'), 422);
  if (note.length > 500) {
    return c.json(problem('invalid_note', 'A note can be up to 500 characters.'), 422);
  }
  // Only the known fields go on: never userId, status or a price from the body.
  return {serviceId, startsAt, note: note || null};
});
  • Prepared statements only. Values go into SQL through the tagged forms, never by building strings. See Tagged SQL.
  • HTML is escaped. Astro and React escape values for you. Never innerHTML or set:html with data unless it went through sanitizeHtml().
  • Links people type are parsed with new URL and allowed only with https: (and mailto: where it fits).

Passwords and email addresses

A password never leaves the server

Not in any form (plain, hashed, encrypted or masked), not to anyone, not even to its own user: no response, page, email, log, export, cache or URL carries one. A forgotten password gets a reset link from the site's sign-in. The same goes for sign-in codes, tokens and keys once issued.

An email address is seen only by its own user and the site owner (or staff with permission to see people's details). Everyone else sees a name, or nothing.

WhereWhat it shows
Public pages, public routes, sitemaps, share tagsNo email addresses at all
Lists, comments, reviews, “posted by”, team membersA name or initials
Someone must recognise an address that isn't theirs (an invite already sent)Masked: m•••@gmail.com
Errors and form messagesThe same answer whether an address exists or not
Logs, URLs, background job data, cache keysA user id, never the email

Store only what your app needs. Point at people by user id rather than copying their email or name into your tables, and say what you store in your listing's data note.

The site owner and admins

The owner opens a site's admin with View as admin in the dashboard. It's the only way in, and only the account's owner can use it. Everyone who signs up is a normal user, whatever email they use. Team members get in by signing up on the app and getting a role from the owner.

  • Never create an admin or owner, give out the owner role, or add another way in.
  • The site doesn't know the owner's email, and their viewer.email is empty, so never assume a viewer has one. To email the owner, send kind notification with no to.
  • Check permissions, never a role name or an email address.
  • A duplicate raises P2002, so catch it for a clean answer. Don't use String[] columns: use a related table, or Json for an opaque list.

Secrets

  • No keys, tokens or passwords in your code, your repository or a file in your bundle. Bundles are scanned for private keys and live API keys, and one found is refused.
  • The site's own secrets (DATABASE_URL, SITE_KEY and the rest) are read only on the server, through src/platform/config.ts, and never sent to a browser or a log.
  • Everything in a page or in browser code is public. No hidden admin routes and no secret flags.

Rate limits

Anything public that writes, sends email, costs money or can be guessed needs a limit. The site's built-in limiter covers its own sign-in only, so your app checks its own table, bounded and indexed:

// A public form: at most 30 sign-ups a minute for the whole site.
// Index (created_at) keeps this cheap. LIMIT 31, never count(*).
const recent = await db.$queryRaw<{id: bigint}[]>`
  SELECT id FROM app_my_app_signups
  WHERE created_at > now() - interval '1 minute'
  LIMIT 31`;
if (recent.length > 30) {
  return c.json(problem('rate_limited', 'Too many sign-ups right now. Try again in a minute.'), 429);
}

For signed-in people, limit per user (user_id and created_at in the WHERE, with an index on both). A hard quota (seats, credits) is one conditional UPDATE, never a read followed by a write.

Performance

Sites run on Cloudflare Workers and reach their database over one connection per request. A query takes about 3 ms, but about 1 Worker instance in 15 is slow to reach the database, at about 170 ms a query. Fewer, smaller, indexed queries still matter, on an older site most of all, where each query is about 130 ms.

RuleWhy
At most about 5 database round trips per request, ideally 1 to 3.On a slow Worker instance, five sequential queries are already about a second.
No query inside a loop (N+1).A list of 50 becomes 51 round trips. Use a join or one IN (…) query.
Independent reads in Promise.all.Two reads at once take as long as the slower one, not both.
Select only the columns you show.Less to read, send and serialize, and nothing private by accident.
A LIMIT of 100 or fewer on every read, with keyset pages.An answer never grows with the table.
An index for every query shape.Without one, Postgres reads the whole table.
Heavy work (big files, reports over many rows) in a background job.A request has a short CPU budget.
// WRONG: one query per booking (N+1). 50 bookings = 51 round trips.
for (const b of bookings) {
  b.service = (await db.$queryRaw`
    SELECT title FROM app_my_app_services WHERE id = ${b.service_id}`)[0];
}

// RIGHT: one query with a join.
const rows = await db.$queryRaw<Row[]>`
  SELECT b.id, b.starts_at, s.title
  FROM app_my_app_bookings b JOIN app_my_app_services s ON s.id = b.service_id
  WHERE b.user_id = ${me}
  ORDER BY b.id DESC
  LIMIT 21`;

// RIGHT: reads that don't need each other run at the same time.
const [services, bookings] = await Promise.all([
  db.$queryRaw<Service[]>`SELECT id, title FROM app_my_app_services ORDER BY title LIMIT 100`,
  db.$queryRaw<Booking[]>`SELECT id, starts_at FROM app_my_app_bookings WHERE user_id = ${me} ORDER BY id DESC LIMIT 21`,
]);

The Site Cache

Every database query is billed and a Site Cache read isn't. Fix the query first, then cache what many visitors read and that rarely changes (services, menus, opening hours, settings, totals). Before adding a key, list every place that changes that data and clear it in each one. Server code only, with cache.getOrSet(key, ttl, load).

src/server/apps/my-app/routes.ts
import {cache} from '../../../platform/cache';

const DAY = 86_400; // seconds

// Read-through: the cached value, or load it, keep it for 7 days and return it.
const services = await cache.getOrSet('my-app:services', 7 * DAY, () =>
  db.$queryRaw<Service[]>`SELECT id, title, minutes, price_cents FROM app_my_app_services ORDER BY title LIMIT 100`
);

// In the route that saves a service, right after the write:
await cache.del('my-app:services');
  • The TTL is required, in seconds, from 60 (1 minute) to 604,800 (7 days). Outside that range, the cache is skipped.
  • Clear the key in the same route that saves the data. Freshness comes from the clear, not the TTL.
  • Start your keys with your app's slug (my-app:services) so they never clash with the site's.
  • A key is shared by every visitor: cache only what every visitor sees. Never sessions, roles, carts, prices an order charges, email addresses or anything secret.
  • Values are JSON: dates come back as text, and BigInt values aren't stored.
DataTTL
Cleared by your own route when it changes (settings, services, menus)1 to 7 days
Changes by itself, nothing clears it (counts, “open now”, another service's data)1 to 60 minutes: as old as a visitor may see it
One signed-in person's own data5 to 30 minutes, under their own key
Money and accessNever cached
Sites made before the Site Cache

Older sites may not have src/platform/cache.ts yet, and an app that imports it won't build there. The cache rules served on every site (/api/public/site/v1/agent-docs/cache.md) include a small fallback: keep it in your own folder (src/server/apps/my-app/) and import it from there.

Charge once, and never sell too many

For anything that charges, books or sells something limited. A double click or a lost connection sends the same request twice, and two people can reach the last item at the same moment.

src/server/apps/my-app/routes.ts
import {idempotencyKeyOf, idempotent} from '../../../platform/idempotency';

// The same key again gets the first answer. A thrown error frees the key.
const key = idempotencyKeyOf(c.req.raw);
if (!key) return c.json({error: 'key_required'}, 400);
const {value} = await idempotent(
  db,
  {scope: `booking:${viewer.id}`, key, request: {slotId}},
  tx => tx.booking.create({data: {slotId, userId: viewer.id}, select: {id: true}})
);

// One statement: 40 buyers for 5 items sell exactly 5.
const {count} = await db.product.updateMany({
  where: {id, stock: {gte: quantity}},
  data: {stock: {decrement: quantity}},
});
if (count === 0) return c.json({error: 'sold_out'}, 409);
  • Every charge and booking carries an idempotency key: one per attempt, made in the browser, the same on every retry. The site's own checkout already sends one.
  • idempotent() takes the key and does the work in one transaction, so a repeat gets the first answer. Only database work goes inside it, never a call to a payment provider.
  • The database decides what's left: one conditional update, a unique index, or a short lock. Never read, check in your code, then write.
  • Never use the Site Cache for keys, locks or stock. It has no atomic operations, and a miss must not block a sale.
  • Test it with many requests at once. 40 buyers for 5 items must sell exactly 5.
Sites older than template 0.10.11

They have no idempotent(), and an app that imports it won't build there. Use your own table with a unique scope and key, and INSERT … ON CONFLICT DO NOTHING RETURNING id in a transaction. No row back means it's a repeat.

Never wait long on another service

  • Every call to another service has a short timeout (a few seconds while a visitor waits), checks the answer and has a fallback: the section hides, the page still works.
  • Fail closed: an error never grants access, marks something paid or skips a check.
  • Slow or retried work (many emails, a long AI call, a sync) goes in a background job, not the request.
  • Call a fixed https address written in your code. Never fetch a URL someone typed.
// A call to another service, from a request: short timeout, check the answer, fall back.
const res = await fetch('https://api.example.com/v1/rates', {signal: AbortSignal.timeout(3_000)})
  .catch(() => null);
const rates = res?.ok ? await res.json().catch(() => null) : null;
if (!isRates(rates)) return c.json({rates: null});   // the section hides; the page still works

Accessibility and UI

  • Every image has alt text, every form field a label, every button and link a name, and the page has a language. Review counts these: 3 or more issues fail.
  • Show errors in words next to the form, in an element with role="alert", and keep what the person typed.
  • It works at phone width and with a keyboard, with visible focus and enough contrast.
  • Every colour and word the owner may change lives in your settings.json, so a one-sentence customization can change it.
  • No inline event handlers or style attributes in Astro markup, and no scripts from another site: the site's Content Security Policy blocks them.
  • Your demo page loads in 1.5 seconds or less on a slow phone connection (3 seconds is a warning).

Before you submit

This mirrors what the automatic checks, the code review and the reviewer look at (Review). Tick every line before you send your app:

  • Your repository has ezystudio-app.json at the top, every file is under your own folders, and migrations follow the naming rule.
  • Every route is public on purpose, or has withViewer and requireAuth, plus a permission for staff screens. Every read and write by id has the owner in the query and answers 404 for someone else's id.
  • A test for each route that reads or writes by id: signed out 401, without the permission 403, another person's id 404, the owner 200.
  • Every input is validated: type, trimmed length, allowed values, safe integers. Writes are built field by field.
  • SQL only through the tagged forms. No $queryRawUnsafe, no SQL built from strings, a LIMIT on every read.
  • No password in any form leaves the server. Another person's email appears only for the site owner and permitted staff; everywhere else a name or m•••@domain.
  • No keys, tokens or passwords in the code or the repository.
  • Public forms and anything costly have a rate limit, and answer the same whether something exists or not.
  • At most about 5 database round trips per request, no query inside a loop, independent reads in Promise.all, and the demo page loads in 1.5 seconds or less.
  • Read-heavy shared data is cached with a TTL chosen from the data, and cleared by the route that saves it.
  • Every call to another service has a timeout and a fallback, and nothing waits long on one.
  • Migrations run twice without errors on a throwaway Postgres, are forward only, and never drop or rename what the previous version reads.
  • Every dependency has an exact version, no install scripts, nothing from git or a URL, and no high or critical advisories.
  • Every image has alt text, every field a label, every button and link a name, and the page works at phone width.
  • The data note says what you store and why, screenshots match the app, and prices in the app match the listing.
Read more

Database: Postgres and migrations, Security rules, and the full rules for coding agents on every site at /api/public/site/v1/agent-docs (security, performance, cache, database and review).