How to Use WebAssembly in Next.js

6 months ago
Written by
Fouad Matin
@fouadmatin

It's become pretty easy to call functions in WASM from the web. Let's take a simple example, assuming we have a function called add written in Rust that adds two numbers together.

Here's the source code in Rust:

// The wasm-pack uses wasm-bindgen to build and generate JavaScript binding file.
// Import the wasm-bindgen crate.
use wasm_bindgen::prelude::*;
// Our Add function
// wasm-pack requires "exported" functions
// to include #[wasm_bindgen]
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
return a + b;
}

Then we can compile it into WASM using wasm-pack and use it in our web application:

wasm-pack build --target web

Starting with Next.js 11, you can import WASM files either by importing them with next/dynamic components or using WebAssembly APIs.

For example, let's import the WASM file using next/dynamic:

import dynamic from 'next/dynamic'
export interface AddModuleExports {
add(a: Number, b: Number): Number
}
interface RustComponentProps {
number: Number
}
const RustComponent = dynamic({
loader: async () => {
// Import the wasm module
// @ts-ignore
const exports = (await import('../add.wasm')) as AddModuleExports
const { add } = exports
// Return a React component that calls the add_one method on the wasm module
return ({ a, b }: RustComponentProps) => (
<div>
<>{add(a, b)}</>
</div>
)
},
})

Or you can use WebAssembly APIs to fetch and import the WASM file from a static path:

// Put `add.wasm` in `public/wasm/` folder
// (or any other static folder)
WebAssembly.instantiateStreaming(
// Fetch the file and stream into the WebAssembly runtime
fetch('/wasm/add.wasm')
).then((result) => result.instance.exports.add(1, 1)) // = 2

Ok great, it works well for simple functions, but what if we want to call more complex functions? What about other languages?

At Indent, we primarily write TypeScript on the frontend and Go on the backend. Sometimes there's logic we have to replicate on both sides of the stack that can either be redundant or, worse, drift in logic. Let's see what it's like to call a function written in Go from a Next.js application.

Calling Go functions from Next.js

We have some functionality called TestPolicy that tests customer-defined policies in a client-side simulator to make sure they're working as expected. We want to call this function from inside our Next.js application to save users an extra network request while debugging.

In our Next.js project, we'll create a file cmd/simulator/main.go with the following source code:

//go:build js && wasm
package main
import (
"errors"
"fmt"
"syscall/js"
)
func main() {
testFunc := js.FuncOf(testWrapper)
js.Global().Set("testPolicy", testFunc)
defer testFunc.Release()
<-make(chan bool)
}
// testWrapper wraps the validate function with `syscall/js` parameters
func testWrapper(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return response("", errors.New("missing `policy` argument"))
} else if len(args) < 2 {
return response("", errors.New("missing `test` argument"))
}
policy := args[0].String()
test := args[1].String()
output, err := TestPolicy(policy, test)
if err != nil {
return response(false, err)
}
return response(output, nil)
}
// TestPolicy is out-of-scope for this example, we'll just assume it returns true with no error
func TestPolicy(policy string, test string) (bool, error) {
return true, nil
}
func response(out any, err error) any {
if err != nil {
out = err.Error()
}
return map[string]any{"output": out, "isError": err != nil}
}

We can use the standard Go toolchain to compile this into WASM:

# Put in package.json scripts as "build:wasm"
GOOS=js GOARCH=wasm \
go build -ldflags=\"-s -w\" \
-o public/wasm/simulator.wasm \
cmd/simulator/main.go

Once we've built the WASM, we can try importing it into our Next.js application like we did earlier... But we get this error:

Module not found: Can't resolve 'gojs'

Screenshot of Google search with zero resultsScreenshot of Google search with zero results

It's never a great sign when the error message is a phrase that has 0 results on Google. Let's see what's going on.

The only "gojs" I'm familiar with is gojs.net the diagramming tool. And the gojs package on NPM is also for the diagramming tool. What is going on here?

Well, it turns out that Go wasm modules require wasm_exec.js to be loaded in the browser. This file is included in the standard Go toolchain, but it's not referring to the package on NPM.

With wasm_exec.js loaded, we can instantiate the module:

// Go is defined in wasm_exec.js
const go = new Go()
WebAssembly.instantiateStreaming(
// Fetch the file and stream into the WebAssembly runtime
fetch('/wasm/simulator.wasm'),
// importObject is where `gojs` is defined
go.importObject
).then((result) => {
// Since we used `js.Global().Set` in Go, we can access the function globally
const result = window.testPolicy('<policy>', '<test>')
console.log(result)
})

And it works!

{"output":true,"isError":false}

But when we looked at the WASM file, it was pretty big: 43MB. Can we make it smaller since we're loading it client-side?

Let's gzip the WASM file and see what happens:

gzip --best -f < public/static/wasm/simulator.wasm \
> public/static/wasm/simulator.wasm.gz

The problem is that when we try to stream the gzipped file, we get an error because the streaming WebAssembly API doesn't support gzipped files. We can use pako to gunzip the file before instantiating:

import pako from 'pako'
const go = new Go()
const imports = go.importObject
const res = await fetch('/static/wasm/cel.wasm.gz')
const gzBuf = await res.arrayBuffer()
let buffer = pako.ungzip(gzBuf)
// (Firefox) Sometimes buffer is double-gzipped
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
buffer = pako.ungzip(buffer)
}
const result = await WebAssembly.instantiate(buffer, imports)
// Running the module will define `window.testPolicy`
go.run(result.instance)

Great! Now, we've got a small WASM file that we can use to call functions written in Go from inside our Next.js application. In future blog posts, we'll cover other languages like Python, using WASM in API routes, and Next.js 13 Server Actions.

Have any questions? Feel free to reach out to me on Twitter/𝕏 @fouadmatin or send us an email: [email protected]

Indent: Temporary Access for Production and Customer Data That Doesn't Suck

Are you on a team that's building a product that store customer data or need to perform sensitive operations on your systems?

Solve the root problem of over permissioned users and grant time-bound access to production. Use configurable peer or auto-approvals to remove bottlenecks for granting on-call or low-risk access instantly.

Want to learn more about Indent? Feel free to schedule time that works for you and we're happy to answer any questions you have!

Try Indent for free.