Follow these steps in order. Each step has the command, what you should see,
and the mistake people make most. It works for a developer at a terminal and
for an AI agent running the same commands (add --json to any command for
machine-readable output).
You need the server and the CLI first: getting-started.md.
1. Inventory your backend
Write down what the old backend has, and where each part goes on substrio:
| In your backend | On substrio | Step |
|---|---|---|
| Tables, collections, documents | Collections (collections/<name>.json) |
3, 4 |
| Existing rows | substrio data import |
4 |
| Primary keys, unique columns | _id and a field for the natural key |
5 |
| Users, passwords, sessions, OAuth | substrio auth, the identity app | 6 |
| HTTP routes, controllers | Functions with routes | 7 |
| Cron jobs, workers, queues | [[cron]], queues |
8 |
| Outgoing email | An email function, a mail provider | 9 |
| Uploaded files, static site | substrio files, public/, [site] |
10 |
| Environment variables, API keys | Secrets (substrio env push) |
11 |
| Tests | Calls to the dev sandbox host | 12 |
| Deploy scripts | substrio push, substrio promote |
13 |
Common mistake: starting with code. Start with the data and the ids (steps 3 to 5); the routes follow from them.
2. Create the project and run it
mkdir myapp && cd myapp
substrio init --tenant myapp --yes
substrio push
substrio push sends the project to your app's dev sandbox and prints its
host, https://myapp--dev.substrio.com: a copy of the app's data that runs
what you pushed, while the live app stays as it is. Run substrio push again
after each change below.
Common mistake: running the later commands from another directory. The CLI
reads substrio.toml in the current directory to find the app.
3. Model your collections
Make one file per collection in collections/. The file name is the collection
name:
mkdir collections
cat > collections/orders.json <<'JSON'
{
"schema": {
"strict": false,
"rules": [
{ "field": "event_id", "type": "string", "required": true },
{ "field": "total", "type": "number", "required": true },
{ "field": "created_at", "type": "datetime" }
]
},
"rules": { "read": "auth", "write": "role:admin", "create": "auth", "delete": "role:admin" },
"indexes": ["event_id"]
}
JSON
Run substrio push; you see it print the new version. Check:
substrio collections list
substrio collections schema orders
What to know:
- A schema's
"strict"is true by default: a field no rule names is refused. For a legacy document with many fields, list the ones you want checked and set"strict": false.substrio collections schema NAMEalways shows the value in force. datetimeaccepts an RFC 3339 string ("2026-10-07T00:00:00Z") or epoch milliseconds. Your old ISO strings work as they are.- Field names that start with
_are kept as written. Only_id,_createdAt,_updatedAtand_versionbelong to substrio. - A collection's access rules go either in its file or in the
rules_file, never both.
Every key of the file: project-files.md.
Common mistake: a schema rule that no legacy document satisfies (a number kept as a string in the old data). Fix the type in the rule, or convert the field while exporting (step 4).
Relational data you would rather keep relational: substrio also runs SQL (sql-over-api.md). The rest of this guide uses collections.
4. Import your data
Export each table or collection from the old store to one of four shapes:
- NDJSON: one JSON document per line (
users.ndjson). - A JSON array:
[ {...}, {...} ](users.json). - An object:
{"documents": [ {...}, {...} ]}. - CSV (
--format csv, or a.csvfile): every column is a string unless--typessays otherwise (--types age:number,born:date,active:boolean,meta:json; an unlisted column stays a string). An empty field is left out of the document unless--empty-as null. A value that does not fit its type refuses that row alone, with the line and the rule it broke.
Examples of exporting:
sqlite3 -json app.db "select * from users" > users.json
psql -At -c "select json_agg(t) from users t" mydb > users.json
mongoexport --db mydb --collection users --out users.ndjson
Then import:
substrio data import users --file users.ndjson
substrio data import users --file users.json
substrio data import users --file users.csv --types age:number,active:boolean
cat users.ndjson | substrio data import users --file - --format ndjson # stdin
The format follows the file extension (.ndjson, .json or .csv); --format
overrides it and is required for stdin. The file is streamed start to finish,
never loaded whole, so a dump of any size imports in bounded memory; it is cut
into batches (--batch-bytes, default 4 MiB; --batch-docs) sent several at a
time (--concurrency, default 4; --concurrency 1 restores a single
connection's strict order).
A document that cannot be stored is refused alone and the others are written.
Under --json each refusal is its own NDJSON line as it happens:
{"event": "violation", "position": 3, "code": "KEY_EXISTS", "message": "..."}
(a schema or CSV-type refusal also carries violations: [{field, message}]);
progress lines ({"event": "progress", "imported", "failed", "offset", "docs_per_s"})
follow at most once a second, a {"event": "retry", "status", "attempt", "wait_ms"}
line precedes every wait for a 429 or 503 (the server's own Retry-After is
honoured when it sends one, so a long wait is never a silent hang), and the run
ends in exactly one done or stopped line. Without --json, the same is
printed as text, the refusals listed as they happen. The command exits with
code 8 (partial import) when any document was refused (0 with
--allow-failures) and 0 when all were stored; a run that fails as a whole
exits with the failure's own code (see substrio help) and names the first
document not written. Fix the refused documents and import only them. (The
server's own answer to data.import is
{inserted, skipped, replaced, rejected, errors: [{index, code, message, violations?}], total};
documents travel as their own bytes, one call per batch, never one call per
document.)
A run that stops partway (the connection drops, the process is killed) can be
continued: --checkpoint FILE (default FILE.substrio-import, beside the input)
tracks how far the input was read and stored; --resume picks up from there
after checking the file has not changed size or modified time. Resuming needs
every document to land at most once even if a batch's answer was lost right
after it was written, so --resume always needs an idempotent write and is
refused otherwise, even at the default concurrency: --upsert (on_conflict: replace, a batch at a time: a document whose _id is already stored is
replaced, not refused, and counted as replaced) or --on-conflict skip
(on_conflict: skip: a document whose _id is already stored is skipped, not
refused, and counted as skipped). Without one of those, no checkpoint file
is written at all, --resume or not: there is nothing safe to resume from.
To check, and to copy data between servers, use count and export:
substrio data count users
substrio data count users where.role=admin # the filters of `substrio data query`
substrio data export users --json > users-copy.json
What to know:
- A document that carries
_idis stored under it (a base62 string or a positive whole number), so the references between your documents still resolve. A document without_idgets a generated one. - An
_idalready in use is refused withKEY_EXISTS; nothing is overwritten, unless--upsert(replaced) or--on-conflict skip(left as it was) is given. An_idof any other shape is refused withINVALID_ID. _createdAt,_updatedAtand_versionin your file are ignored: an imported document is stamped anew. Keep your old creation time in an ordinary field such ascreated_at.substrio data insert users --data '{"email":"[email protected]"}'inserts one document: the--datavalue is the document itself, with no wrapper. It refuses a document that names_id(ID_RESERVED); to choose ids, import.
Common mistake: Mongo's mongoexport writes ids as {"$oid": "..."} and dates as
{"$date": "..."}. Convert them to plain strings and numbers before importing.
To load the same data into production, run the same command with the production
profile: substrio data import users --file users.ndjson --profile prod.
5. Ids and natural keys
substrio assigns each document an _id (a base62 string such as Qs7HRQnMTg) and
reads it back as a string, even when you imported a number.
If your old table is keyed by an email, a slug or a UUID:
- Keep it as an ordinary field (
"email": "[email protected]"). - Look documents up by that field:
substrio data query users --data '{"where":{"email":"[email protected]"}}'. - List the field in
indexesof the collection file, so lookups stay fast. - If other documents point at it, store the natural key in them too, or the
_idyou imported.
Uniqueness constraints are not declarable yet. A schema or collection file
cannot say "this field is unique"; it is coming. Until then, make the code that
writes the field check first, or declare it inside a function with
db.collection("users").unique("email")
(runtime/collections-and-chains.md, "Indexes").
After an import, verify the field by hand: query for duplicates before you rely
on it.
Common mistake: assuming data.insert takes an _id you choose. It does not;
import does, and a function can choose with put(doc).
6. Sign-in
substrio has users, passwords, sessions and roles built in (authentication.md):
curl -X POST $SUBSTRIO_URL/v1/_auth/register -H "X-Substrio-Tenant: myapp" \
-H "Content-Type: application/json" -d '{"email":"[email protected]","password":"..."}'
curl -X POST $SUBSTRIO_URL/v1/_auth/login -H "X-Substrio-Tenant: myapp" \
-H "Content-Type: application/json" -d '{"email":"[email protected]","password":"..."}'
# {"token":"eyJ...","user":{"id":"1","email":"[email protected]"}}
- Your users: create them with the
user.createoperation (email, password, optional role):substrio op user.create --file user.json. The documentation does not describe importing the password hashes of another system, so plan for your users to set a new password with the reset email (step 9). - Roles:
substrio op role.assign id=42 role=editor. Rules incollections/<name>.jsonuse them (role:editor). - One login across your apps, and social providers: the identity app signs people in once and each app registers as its client (authentication.md, "One login across apps").
- A function sees the signed-in user as
request.auth. - Sign-in emails and link URLs are the
[auth]table ofsubstrio.toml(project-files.md). - OAuth for AI clients is built in: mcp.md.
Common mistake: giving a client your app key. A user's token goes in
Authorization: Bearer <token>; the app key is for your own backend and deploys, never for a client.
7. Routes as functions
Each route of your old backend becomes a function in functions/. One file per
function, no imports (bundle your dependencies into the file with a
[[build.steps]] entry, for example esbuild):
// functions/api.js
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === '/api/orders') {
return Response.json(db.collection('orders').find({}, { limit: 20 }));
}
return new Response('not found', { status: 404 });
},
};
# substrio.toml
[functions.api]
routes = ["ANY /api/*"]
Save both files and run substrio push; then:
curl https://myapp--dev.substrio.com/api/orders
substrio logs --follow
Everything a function can call: runtime/index.md, runtime/collections-and-chains.md. To expose a route to AI agents as a tool, see mcp.md.
Common mistake: import of a package. The loader has no resolver; bundle it.
Another: a source that only declares a named function is refused when invoked;
export a default object, or make the file evaluate to a function.
8. Scheduled jobs and queues
A cron job is a function plus a line in substrio.toml:
[[cron]]
function = "cleanup"
expression = "0 3 * * *"
The function needs a scheduled(event, env, ctx) export or a default-export
function. Cron expressions run in UTC from substrio.toml. Test a job without
waiting: substrio op schedule.run id=<schedule id> (schedules.md).
Queues hold work for a consumer function, delivered at least once
(runtime/queues.md). A queue is a [queues.<name>] table
in substrio.toml (the fields of the manifest's queues entries); send with
substrio.queue("emails").send({...}).
[queues.emails]
consumer = "sendEmail"
max_retries = 5
Triggers that run on a document change are [[triggers]] in substrio.toml.
Common mistake: a cron function that fails silently. Read the errors with
substrio logs --function cleanup.
9. Mail
substrio sends the sign-in emails of your app (verification, password reset)
through a function you name in [auth]:
[auth]
email_function = "send_auth_mail"
reset_url = "https://myapp.example/reset?token={token}"
functions/send_auth_mail.js calls your mail provider with fetch and the
provider's key from env (step 11). For other mail, do the same from any
function. The documentation lists no built-in outbound mail service: bring a
provider.
Common mistake: testing mail from a sandbox. A sandbox fences outbound requests;
read what it held back with substrio sandbox outbox.
10. Files and static sites
- Static website: put it in
public/or declare[[site.parts]]insubstrio.toml(project-files.md). It is served with the app. - Files your users upload:
substrio files upload ./photo.jpg,substrio files ls,substrio files rm ID(orfile.uploadfrom a function). - Your old uploads folder, moved in one command:
substrio files sync ./uploads images --concurrency 16uploads every file the store does not hold at the same path and size (a second run sends only what changed). A file sent with no type of its own is stored with the type its name gives (.pngisimage/png).
11. Secrets
Put your old environment variables in a .env file, kept out of git, then:
printf '.env\n' >> .gitignore
substrio env push
You see one line per secret, and Pushed N secret(s). Functions read them as
env.MAIL_API_KEY. A value cannot be read back, only overwritten; set a single
one with substrio secrets set NAME (the value is never a command argument: it is
read from stdin, --from-file PATH, or --from-env VAR).
Common mistake: substrio env pull writes .env with masked values (***). It
overwrites your file. Push from your real .env, and pull only into an empty
directory. substrio env push skips masked values, so it never overwrites a secret
with ***.
12. Tests
Port the tests of your old backend to call your functions on the dev sandbox host and read your collections there, with the app key:
curl -s https://myapp--dev.substrio.com/api/orders -H "Authorization: Bearer $SUBSTRIO_KEY"
13. Deploy
Save a profile for the server you deploy to (the key from a file, not the command line), then:
substrio login --url https://api.myapp.example --tenant myapp --profile prod --key - < prod.key
substrio push -m "first version" --profile prod
substrio promote --profile prod
substrio push builds, uploads a new version (functions, collections, rules, cron,
site) and runs it in the dev sandbox; you see its address. Try it there.
substrio promote makes it live, after backing up the data. If something is wrong:
substrio rollback --profile prod
substrio versions --profile prod # which version is live
Then import your data into production (step 4) and switch your clients over.
Common mistake: promoting without trying the sandbox. A sandbox is a copy of the app's data, with outbound requests fenced, made for that.
Versions and functions deployed on their own
substrio push and substrio promote work from versions: everything in one is
pushed together, its sources content-addressed by hash. function.deploy
(substrio deploy NAME, see cloud-functions.md),
function.environment.set and function.delete change a function
immediately, outside any version, useful for a quick fix, but the next
promote risks silently overwriting it with what the version actually
records, or deleting it if the version does not name it at all.
version.live.set (substrio promote, substrio rollback) checks for this before
touching anything. A function deployed directly after the target's live
version is recorded, that the promote would overwrite or delete with
something else, refuses with 409 DIRECT_CHANGES and lists what would be
lost. Nothing changes until you choose:
substrio promote 7 # refuses if 7 would lose a direct change
substrio promote 7 --replace-direct # apply 7 anyway; what it overwrote is reported
substrio promote 7 --keep-direct # apply 7 except those functions, left as deployed
substrio rollback and substrio push (which runs the pushed version in a sandbox)
take the same two flags. A direct change the target version already
reproduces exactly, or a function deleted directly that the version would
restore, is never refused: there is nothing to lose.
substrio versions lists any function deployed directly, for the app and each
sandbox, without ever refusing: it is a report, not a gate.
Where to look next
- project-files.md: every
substrio.tomlkey, collection files. - agents.md, mcp.md: AI agents building and operating the app.
- Every operation, also reachable as
substrio op NAME, is in the generated API reference. - query-language.md: the
wherefilter.