[ Getting started ]
Build an app
Build a small link board from an empty folder to a deployed Worker, creating the project, scaffolding a resource, changing its validator and policy, adding a scheduled job, testing it and deploying it.
Last updated 2026-10-19
After reading this guide, you will know
- How
sdxc create,sdxc scaffoldandsdxc run db migratetake you from an empty folder to working pages - Where a validator, a policy, a model method and a job go, and how to change each one
- How to run the tests
sdxcwrote for you and ship the app withsdxc deploy
In this guide you build links, a public link board: anyone can add a bookmark with an
https link, signed-in members edit and delete them, and a job removes bookmarks older than 30
days every night. Each step uses one sdxc command and then changes what it wrote, so by the
end you have touched every layer an app has.
You need Bun to run it locally and a
Cloudflare account for the last step. The guide assumes
you have written a Cloudflare Worker and know
Remix routes and controllers; it covers what sdxc adds on top.
Install sdxc
npm add -g @sdxc/kit
That puts the sdxc command on your path. The sdxc command
maps every command to its guide.
Create the project
sdxc create links
Created links from the app template
Next steps:
cd links
bun install
bun dev
create writes a Remix app on Cloudflare Workers with a router, a D1 database, a job queue,
email, an MCP endpoint and their tests already wired together, and pins every @sdxc package
to one release. It copies .env.example to .dev.vars, so the development secrets are in
place. Install the dependencies, generate the Worker's env types, and start the dev server:
cd links
bun install
sdxc run typegen
bun dev
Open the address bun dev prints and you see the template's home page. Leave the server
running; the next steps reload it as files change.
How it fits together explains each folder you
just got.
Scaffold the bookmarks
A bookmark has a title, a link and optional notes. sdxc scaffold writes everything a resource
needs from its name and fields:
sdxc scaffold bookmark title link_url 'notes?:text'
A field's name picks its type, so link_url is a URL because it ends in _url, and title is
a string. notes?:text names the type itself, and the ? makes it nullable; quote it so your
shell leaves the ? alone.
Wrote app/models/bookmark.ts
Wrote app/models/bookmark.test.ts
Wrote database/factories/bookmark.ts
Wrote app/http/validators/bookmark.ts
Wrote app/http/validators/bookmark.test.ts
Wrote app/policies/bookmark.test.ts
Wrote resources/views/bookmarks/form.tsx
Wrote app/http/controllers/bookmarks/index.tsx
…
Wrote database/migrations/0001_CreateBookmarks.sql
Edited app/models/index.ts
Edited app/policies/abilities.ts
Edited app/policies/policy.ts
Edited routes/web.ts
Edited app/initializers/router.tsx
Edited app/http/controllers/sitemap.ts
Edited resources/locales/en.json
Added @sdxc/uuid@2026.10.13, @sdxc/validate@2026.10.13 to package.json; run bun install
✓ Formatting 27 files
Every file has a test beside it, app/test/database.ts gives those tests a migrated database
and factories, and every edit wires a file in: the routes are declared in
routes/web.ts and mapped in the router, the model is registered as ctx.models.bookmark, and
the abilities join the app's policy. Install the two packages the new code imports:
bun install
Scaffold a resource lists what each file does.
Create the table
The scaffold wrote the migration that creates the bookmarks table:
-- Creates the bookmarks table the Bookmark model reads and writes.
CREATE TABLE "bookmarks" (
"id" TEXT PRIMARY KEY NOT NULL,
"title" TEXT NOT NULL,
"link_url" TEXT NOT NULL,
"notes" TEXT,
"created_at" INTEGER NOT NULL,
"updated_at" INTEGER NOT NULL
);
Apply it to the local database, the same one bun dev serves:
sdxc run db migrate
Applied 0001_CreateBookmarks.sql
Open /bookmarks: the list is empty, and New bookmark answers 403. That is the policy at
work, and the next step changes it.
Let guests add bookmarks
The scaffold granted reads to everyone and every write to signed-in members. A link board where
anyone may post needs bookmark.create for everyone too, while updates and deletes stay with
members. Grants live in app/policies/policy.ts:
const policy = definePolicy(abilities, {
facts: { actor: fact<Actor>({ optional: true }) },
everyone: [
allow(["bookmark.read", "bookmark.create"], { id: "bookmark-read-create" }),
],
roles: {
member: [
allow(["bookmark.update", "bookmark.delete"], { id: "bookmark-write" }),
],
},
});
Run the policy's test to see what that changed:
bun run test app/policies
The case "refuses a guest every write" fails, because a guest may now create one. The test is the record of who may do what, so change it to say so:
test("lets a guest read and create one", () => {
let guest = testAccess(policy, { roles: [] });
expect(guest.can(abilities.bookmark.read, { bookmark: RECORD })).toBe(true);
expect(guest.can(abilities.bookmark.create)).toBe(true);
});
Rename the old case to "refuses a guest an update or a delete" and drop its create line. Reload
/bookmarks/new and the form appears. Authorization
covers conditions, such as letting members change only their own bookmarks.
Accept only https links
app/http/validators/bookmark.ts holds the schema a submission passes before the create action
trusts it. The scaffold accepts any URL; a link board should send its readers to https only:
export const BookmarkSchema = f.object({
title: f.field(s.string().refine((value) => value.trim() !== "", "Required")),
link_url: f.field(
s
.string()
.refine(
(value) => URL.parse(value)?.protocol === "https:",
"Expected an https URL",
),
),
notes: f.field(
s
.defaulted(s.string(), "")
.transform((value) => (value.trim() === "" ? null : value)),
),
});
Add the case to its test, beside the two the scaffold wrote:
test("refuses a link that is not https", async () => {
let form = new URLSearchParams({ title: "Remix", link_url: "http://remix.run/" });
expect(isFailure(await validate(form, BookmarkSchema))).toBe(true);
});
A refused submission renders the form again with the message beside the field, so submit
http://remix.run on /bookmarks/new to see it.
Forms and params covers the schema API.
Prune old bookmarks every night
Removing old bookmarks is a rule about one model, so it is a method on that model; a job then
calls it on a schedule. Add the method in app/models/bookmark.ts:
import { column as c, lt, table } from "remix/data-table";
export const Bookmark = createModel(bookmarkTable, {
generateId: generateUUID,
methods: {
/** Deletes every bookmark created before `cutoff`, so the board shows recent links. */
pruneOlderThan(cutoff: Date) {
return this.query().where(lt("created_at", cutoff.getTime())).delete();
},
},
});
Test it in app/models/bookmark.test.ts, where the factory the scaffold wrote creates a row:
test("prunes the bookmarks created before the cutoff and keeps the rest", async () => {
let bookmarks = Bookmark.bind({ db: openDatabase() });
let kept = await factories().create(BookmarkFactory);
await bookmarks.pruneOlderThan(new Date(Date.now() - 60_000));
expect(await bookmarks.find(kept.id)).not.toBeNull();
await bookmarks.pruneOlderThan(new Date(Date.now() + 60_000));
expect(await bookmarks.find(kept.id)).toBeNull();
});
Now write the job that runs it at 03:00 every day:
sdxc make job bookmarks/prune --cron "0 3 * * *"
Wrote app/jobs/bookmarks/prune.ts
Wrote app/jobs/bookmarks/prune.test.ts
Edited app/jobs/index.ts
Edited app/initializers/dispatcher.ts
Edited cloudflare.config.ts
✓ Formatting 5 files
The job is declared with its schedule in app/jobs/index.ts and mapped on the dispatcher.
Cloudflare fires only the schedules the Worker declares, so --cron also adds the trigger that
fires it to cloudflare.config.ts:
triggers: [
triggers.queue({ name: manifest.name }),
triggers.scheduled({ schedule: "0 3 * * *" }),
],
Fill in the handler, which reads the model through ctx.models as a controller does:
/** How long a bookmark stays on the board. */
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
export default createJobHandler(jobs.bookmarks.prune, async (ctx) => {
ctx.log.set({ job: { name: "prune" } });
await ctx.models.bookmark.pruneOlderThan(new Date(Date.now() - MAX_AGE_MS));
});
Delete the test.todo in app/jobs/bookmarks/prune.test.ts: the rule it would test is covered
on the model. sdxc schedule confirms the job and its trigger line up:
sdxc schedule
Job Schedule Next run Trigger
bookmarks.prune 0 3 * * * 2026-10-20 03:00 UTC cloudflare.config.ts
Background jobs and cron covers jobs with input, retries and enqueuing one from a controller.
Run the tests
bun run test
One case fails: app/http/controllers/bookmarks/new.test.tsx still expects a guest to get a
403 from the form. Replace that case with the behavior you chose:
test("lets a guest add a bookmark", async () => {
let page = await form(null);
let response = await submit(
{ title: "Remix", link_url: "https://remix.run/" },
null,
);
expect(page.status).toBe(200);
expect(response.status).toBe(303);
});
bun run test
Test Files 33 passed (33)
Tests 75 passed (75)
The page tests drive the app's real router, so the honeypot, the policy and the validator all run as they do in the Worker. Test your app shows how to run one file or one case while you work.
Check the project
Two more commands catch what tests do not:
bun check
sdxc doctor
bun check formats, lints and type checks. sdxc doctor checks the project itself: every
@sdxc package on one release, every secret cloudflare.config.ts declares listed in
.env.example, the migrations replaying on an empty database, every route mapped through
lazy(), every model registered, and every declared email mapped to a template. Check a project lists each
check and its fix.
Deploy
The first deploy needs the Cloudflare resources the app binds: the D1 database, the sessions KV namespace, the queue and the secrets. Deploy your app creates each one; do that once, then preview the plan:
sdxc deploy --dry-run
App: links
Build: built just now
Migrations: 1 pending
0001_CreateBookmarks.sql
Deploy: the app's deploy command
sdxc deploy
sdxc deploy builds, applies the pending migrations to production, and then deploys, after you
answer yes, so the new code never runs against the old schema.
Deploy covers what happens when a step fails.
Where to go next
How it fits together — what happens between a request reaching the Worker and your controller answering it.
Generate files — every kind
sdxc makewrites.Sign in with OpenID Connect — sign members in, so the edit and delete pages open for them.