# Rust shared core, part 6: using it on the web as a Wasm package

> To use the Rust rules from mobile on the web, compile them to WebAssembly and ship a package. Roles, where to initialize in Next.js, and the call contract.

- Canonical: https://jaemyeong.com/en/blog/rust-shared-core-06-web-wasm/
- Published: 2026.07.24
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #WebAssembly, #wasm-bindgen, #wasm-pack, #TypeScript

Applying the rules of the Rust core used on iOS and Android to the web as well needs a way to run the same code in a browser. [WebAssembly](https://developer.mozilla.org/en-US/docs/WebAssembly) is that way. It is a low-level binary format for browsers, Rust can compile to it with the web as the target, and it is used together with JavaScript.

That does not mean writing the whole web app in Rust. Part 6 uses Sudoku to explain what Rust and the web each take on, and when and where a Next.js app initializes Wasm.

## What Rust takes and what the web takes

The candidates on the Rust side are the things whose results and rules have to be the same across platforms: puzzle generation, the solver, validation, the action reducer, score calculation, and the seed of the daily puzzle.

What I recommend leaving on the web side is the React components, routing, localStorage and IndexedDB, keyboard shortcuts, pointer interaction, analytics, and the service worker. The DOM, fetch, hydration, accessibility, and browser storage are also better handled by TypeScript and the web framework. The web builds its UI state from the results of calling Rust.

## Two tools and a directory layout

Two tools share the work. [wasm-bindgen](https://wasm-bindgen.github.io/wasm-bindgen/) exposes Rust functions to JavaScript and generates the wrapper and TypeScript declarations. [wasm-pack](https://wasm-bindgen.github.io/wasm-pack/book/) builds the crate and bundles the WebAssembly binary, the JavaScript glue code, and the package metadata into a form that can be consumed from npm. Up to here the content comes from the documentation of the two tools. The directory layout and the initialization approach below are my own design choices.

```text
core/sudoku-rs/crates/
  sudoku-core/      # 순수 Rust 로직
  sudoku-wasm/      # wasm-bindgen 공개 API

packages/
  sudoku-wasm/      # wasm-pack output 또는 wrapper package

apps/web/
  app/              # Next.js / React 앱
```

The Korean comments in the block mean, in order: "pure Rust logic" on the `sudoku-core/` line, "the public wasm-bindgen API" on the `sudoku-wasm/` line under `crates/`, "wasm-pack output or a wrapper package" on the `sudoku-wasm/` line under `packages/`, and "the Next.js / React app" on the `app/` line.

`sudoku-wasm` calls `sudoku-core`. The web app imports `@app/sudoku-wasm` and does not see Rust's internal model directly. Managing it as a package makes the dependencies and the location of the artifacts visible. The versions and the target option I used in an example are in the section "A Wasm package built from an example." The current wasm-pack documentation says it may describe features that are not in a released version, so check that it matches the version in use.

## Where to initialize in Next.js

In Next.js, code runs on the server and in the browser. Running a browser-only module in a server component or during a static build can cause an error. So I suggest fixing the place and the time of initialization.

```text
Client Component
  -> dynamic import("@app/sudoku-wasm")
  -> init wasm
  -> create SudokuEngineClient
```

A Client Component does a dynamic import, initializes Wasm, and then creates the `SudokuEngineClient`. Another option is lazy initialization in the state layer, on first use.

The UI has to be able to tell whether the engine is ready. I suggest managing five states.

- wasm loading
- wasm ready
- wasm failed
- engine action pending
- engine action failed

Without showing these states, the user can get a blank screen or a button that does nothing. Apart from whether the core is stable, the initialization process affects how stable the app feels. Real error messages and steps to reproduce are not included in this post.

## One call per UI event

I recommend avoiding calls to Rust per cell inside React render, or on every pointer move. It is better to pass the snapshot and the action once per UI event and receive the next snapshot and the effects.

The contract the app sees has two methods.

```ts
export interface SudokuEngineClient {
  startGame(request: StartGameRequest): Promise<GameSnapshot>;
  applyAction(snapshot: GameSnapshot, action: GameAction): Promise<Transition>;
}
```

In this structure React uses only this client, and the generated raw functions are called only inside the client implementation. The `SudokuEngineClient` on iOS, Android, and the web can differ in implementation, but the meaning in the domain and the contract the app sees stay the same. Conceptually it sits in the same place as the adapter on mobile.

The types may need one more step. The generated TypeScript declarations alone may not cover all the domain types of the app. So a separate wrapper is a good idea. That wrapper converts what Wasm returns into the app's types and does JSON decoding, schema validation, and error code mapping.

## A Wasm package built from an example

I built this structure as a small example and loaded it from Node. No real web app is involved. It is a minimal workspace that checks the structure. The tools were wasm-pack 0.15.0, wasm-bindgen 0.2.129, and Node 24.14.1, and the date was October 4, 2026. The binding crate only wraps the functions of the core. Keeping the JSON-facing functions in the core as well is where the example departs from the design in part 2, to stay small.

```rust
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn start_game(request_json: &str) -> String {
    sudoku_core::start_game_json(request_json)
}

#[wasm_bindgen]
pub fn apply_action(snapshot_json: &str, action_json: &str, environment_json: &str) -> String {
    sudoku_core::apply_action_json(snapshot_json, action_json, environment_json)
}
// … validate_snapshot and generate_daily_puzzle have the same shape.
```

There is one build command. The target is `nodejs`, because the run was in Node and not in a browser. The `grep` and `sed` after it keep only the completion lines of the output and shorten the absolute path. Run on its own, this line can end as a success even when the build fails, because of the last command in the pipe. The script that was run has `set -euo pipefail` at its top so that such a failure is not missed.

```bash
wasm-pack build crates/sudoku-wasm --target nodejs --out-dir ../../out/wasm-node 2>&1 | grep -E "Done|ready" | sed 's#at .*/out/#at out/#'
```

The output directory got `sudoku_wasm_bg.wasm`, `sudoku_wasm.js`, `sudoku_wasm.d.ts`, `sudoku_wasm_bg.wasm.d.ts`, and `package.json`. The `.wasm` file was 142,894 bytes. These are the TypeScript declarations generated in `sudoku_wasm.d.ts`. The two lint comment lines at the top of the file are left out.

```ts
export function apply_action(snapshot_json: string, action_json: string, environment_json: string): string;

export function generate_daily_puzzle(request_json: string): string;

export function start_game(request_json: string): string;

export function validate_snapshot(snapshot_json: string): string;
```

Every argument and return value is a `string`. As described earlier, turning that string into the app's types and validating it is work a wrapper has to do separately. I loaded this package in Node with `require` and called the functions, and two tests that read the shared fixtures passed. Those tests are in part 7.

What was not done is worth writing down too. No build was made for the `web` or `bundler` target. Initialization in a browser, the dynamic import in Next.js, and a client and wrapper that return a `Promise` were not built.

## Both sides read the same fixtures

I suggest that the Rust tests and the web tests use the same fixtures. The Rust side checks the output of the engine, and the web side checks that the wrapper interprets the same result correctly. The aim is to find the case where the engine is right and the type conversion is wrong.

```text
fixtures/
  daily-seed-2026-06-24.json
  apply-action-note-toggle.json
  invalid-move-conflict.json
```

The date in the file name is an identifier in the example. How to set up tests and CI is covered in the next part.

What this setup emphasizes is not speed. It is that the three platforms use the same domain rules. Performance numbers and an implementation that handles initialization failure are not here. Testing went as far as running two fixtures in Node. The interface that returns a `Promise` is an example of the contract the app sees, and this post has no definition of the fields of `Transition`.
