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 module lives below the client boundary Server components render on the server and must not import the module. A client component marked with the use client directive loads it dynamically in the browser, keeping it out of the server build entirely. server components — rendered on the server no browser globals · must not import the module's glue "use client" boundary client component dynamic import in an effect · module fetched in the browser Everything above the dashed line runs twice — once at build, once per request — and the module belongs in neither.

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
One module, two runtimes The same compiled module is loaded by a client component in the browser and instantiated by a route handler on the server. Both call the same functions, so the rules they enforce cannot diverge. engine_bg.wasm client component dynamic import in an effect immediate feedback route handler instantiated from disk authoritative

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.

Four runtimes, four sets of rules The same binary may run during the build, on a Node server, in an edge runtime, or in the browser. Each has different limits and a different way to reach the file. build time full Node access; results become static output Node server route file system available; instantiate once per process edge runtime no file system; the binary must be imported browser bundle fetched over the network like any other asset The edge runtime is the constrained one: reading the binary from disk is simply not available there. Instantiate at module scope in every server runtime so the compile is paid once, not per request.

Gotchas

  • Static import in a client component. Still evaluated during the server render. Use a dynamic import inside an effect.
  • document is not defined at 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 dev and next 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.

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