
A working demo of Remix 3's fetch-router running on Val Town — no build step, no bundler, just web standards.
Server-side (fetch-router):
- Typed route definitions with
route()— nested routes, dynamic params, method constraints - Type-safe URL generation:
routes.messages.show.href({ id: "42" })→/messages/42 - Middleware (logging) via
createRouter({ middleware: [...] }) router.map(routes, handlers)for matching routes to handler functions- Standard
Request→Responsehandlers — works on any runtime (Deno, Node, Bun, Cloudflare) - Form handling with
request.formData()and POST → 303 redirect pattern
Client-side (@remix-run/dom):
- Remix 3's custom JSX runtime — no React, no virtual DOM
- Component model: outer function (setup, runs once) returns inner function (render)
- State via closure variables + explicit
this.update()to re-render - Progressive enhancement: the guestbook works fully without JavaScript
Val Town platform:
serveFile()fromstd/utilstranspiles.tsx→ JS on-the-fly via esm.town@jsxImportSourcepragma tells the transpiler which JSX runtime to use- Val Town SQLite for persistence
- Single HTTP entry point, no build configuration
| Method | Pattern | Handler | Description |
|---|---|---|---|
| GET | / | home | Sign form + 5 most recent messages |
| GET | /messages | messages.index | Browse all messages |
| POST | /messages | messages.create | Create message, redirect to detail |
| GET | /messages/:id | messages.show | Single message view |
| GET | /frontend/* | frontend | Static assets (CSS, transpiled TSX) |
Define your routes as a typed object:
import { createRouter, route } from "npm:@remix-run/fetch-router@0.16.0";
const routes = route({
home: "/",
messages: {
index: { method: "GET", pattern: "/messages" },
create: { method: "POST", pattern: "/messages" },
show: { method: "GET", pattern: "/messages/:id" },
},
});
Then map them to handlers. Each handler receives a context with request, url, params, etc. and returns a Response:
const router = createRouter({
middleware: [(context, next) => { console.log(context.url.pathname); return next(); }],
});
router.map(routes, {
home() {
return new Response("Hello from home");
},
messages: {
show({ params }) {
return new Response(`Message ${params.id}`);
},
create({ request }) {
// handle form POST...
return Response.redirect("/messages/1", 303);
},
},
});
export default (req: Request) => router.fetch(req);
The route objects are also used for URL generation: routes.messages.show.href({ id: "42" }) returns /messages/42. This means links in your HTML are always in sync with your route definitions.
Remix 3 components don't use React. The model is:
/** @jsxImportSource https://esm.sh/@remix-run/dom@0.4.0 */
import { createRoot } from "https://esm.sh/@remix-run/dom@0.4.0";
import type { Remix } from "https://esm.sh/@remix-run/dom@0.4.0";
function Counter(this: Remix.Handle) {
let count = 0; // state is just a variable
return () => ( // inner function = render
<button on:click={() => { count++; this.update(); }}>
Clicked {count} times
</button>
);
}
createRoot(document.getElementById("root")).render(<Counter />);
The outer function runs once (setup). The inner function runs on every render. State lives in closure variables. Call this.update() to trigger a re-render. There's no useState, no hooks rules, no dependency arrays.
Val Town's serveFile() handles the transpilation magic. When a browser requests /frontend/index.tsx:
- The route matches
/frontend/* serveFile("/frontend/index.tsx")fetches the file from esm.town with transpilation- The
@jsxImportSourcepragma tells the transpiler to importjsx/jsxsfrom@remix-run/dom/jsx-runtime - TypeScript types are stripped, JSX is transformed, and the result is served as
text/javascript
No webpack, no vite, no esbuild config. Just a pragma and a function call.
- No React dependency —
@remix-run/domhas its own JSX runtime and component model - No bundler — works without a build step on any JS runtime
- Explicit re-renders —
this.update()instead ofuseState/useEffect fetch-routeris just routing — no loaders, no actions (yet), no nested route data loading. You handleRequest→Responseyourself.- Progressive enhancement by default — server renders full HTML pages, client JS is optional enhancement
<Frame>— Remix 3's HTMX-style component for fetching HTML fragments from the server. Not yet published on npm.@remix-run/events— Composable event handlers (dom.click,pressDown, etc.) for richer interactions.- Client-side navigation — Currently uses full page loads between routes. Once
<Frame>ships, you'd wrap links in frames for SPA-like transitions.
- Wake up, Remix — Ryan Florence's announcement
- Remix 3: Beyond React — Better Stack walkthrough
- fetch-router on GitHub
- @remix-run/dom on npm