[ The sdxc command ]
Make a controller
Write a page, a form or a JSON endpoint with sdxc make controller, its route declared in a contract, mapped on the router and decided in the sitemap.
Last updated 2026-10-19
A controller answers one route. sdxc make controller writes it in app/http/controllers/,
declares its route in a contract in routes/, maps it on the router through lazy(), and adds
a page's decision to the sitemap, so the URL answers as soon as the command finishes:
sdxc make controller sponsors
Wrote app/http/controllers/sponsors.tsx
Wrote app/http/controllers/sponsors.test.tsx
Edited routes/web.ts
Edited app/initializers/router.tsx
Edited app/http/controllers/sitemap.ts
Reach for it whenever a feature needs a new URL: a page, a form with its action, or an endpoint.
Flags
| Flag | Default | What it does |
|---|---|---|
--method <method> | get | get, post, put, patch, delete, or form for a page and its action |
--path <path> | /<name> | the route's pattern, params included |
--routes <contract> | make.routes | the contract in routes/ the route is declared in |
--test, --no-test | make.tests | write the test beside it, or skip it |
--dry-run | print the files and the diffs, and change nothing |
On a terminal --method is asked for, with get selected; without one, it is get.
sdxc make controller ping --method post --path /api/ping
sdxc make controller posts/show --path "/posts/:slug"
sdxc make controller signup --method form
sdxc make controller api/keys --routes api
What it writes
The file comes from the stub that fits the route:
| Stub | Written for | What it is |
|---|---|---|
controller.stub | any method on a contract without its own stub | a page rendered in the document layout with the page chain |
controller.form.stub | --method form | a page with a form and the action that receives it |
controller.api.stub | --routes api | a JSON endpoint with the API chain |
A contract with a stub named after it, controller.<contract>.stub, takes that stub, so a
project can add controller.admin.stub for --routes admin. --method sets the route helper
the contract declares; the handler's shape comes from the stub, so a post route on the web
contract starts from the page stub for you to turn into the action you need.
The page controller for sdxc make controller sponsors:
import { createAction } from "remix/router";
import page from "~/app/http/middleware/page";
import DocumentLayout from "~/resources/layouts/document";
import routes from "~/routes/web";
export default createAction(routes.sponsors, {
middleware: page,
/** Renders the Sponsors page. */
handler: (ctx) =>
ctx.render(
<DocumentLayout
assets={ctx.assets}
intl={ctx.intl}
url={ctx.url}
title="Sponsors"
>
<h1>Sponsors</h1>
{/* TODO: render the page */}
</DocumentLayout>,
),
});
--method form writes a createController with two actions: index renders the form with its
honeypot fields and Turnstile widget, and action receives the submission behind the CAPTCHA
and spam checks, then redirects back with a 303. --routes api writes a createAction with
the API chain that answers Response.json({}).
What it edits
The route goes into the contract, the mapping into the router initializer that imports that
contract and calls createRouter, and the decision into the sitemap:
--- a/routes/web.ts
+++ b/routes/web.ts
notFound: "/*path",
+ sponsors: get("/sponsors"),
});
--- a/app/initializers/router.tsx
+++ b/app/initializers/router.tsx
+ router.map(webRoutes.sponsors, lazy(() => import("~/app/http/controllers/sponsors")));
--- a/app/http/controllers/sitemap.ts
+++ b/app/http/controllers/sitemap.ts
notFound: false,
+ sponsors: true,
The contract gains the import of the route helper when it lacks it: post, put, patch,
del for --method delete, or form.
The sitemap decision
A get or form route is a page, so when a controller builds the sitemap with fromRoutes over
the same contract, make adds the page's decision there:
truelists the page.[]is written for a pattern with required params, such as/posts/:slug, for you to fill with the pages to list.A
formroute decides its page underindex:signup: { index: true }.A page inside a group the sitemap already excludes with
falsestays excluded.
post, put, patch and delete routes are no pages and get no decision.
Names, routes and patterns
The name's folders nest the route in the contract and the file in app/http/controllers/.
sdxc make controller posts/show --path "/posts/:slug" writes
app/http/controllers/posts/show.tsx, declares show inside a posts group, created when
missing, and maps webRoutes.posts.show:
--- a/routes/web.ts
+++ b/routes/web.ts
+ posts: {
+ show: get("/posts/:slug"),
+ },
A group make creates carries no path prefix, so the pattern is the whole path: /<name> with
the folders included, or what --path says. api/keys --routes api joins the template's api
group and is served at /api/keys.
The test
The page and endpoint tests import the controller and check it exports what the router maps,
with a test.todo for the response:
import { describe, expect, test } from "vitest";
import controller from "~/app/http/controllers/sponsors";
describe("sponsors", () => {
test("exports what the router maps", () => {
expect(controller).toBeDefined();
});
test.todo("answers the Sponsors page");
});
The form's test runs through createWebRouter(), so the page chain's checks run as they do in
the Worker: it renders the form, and refuses a submission without the honeypot fields, one that
fills the trap, and one without a CAPTCHA token. Run it with:
bun run test app/http/controllers/signup.test.tsx
Fill it in
Read and write data through
ctx.models, whichsdxc make modelregisters.Validate a form or a body with a schema from
sdxc make validator.Decide who may reach it with a rule from
sdxc make policy.Add a check every page needs with
sdxc make middleware --mount page.
Wire the router covers the router the controller is mapped on, Forms and params a form's fields and route params, and JSON APIs an endpoint's responses.
Where to go next
List routes — confirm the route, its controller and its middleware.
Scaffold a resource — the five controllers a resource needs, from one command.
Generate files — the flags, configuration and stubs every kind shares.