Sergio Xalambrí

The Remix Way

By Sergio Xalambrí ·

Around six months ago I decided to try Remix v3, when it was still in v0.0.0-alpha.3.

So I did what everyone does when trying a new framework: I rebuilt my blog on it.

But my blog has nearly 650 posts, and reads them from a D1 database, and exposes an RSS feed, and an Atom feed, and JSON Feed, and caches pages, and parses Markdown, and highlights code syntax...

When I started this, I had a monorepo with my React Router blog, my React Router Auth book landing, an uptime monitoring app, and even a simple identity provider, everything in the same repo with ~20 shared packages between them, many with specific React or React Router integrations.

While I was rebuilding my blog, I created a few packages. An XML lib used to generate the RSS feed and sitemap, a collection of Remix helpers like action, middleware, controller, all then replaced by equivalent helpers coming directly from remix/router.

My blog was originally using Drizzle to query my D1 database, but I really wanted to just use remix/data-table. However, Remix ships only these database adapters:

import { createPostgresDatabase } from "remix/data-table/postgres";
import { createMysqlDatabase } from "remix/data-table/mysql";
import { createSqliteDatabase } from "remix/data-table/sqlite";

The good thing is that Remix gave me an interface Database that I could use to provide my own implementation, that's how I built @sdxc/data-table-d1, and @sdxc/data-table-sqlstorage (the Durable Object database), just for the sake of it.

Contracts

Let me stop here for a while, this is something I really like about how Remix v3 is built, and I have been doing something similar with other packages I published to npm in the past.

Many parts ship contracts, not only implementations, what do I mean? The data-table package ships a Database contract, everything that fits that contract can be considered a database, right? Well, they do the same for other contracts like SessionStorage.

This allowed me to build @sdxc/session-storage-kv, a Worker KV-based Session Storage object.

And when I started building more of my packages, I decided to follow this same idea.

First for caching, I built @sdxc/cache with a Cache contract and two implementations, an in-memory cache, useful for testing, and a Worker KV cache I use in production.

export interface Cache {
	read<T>(key: string): Promise<Result<T | null, CacheError>>;
	write<T>(key: string, value: T, …): Promise<Result<void, CacheError>>;
	fetch<T>(key: string, load: () => Promise<T>, …): Promise<Result<T, CacheError>>;
	delete(key: string): Promise<Result<void, CacheError>>;
}

class MemoryCache implements Cache {} // tests
class WorkerKVCache implements Cache {} // production

My uptime monitoring app sends emails, and I used Resend with code all over the app. When I wanted to switch to Cloudflare to send emails, I built an email transport contract, then implemented it using Resend, migrated my app code to use it, confirmed it worked, and finally built my Cloudflare transport and I only had to switch one line of code.

export interface Transport { … }

class MemoryTransport     implements Transport {} // day one
class ResendTransport     implements Transport {} // day one
class CloudflareTransport implements Transport {} // later
 mail({
-  transport: () => new ResendTransport(env.RESEND_TOKEN),
+  transport: () => new CloudflareTransport(env.EMAIL),
   from: MAIL_FROM,
   replyTo: MAIL_REPLY_TO,
 })

And I even did the same for my billing provider, I use Polar, but I didn't want to depend on their SDK, so I built my own contract, an implementation in memory, for Polar and even for Stripe to test that the contract was ok.

export interface Billing {
	readonly customers: CustomerApi;
	readonly checkouts: CheckoutApi;
	readonly subscriptions: SubscriptionApi;
	readonly entitlements: EntitlementApi;
	…
}

class MemoryBilling implements Billing {}
class StripeBilling implements Billing {}
class PolarBilling implements Billing {}

The Component Model

After I migrated my blog and shipped it to prod, I decided to migrate my Uptime monitoring app. This was way more complex, and required way more packages.

My React Router version of the app had a custom UI library built on top of React Aria. So I wanted the same thing for Remix. So I told an agent to check shadcn/ui and React Aria lists of components, then come up with a list of deduped components, and then I asked it to build my own versions on top of remix/component, but using as little client-side JS as possible so I could limit hydration to a few components that really need it.

You can confirm this in my own apps, my blog has zero hydrated components, there isn't a single client entry in it. My uptime app ships multiple hydrated (client entries) components, and many of my other apps ship more or fewer components, but only what's really needed or makes sense to hydrate.

Now my first version of this component library used the css mixin from remix/component, but the previous version in React used Tailwind, and I really wanted a Tailwind-like way to style the components, so I tasked an agent to build a Tailwind on top of that mixin.

import * as u from "@sdxc/u";

<section
	mix={[
		u.surface("muted"),
		u.rounded("lg"),
		u.p(4),
		u.at("md", [u.p(6), u.hstack({ gap: 4 })]),
		u.dark(u.border("neutral.strong")),
	]}
>
	{children}
</section>;

Each of these mixins uses remix/component's css mixin internally to apply styles, but they're built in a way that can be nested for container queries, color schemes, selectors, etc.

Once I had both @sdxc/u and @sdxc/ui, I made my blog and uptime apps use them.

near Zero Dependencies

One of Remix's principles is to avoid dependencies. So I thought to myself, what if I do the same thing? How many dependencies could I replace with my own packages?

That's why I built api-client, atom, auth, billing, bracket-params, cache, captcha, catch-response-middleware, cloudflare-mocks, cloudflare-pricing, cron, crypto, csv, data-table-d1, data-table-sqlstorage, dates, distill, doh, duration, email-address, feed, flags, flags-engine, get-client-ip, highlight, honeypot, hostname, html, http, i18n, icalendar, icons, idempotency, jobs, jsdoc, json-feed, json-schema, jwt, lazy-route, location, logger, mail, markdown, mcp, merge-patch, messageformat, microformats, micropub, openapi, opml, pagination, passkey, password-policy, problem, rate-limit, response, result, robots, rss, saml, sample, scim, security-headers, semver, seo, server-timing, session-storage-kv, sitemap, spam, spec, strings, structured-fields, trace-context, trailing-slash-middleware, typeid, types, u, ui, user-agent, uuid, validate, webhooks, webmention, websub, well-known, workers-cache, xml, yaml.

And my list of third-party dependencies is really short, repo-wide, and most of those are development dependencies like Vite, types for Node, Cloudflare packages, etc. that I honestly don't want to maintain myself.

The Router

Of course, since Remix comes from React Router, it comes with a router, and it's a simple router: you define some routes.

export default route({
	feed: get("/rss"),
	articles: get("/articles.rss"),
});

And then you map them to request handlers or controllers.

router.map(routes.rss, {
	actions: {
		feed: lazy(() => import("~/app/http/controllers/rss/feed")),
		articles: lazy(() => import("~/app/http/controllers/rss/articles")),
	},
});

btw, that lazy there comes from @sdxc/lazy-route.

The reason to separate route definitions from route handler mapping is actually quite simple: you can use the whole route table on the server and the client, which allows you to resolve actual URLs and get type errors if a route stops existing.

What if we do the same for other parts of the code? Enter @sdxc/mcp.

After a while, I wanted to add an MCP to my apps. I'm using agents a lot and I really believe a future is possible where apps are used more via agents than their own UIs. And the good thing for me is that by the time I decided to add an MCP the new version was built entirely as a Request -> Response function, so the tools match the router request handlers.

And that's what I did, this is how I define tools and resources my MCP server will use.

// app/mcp/tools.ts
export default tools({
  searchPosts: tool("search_posts", { … }),
  getPost:     tool("get_post",     { … }),
});

// app/mcp/resources.ts
export default resources({
  article: resource(`${ORIGIN}/articles/:slug.md`, { … }),
});

And then I could define tool or resource handlers the same way a router handler is defined.

import { createTool } from "@sdxc/mcp";
import toolset from "~/app/mcp/tools";

export default createTool(toolset.searchPosts, async (ctx) => {
	let results = await PostSearch.query(ctx.db, {
		query: ctx.input.query,
		kind: ctx.input.kind,
		tag: ctx.input.tag,
		limit: ctx.input.limit,
	});

	return { query: ctx.input.query, count: results.length, results };
});

And then I create the MCP handler, where I can even apply a middleware, and map my toolset to my tool handlers.

let mcp = createHandler({
	name: "…",
	version: "1.0.0",
	instructions: "…",
	toolMiddleware: [cacheToolResults()],
});

mcp.tools.map(toolset.searchPosts, searchPosts);

And to serve it, it's quite simple: an MCP handler is just a request handler of Remix's own router.

router.map(routes.mcp, {
	middleware: [mcpRateLimit(env)],
	handler: (ctx) => mcp.fetch(ctx),
});

And I did the same thing again for background jobs. A table of all the jobs with their definition, cron, input schema, etc.

export default jobs({
	checkHttp: job({ input: CheckHttpSchema }),
	checkDns: job({ cron: "* * * * *" }),
});

A job handler that uses the jobs table to know what data the job receives.

import { createJobHandler } from "@sdxc/jobs";

import jobs from "~/app/jobs";

export default createJobHandler(jobs.checkHttp, async (ctx) => {
	let monitor = await ctx.log.time("db", () => ctx.database.find(ctx.input.monitorId));
	ctx.log.set({ monitor: { id: monitor.id, region: monitor.region } });
	ctx.log.note("check.started");
});

And a job dispatcher that sets a list of middleware functions, and maps the jobs table to their handlers.

export const dispatcher = createJobDispatcher({
	logger,
	queue: jobQueue,
	middleware: [costLedger(), database(), mailer(), featureFlags(flags)],
});

dispatcher.map(jobs.checkHttp, () => import("~/app/jobs/check-http"));

And you can enqueue jobs quite easily.

await ctx.jobs.enqueue(jobs.checkHttp, {
	id: `${monitorId}:manual:${generateUUID()}`,
	monitorId,
	scheduledAt: Date.now(),
});

Before, adding a job required me to create the function, and wire everything inside the Cloudflare Worker entry point, including manually validating the input to ensure the data was ok, and I had no type-safe way to enqueue them.

The Primitives

Another of Remix's principles is "Build on Web APIs". Every time I decided to build a package I asked myself the same question: is there anything here that could use a Web API? Can I avoid an abstraction and just rely on the web?

That's how my <Dialog> component is triggered from my <Button> component just using two HTML attributes command="show-modal" commandfor="dialog-id" instead of building my own connection.

When I dropped i18next for @sdxc/i18n, I built the translated strings to use Unicode MessageFormat 2 and built an implementation of the upcoming API Intl.MessageFormat so one day I can drop that part of the code.

That's why many of the packages I built are implementations of actual standards, so even when I need to build something that the platform doesn't give me, I still use a standard and avoid re-inventing the wheel.

The Demo

To see all of this together I built a job board. It's a small app — a couple of thousand lines, about half of that JSDoc — and it uses nineteen of my packages without reaching for anything unusual, because a public board happens to need most of them: a list, a form, a detail view, a captcha, a rate limit, Markdown, mail, a background job, i18n, logging and an MCP server.

The list page hydrates exactly one component. Opening a job position loads its detail as a frame, which means rendering the list never parses the Markdown of every position on it — that work only happens for the one you asked for. The island that does it is 846 bytes.

The form to publish a position opens its dialog with the same two HTML attributes from the last section, and the captcha and the rate limit that guard it are middleware rather than handler code, so the handler is only about creating the position. Publishing enqueues a job, and the job sends the confirmation email through the Transport contract — with the in-memory transport in place, so the mail lands in an outbox page instead of a real inbox.

And the MCP server answers on the same router as every other route, so an agent can read the board the same way a browser does.

The whole thing, along with every package in this article, is at github.com/sergiodxa/monorepo — the demo lives in apps/demo.

Building the Remix Way

After six months, 88 packages, and 11 apps built with Remix, I stopped just building with Remix, and started to build the Remix way.

Do you like my content?

Your sponsorship helps me create more tutorials, articles, and open-source tools.

Sponsor me on GitHub