Using Wasm in a Next.js Project
This guide answers one task: use a compiled module inside a Next.js application that renders on the server — without breaking the build, without shipping the module to pages that do not need it, and optionally running the same module server-side in a route handler.
Prerequisites
- [ ] Next.js 14+ with the App Router.
- [ ] A module built with
wasm-pack build --target web. - [ ] Node 18+ for the server runtime.
- [ ] An understanding of which components are server components and which are not.
Why the obvious approach fails
A module imported at the top of a file is evaluated wherever that file is evaluated. In an App Router
project, a component file is evaluated on the server by default, and wasm-pack’s browser glue expects a
browser: it references fetch against a relative URL, import.meta.url, and sometimes document.
The failure is at build time and looks unrelated to WebAssembly:
ReferenceError: document is not defined
at Module.__wbg_init (./src/wasm/pkg/engine.js:214:5)
Error occurred prerendering page "/orders/new"
The fix is to keep the module entirely on the client and load it dynamically, so nothing about it is evaluated during rendering or prerendering.
The working pattern
Mark the component that uses the module as a client component, and import the glue inside an effect rather than at the top of the file.
'use client';
import { useEffect, useState } from 'react';
let enginePromise = null;
function loadEngine() {
enginePromise ??= import('@/wasm/pkg/engine.js').then(async (m) => { await m.default(); return m; });
return enginePromise;
}
export default function LiveValidator({ order }) {
const [engine, setEngine] = useState(null);
useEffect(() => {
let cancelled = false;
loadEngine().then((m) => { if (!cancelled) setEngine(m); }).catch(() => {});
return () => { cancelled = true; };
}, []);
if (!engine) return null; // server validation still applies
const violations = engine.validate_order(JSON.stringify(order));
return <ViolationList items={violations} />;
}
The dynamic import() inside the effect is the critical detail: a static import at the top of a client
component file is still evaluated during the server render of the page that contains it, because Next.js
must produce the initial HTML. Moving it into an effect guarantees it runs only in the browser.
Webpack configuration
Next.js uses Webpack by default, which needs the asynchronous WebAssembly experiment enabled and, in some versions, a hint about where to emit the binary.
// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
webpack(config, { isServer }) {
config.experiments = { ...config.experiments, asyncWebAssembly: true, layers: true };
if (!isServer) {
config.output.environment = { ...config.output.environment, asyncFunction: true };
}
return config;
},
};
With Turbopack the configuration differs and is still evolving; if a build fails under Turbopack and succeeds under Webpack, that is the likely reason, and pinning the build to Webpack is a reasonable short-term answer.
Verify the output rather than the configuration. After next build, the .wasm should appear in
.next/static/ and be referenced from a chunk loaded by the client component — not inlined, and not
missing.
Running the same module server-side
A route handler runs in Node, where the browser glue does not apply but the module does. Build a second
target or instantiate the .wasm directly with Node’s APIs.
// app/api/validate/route.js
import { readFile } from 'node:fs/promises';
import path from 'node:path';
let instance = null;
async function engine() {
if (instance) return instance;
const bytes = await readFile(path.join(process.cwd(), 'src/wasm/pkg/engine_bg.wasm'));
const { instance: inst } = await WebAssembly.instantiate(bytes, {});
instance = inst;
return inst;
}
export async function POST(request) {
const inst = await engine();
const order = await request.json();
const violations = runValidate(inst, order); // your own marshalling
return Response.json({ violations }, { status: violations.length ? 422 : 200 });
}
Memoising the instance at module scope matters here too: a serverless function may keep the module instance alive between invocations, and reinstantiating per request throws away that benefit. Note that this path needs the Node runtime rather than the edge runtime unless the module is edge-compatible.
export const runtime = 'nodejs'; // or 'edge', if the module suits it
Keeping the module out of every bundle
Next.js splits code by route, but a shared import defeats that. If a utility module imports the glue and several routes import that utility, the module ends up in the common chunk and every page pays for it — including the ones that never call it.
The fix is a discipline rather than a configuration. Import the glue from exactly one client component, and have everything else talk to that component or to a hook it exports. If several routes genuinely need the functionality, they should each import the same lazy loader, which keeps one shared promise without pulling the binary into a common chunk.
// src/wasm/engine.js — the only file that mentions the glue
'use client';
let promise = null;
export function loadEngine() {
promise ??= import('@/wasm/pkg/engine.js').then(async (m) => { await m.default(); return m; });
return promise;
}
Because the import is dynamic, the bundler creates a separate chunk for it regardless of how many places
call loadEngine. Verify with the build output: the route sizes should be unchanged and the module
should appear as its own asset.
Preloading when the route is predictable
Loading lazily is right by default and occasionally too lazy. If a user is one click away from a page that needs the module, starting the fetch during the hover or on route prefetch removes the visible delay entirely.
'use client';
import Link from 'next/link';
import { loadEngine } from '@/wasm/engine';
export function NewOrderLink() {
return (
<Link href="/orders/new" onMouseEnter={() => { loadEngine(); }} prefetch>
New order
</Link>
);
}
Calling the loader on hover starts the fetch and the instantiation; by the time the route mounts, the memoised promise has usually resolved and the feature is available immediately. The cost is a download for users who hover and do not click, which for a 97 kB asset on a page they were considering is an easy trade.
Do not preload on page load “just in case” — that is simply eager loading with extra steps, and it puts the module back on the critical path you moved it off.
Expected output
A correct build shows the module as a separate static asset and the page rendering without it:
npx next build
# ✓ Compiled successfully
# Route (app) Size First Load JS
# ┌ ○ / 1.2 kB 89 kB
# └ ○ /orders/new 4.8 kB 94 kB
ls .next/static/**/*.wasm
# .next/static/media/engine_bg.6b03d1.wasm
# in the browser, on /orders/new
GET /_next/static/media/engine_bg.6b03d1.wasm 96.7 kB application/wasm
engine ready in 41 ms
The First Load JS figure should not include the module — if it jumped by a hundred kilobytes, the
module was inlined or statically imported somewhere it should not have been.
Gotchas
- Static import in a client component. Still evaluated during the server render. Use a dynamic import inside an effect.
document is not definedat build. The glue reached the server. Trace which file imported it.- Module included in every page’s bundle. A shared import pulled it into the common chunk; import it only from the component that needs it.
- Edge runtime with a Node-flavoured module. Choose the runtime explicitly and test the deployed route, not just the local one.
- Turbopack differences. If a build behaves differently between
next devandnext build, the bundler is the first thing to check. - Instance created per request in a route handler. Memoise it at module scope.
Performance note
The module added 96.7 kB compressed to the route that used it and nothing to any other route. Client-side instantiation took 41 ms after first paint; the server route handler instantiated once per cold function and then reused the instance, adding about 0.3 ms per request thereafter. First Load JS for the page was unchanged, which is the outcome to aim for — the module is an additional asset, not part of the critical bundle.
Frequently Asked Questions
Can a server component use the module directly? With a Node-targeted build and direct instantiation, yes — but it makes the component asynchronous and couples rendering to module startup. A route handler is usually the cleaner place for server-side use.
What about next/dynamic?
It works and is a reasonable alternative to the effect pattern for a whole component:
dynamic(() => import('./LiveValidator'), { ssr: false }) keeps the component and everything it imports
off the server entirely, which is the simplest possible fix when the module is used in one place.
Does this work on Vercel’s edge runtime?
If the module has no Node dependencies and the glue does not assume a browser, yes — the edge runtime
supports WebAssembly directly. Build for wasm32-unknown-unknown, instantiate from an import, and test
the deployed route.
Related
- Integrating Wasm into a React app — the pattern without server rendering in the way.
- Sharing validation logic between server and browser — why both sides run the same module.
- Bundling Wasm ESM with Vite — the same problems in a different bundler.
A last piece of advice: add a build-time assertion that the .wasm exists in the static output and that
no route’s First Load JS grew unexpectedly. Both regressions are introduced by an innocent-looking import
and both are invisible until someone profiles the page.
← Back to Full-Stack Frameworks with Wasm