sdxc
SearchCtrlK

[ 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

FlagDefaultWhat it does
--method <method>getget, post, put, patch, delete, or form for a page and its action
--path <path>/<name>the route's pattern, params included
--routes <contract>make.routesthe contract in routes/ the route is declared in
--test, --no-testmake.testswrite the test beside it, or skip it
--dry-runprint 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:

StubWritten forWhat it is
controller.stubany method on a contract without its own stuba page rendered in the document layout with the page chain
controller.form.stub--method forma page with a form and the action that receives it
controller.api.stub--routes apia 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:

app/http/controllers/sponsors.tsx
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:

  • true lists the page.

  • [] is written for a pattern with required params, such as /posts/:slug, for you to fill with the pages to list.

  • A form route decides its page under index: signup: { index: true }.

  • A page inside a group the sitemap already excludes with false stays 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:

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

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.

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