sdxc
SearchCtrlK

[ 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 scaffold and sdxc run db migrate take 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 sdxc wrote for you and ship the app with sdxc 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:

database/migrations/0001_CreateBookmarks.sql
-- 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:

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:

app/policies/bookmark.test.ts
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.

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:

app/http/validators/bookmark.ts
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:

app/http/validators/bookmark.test.ts
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:

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:

app/models/bookmark.test.ts
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:

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:

app/jobs/bookmarks/prune.ts
/** 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:

app/http/controllers/bookmarks/new.test.tsx
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

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.