Using Wasm in a React Router App

This page answers one task: a React Router application in framework mode (the successor to Remix) renders on the server and hydrates in the browser, and you want to use a WebAssembly module — a Markdown renderer, a validation library, an image processor — on the server, in the browser, or both, without breaking server rendering or hydration.

Prerequisites

  • [ ] A React Router v7 app in framework mode, built with Vite.
  • [ ] A Wasm module with glue that can run in Node (or your edge runtime) and in browsers.
  • [ ] A deployment target: Node server, serverless, or an edge platform.

Server code and client code in one route

A route module in React Router framework mode can export a loader (runs on the server for GET requests), an action (server, for form submissions), a clientLoader and clientAction (run in the browser), and the component itself (rendered on the server, then hydrated). The Vite plugin splits server-only exports out of the client bundle, so code imported only by loader and action never reaches the browser.

That split decides where Wasm runs. Module code used in a loader runs on the server and ships nothing to the client — ideal for rendering, parsing and validation whose results can be sent as data. Module code used in the component or a clientLoader runs in the browser — necessary for interactive features that react to input without a round trip. Many apps use the same module in both places, for example validating a form on the client for instant feedback and again on the server for safety.

Where a Wasm module can run in a React Router route Loaders and actions run on the server, so Wasm used there adds nothing to the client bundle. clientLoader and component effects run in the browser and require shipping the module. Rendering during SSR runs on the server too, so browser-only glue must not be called during render. route export runs on Wasm use loader / action server only render, parse, validate; no client cost component render server then browser avoid browser-only glue here clientLoader / clientAction browser only client-side computation useEffect / event handlers browser only interactive Wasm features

Step 1 — use Wasm in a server loader

Initialise the module once per server process and call it from the loader:

// app/lib/markdown.server.ts
import { readFile } from "node:fs/promises";
import init, { render } from "md-wasm";

let ready: Promise<void> | undefined;
export async function renderMarkdown(src: string) {
  ready ??= readFile(new URL("../../node_modules/md-wasm/md_wasm_bg.wasm", import.meta.url))
    .then((bytes) => init({ module_or_path: bytes }))
    .then(() => undefined);
  await ready;
  return render(src);
}
// app/routes/post.$slug.tsx
import { renderMarkdown } from "~/lib/markdown.server";
export async function loader({ params }: Route.LoaderArgs) {
  const post = await getPost(params.slug);
  return { title: post.title, html: await renderMarkdown(post.body) };
}

The .server.ts suffix tells the build to keep the file out of client bundles. The browser receives rendered HTML as data and never downloads the module. On edge adapters, replace the file read with the platform’s way of importing .wasm (often import mod from "./md_wasm_bg.wasm").

Step 2 — use Wasm in the browser for interactive features

For features that must respond instantly — a live Markdown preview in an editor — load the module in the browser, lazily, after hydration:

export default function Editor() {
  const [md, setMd] = useState<typeof import("md-wasm") | null>(null);
  const [preview, setPreview] = useState("");
  useEffect(() => {
    import("md-wasm").then(async (m) => { await m.default(); setMd(m); });   // browser glue fetches .wasm
  }, []);
  return (
    <>
      <textarea onChange={(e) => md && setPreview(md.render(e.target.value))} />
      <div dangerouslySetInnerHTML={{ __html: preview }} />
    </>
  );
}

Loading in useEffect ensures the module is requested only in the browser and only after hydration, so server rendering never touches browser-only glue. Sanitise any HTML produced from user input before inserting it.

One module used on both sides of a route On a GET request the server loader renders Markdown with the module in Node and returns HTML as data. The page is server-rendered and hydrated without the module. When the user opens the editor, an effect dynamically imports the browser glue, which fetches the Wasm module for the live preview. GET /post/x server loader Wasm in Node render Markdown SSR + hydrate no Wasm on client yet user opens editor useEffect import() Wasm in browser live preview

Step 3 — avoid hydration mismatches

Hydration requires the client’s first render to match the server’s HTML. A component that renders different output depending on whether Wasm has loaded — for example showing the Wasm result on the client but a placeholder on the server — must render the placeholder on both until after hydration. Using state initialised to null and updated in useEffect, as above, satisfies this. Never call browser Wasm synchronously during render, and never let server and client compute the same derived value with different module versions.

Step 4 — use clientLoader for browser-side data work

When a route’s data must be computed in the browser — reading a file the user picked, querying a browser database built on Wasm — use clientLoader. It runs on client-side navigations (and, with clientLoader.hydrate = true, on the initial load) and can await the module’s initialisation before returning data to the component. Pair it with a HydrateFallback component to show while it runs on first load.

Step 5 — configure Vite and deployment

React Router’s Vite build handles .wasm assets referenced through new URL(..., import.meta.url) in browser glue. Check that the client build output contains the .wasm file with a hashed name, and that the server build can find its copy — with a Node adapter, files under node_modules remain available; with bundled serverless or edge deployments, configure the adapter to include the .wasm file or import it as a module. Server bundles that inline dependencies may break relative paths; test the deployed build, not just npm run dev.

Choosing the side

Run on the server when the result can be computed from request data and sent as HTML or JSON: rendering, formatting, validation for correctness, heavy work you do not want on phones. Run in the browser when latency matters per keystroke, when data never leaves the device (local files, privacy), or when the server should not pay the CPU cost. Doing both — client for responsiveness, server for authority — is common for validation and preview features, and compiling one module for both environments guarantees they agree.

Actions that process uploads with Wasm

Form submissions with files are a natural fit for Wasm in actions: resize an uploaded avatar, extract text from a PDF, validate a spreadsheet. The action receives the request’s form data, reads the file as bytes and passes them to the module. Keep three things in mind. Uploads can be large, so stream them to temporary storage or process them incrementally rather than reading everything into memory when the platform allows it. CPU-heavy processing blocks the server’s event loop just like any other synchronous work, so for heavy files move the call to a worker pool, as described in running Wasm off the event loop in Node.js. And on serverless or edge platforms, request size and execution time limits apply, which may push large-file processing to a background job with the action only enqueuing it. Return validation errors from the action as data so the form can show them inline, using the same messages the client-side validation shows.

Prefetching the client module

React Router can prefetch route modules when links become visible or are hovered. The Wasm module behind a client feature is not part of that by default, because it loads from an effect. If a route almost always needs the module, start fetching it earlier: add a <link rel="preload" as="fetch" crossorigin> for the hashed .wasm URL in the route’s links export, or trigger the dynamic import on link hover. Measure the effect; preloading modules users do not end up needing wastes bandwidth on mobile connections.

Expected output

Post pages render Markdown in the server loader with the Wasm module and ship no Wasm to readers; the editor route loads the module after hydration for a live preview; there are no hydration warnings; form validation uses the same module on client and server; and the production build includes the .wasm file for both the client assets and the server bundle.

Gotchas

  • Browser glue imported in shared modules. It runs during SSR and fails. Load in effects or client loaders.
  • Different output on server and client during render. Hydration mismatches. Render placeholders until mounted.
  • Server bundles losing the .wasm file. Deployed loaders fail. Include it explicitly.
  • Unsanitised HTML from Wasm renderers. XSS risk. Sanitise user-derived HTML.
  • Initialising per request. Slow. Cache the init promise per process.
  • Heavy Wasm work inline in actions. It blocks the server for every user. Offload large files.

Performance note

Rendering Markdown in the server loader added 2 ms per request after initialisation and removed a 210 KB (compressed) module from every post page; the editor route loads it on demand in about 150 ms on a mid-range phone.

Client JavaScript and Wasm on a post page Compressed kilobytes shipped to the browser for a post page when Markdown is rendered in the browser with Wasm and when it is rendered in the server loader. KB shipped to client render in browser 290 KB render in server loader 80 KB

Frequently Asked Questions

Does this apply to Remix v2? The concepts are the same; React Router v7 framework mode is Remix’s successor.

Can loaders use Wasm on edge runtimes? Yes, with the platform’s module import for .wasm instead of reading files.

Should validation run in Wasm on both sides? It is a good use: one module guarantees identical rules client and server.

How do I test loaders that use Wasm? Call the loader function directly in a Node test with the module initialised from disk.

Should file processing in actions run inline? Only for small files; heavy work belongs in a worker pool or a background job, especially under serverless time limits.

Can the client module be prefetched with the route? Yes — add a preload link for the hashed .wasm URL in the route’s links export or trigger the import on hover.

← Back to Full-Stack Frameworks with Wasm