Browser (static HTML from Cloudflare Pages)
|
+-- Astro islands (client-side JS only where needed)
|
+-- Plain HTTP to SpacetimeDB
+-- POST /v1/database/{db}/call/{name} → reads (procedures) + writes (reducers)
+-- POST /v1/identity → create auth identity
No WebSocket. No polling. No SDK. Fetch when you need data. Call reducers to write. Done.
| Layer | Tech | Purpose |
|---|---|---|
| Frontend | Astro 5.7.5 (static output) | HTML pages, Cloudflare Pages |
| Styling | Tailwind v4 (@tailwindcss/vite) | Utility CSS |
| Backend | SpacetimeDB 2.0 (Rust WASM) | Database + business logic |
| Auth | SpacetimeDB identity tokens | JWT stored in localStorage |
| Hosting | Cloudflare Pages + any VPS | Static CDN + DB server |
server/src/lib.rs — all tables, reducers, and procedures (SpacetimeDB module)
worker/src/ — search worker (in-memory index, file uploads, HTTP API)
web/src/lib/auth.ts — identity token management (localStorage)
web/src/lib/api.ts — proc() for reads, call() for writes (no raw SQL — query() removed)
web/src/pages/ — Astro pages (static HTML)
web/src/components/ — Astro islands (interactive UI)
web/src/layouts/ — page layouts
scripts/setup.sh — one-command dev environment
scripts/publish.sh — rebuild + republish WASM module
- Tables:
#[spacetimedb::table(accessor = name, public)]— useaccessor, NOTname - Private tables:
#[spacetimedb::table(accessor = name)]— omitpublicfor tables that should not be queryable via SQL endpoint - Primary keys:
#[primary_key]+#[auto_inc]for ID fields,u64type - Identity: Use
ctx.sender()(method, not field) for the caller's identity - Timestamp: Use
ctx.timestamp(field, not method) for current time - Validation: Do it in reducers.
.trim()strings, then check length. Return early withlog::error!()on bad input. Never panic. - Status codes: Use
u8enums with comments (e.g.0 = draft, 1 = active, 2 = sold) - Owner checks: Always verify
thing.owner == ctx.sender()before mutations - Crate type: Must be
cdylibin Cargo.toml - Build target:
wasm32-unknown-unknown
SpacetimeDB has procedures (#[spacetimedb::procedure]) that can return data to the caller.
#[spacetimedb::procedure]
pub fn get_user_stats(ctx: &ProcedureContext) -> Vec<u8> {
let caller = ctx.sender();
let stats = ctx.with_tx(|tx| {
// access db tables via tx
});
serde_json::to_vec(&stats).unwrap_or_default()
}- Use
ProcedureContext(notReducerContext) - Access the database via
ctx.with_tx(|tx| { ... }) - Called via the same HTTP endpoint as reducers:
POST /v1/database/{db}/call/{name} - Can return data (unlike reducers which are fire-and-forget)
- Use for authenticated reads where SQL queries on public tables are insufficient
- Static output:
output: 'static'in astro.config.mjs — NO SSR - Reads: Use
proc('name', [args])orprocAuth('name', [args])fromlib/api.ts— NEVER use raw SQL - No raw SQL:
query()has been removed. All reads go through server-side procedures. - Writes: Use
call('reducer_name', [args])fromlib/api.ts— auto-includes auth - Procedures: Use
proc('name', [args])orprocAuth('name', [args])fromlib/api.ts - Auth: Handled automatically by
lib/auth.tson firstcall()— creates identity if needed - No polling: Fetch on page load + fetch after mutations. Never setInterval.
- XSS prevention: Always use
esc()fromlib/api.tswhen inserting user content into HTML - SQL injection prevention: Use
sanitizeHex(),sanitizeSlug(),sanitizeId()fromlib/api.tsbefore interpolating values into SQL - Islands: Use
<script>in .astro files for client interactivity. Zero JS on read-only pages. - Tailwind v4: Via
@tailwindcss/viteplugin, imported in global.css as@import "tailwindcss" - Astro version: Pinned to 5.7.5 (newer versions have tinyexec bug)
- NO TEMPLATE LITERALS in Astro
<script>tags — esbuild chokes on backticks. Use string concatenation ('a' + var + 'b') instead. Template literals work fine in standalone .ts files.
- First
call()triggersgetAuth()->POST /v1/identity-> stores JWT in localStorage - All subsequent
call()includesAuthorization: Bearer <token> ctx.sender()in reducers returns the caller's identity- For OAuth: run OAuth client-side -> call
link_oauthreducer with provider info - SpacetimeDB identity = the real auth. OAuth = profile info linked to it.
- Always validate identity with
isValidIdentity()fromlib/auth.tsbefore using in SQL queries - Every
publishinvalidates ALL existing JWT tokens — users must re-authenticate
- Verify sender exists: Always check that
ctx.sender()has a row in the user table before allowing mutations - Owner checks:
thing.owner == ctx.sender()before updates/deletes - Rate limiting: Use a
rate_limittable withidentity+last_action_atto throttle expensive operations - Input validation:
.trim()all strings, check lengths, validate enums against allowlists - Constant-time comparison: Never use
==for password hashes — useconstant_time_eqor similar - Private tables: Omit
publicfrom#[spacetimedb::table]for sensitive data — private tables cannot be queried via the SQL endpoint - Frontend sanitization: Use
sanitizeHex()andsanitizeSlug()fromlib/api.tsto prevent SQL injection when interpolating user input into SQL strings - Admin checks: Use a
rolefield on the user table and verify role before admin operations
- Use LIMIT for pagination:
SELECT * FROM thing WHERE id > {cursor} LIMIT {page_size} - Use COUNT(*):
SELECT COUNT(*) FROM thing WHERE ...instead of fetching all rows just to count - Select only needed columns:
SELECT id, name FROM thing— neverSELECT *from frontend - Cursor-based pagination: Use
WHERE id > {last_id} LIMIT Nsince OFFSET is not supported - In-memory indexing: For search/filtering at scale, use a worker process that maintains in-memory indexes
- Incremental sync: Track
updated_aton tables, only fetch changes since last sync
- Never use
depends_onbetween services with persistent volumes — recreating the dependency recreates volumes and wipes data - Use
restart: unless-stoppedon all services for automatic recovery - Every publish invalidates ALL existing JWT tokens — plan for re-authentication after deploys
PUBLIC_STDB_URL— SpacetimeDB URL (default:http://localhost:3000)PUBLIC_STDB_DB— Database name (default:app)- Must be prefixed with
PUBLIC_to be available in client-side Astro code
# Dev environment (first time)
bash scripts/setup.sh
# Rebuild WASM after changing server/src/lib.rs
bash scripts/publish.sh
# Build static site for deployment
cd web && npm run build # output in web/dist/
# Run Astro dev server (without Docker)
cd web && npm run dev- Add struct in
server/src/lib.rs:#[spacetimedb::table(accessor = thing, public)] pub struct Thing { #[primary_key] #[auto_inc] pub id: u64, pub owner: Identity, pub name: String, pub created_at: Timestamp, }
- Add reducers (create, update, delete) with owner checks and
.trim()validation - Add a procedure to return data:
#[spacetimedb::procedure] pub fn get_things(...) -> String - Run
bash scripts/publish.sh - Read from frontend:
proc('get_things')orprocAuth('get_my_things') - Write from frontend:
call('create_thing', ['name'])
- Create
web/src/pages/thing.astro - Use Layout:
import Layout from '../layouts/Layout.astro' - Add
<script>island for interactivity (NO backtick template literals — use string concat) - Import
{ proc, call, esc }from'../lib/api'
- Add Google Sign-In script to Layout.astro
<head> - On sign-in callback, call:
call('link_oauth', ['google:' + sub, email, name]) - The SpacetimeDB identity (already in localStorage) gets linked to Google profile
- Check
oauth_linkfield in user table to show profile info
Supported: SELECT, WHERE, JOIN, LIMIT, COUNT(*), basic comparisons (=, !=, <, >), AND/OR, hex identity literals (X'...').
NOT supported: ORDER BY (sort client-side), GROUP BY / HAVING, LIKE / ILIKE (filter client-side), OFFSET (use cursor pagination), subqueries.
| Operation | Method | Endpoint | Content-Type | Body |
|---|---|---|---|---|
| SQL query | POST | /v1/database/{db}/sql |
text/plain |
SQL string |
| Call reducer | POST | /v1/database/{db}/call/{reducer} |
application/json |
JSON array |
| Call procedure | POST | /v1/database/{db}/call/{procedure} |
application/json |
JSON array |
| Create identity | POST | /v1/identity |
— | — |
| Health check | GET | /v1/ping |
— | — |
- Use
MSYS_NO_PATHCONV=1before docker exec commands in Git Bash - Scripts handle this automatically