---
title: Using vgpu with Next.js and other bundlers
description: Wire the WGSL loader into Next.js (Turbopack or webpack) or Vite, type `.wgsl` imports for TypeScript, and render a shader from a client component with a canvas.
---

# Using vgpu with Next.js and other bundlers



`effect(gpu, source)` takes WGSL as a string, so nothing forces you to use a bundler loader. Reach for the loader when you want shaders in their own `.wgsl` files with `import` between them — the loader resolves that import graph at build time and hands `effect()` one finished shader.

This guide is the bundler half of [Getting started](/docs/guides/getting-started): install, loader config, TypeScript types for `.wgsl` imports, and the client component that owns the canvas.

## Install

```bash
npm install vgpu
```

That is the whole install. `@vgpu/wgsl` (the loaders) and `@vgpu/wgsl-std` (the pure-WGSL standard modules) are dependencies of `vgpu`, so WGSL package imports such as `import { voronoi3d } from "@vgpu/wgsl-std/noise";` resolve with no second install step — under npm, pnpm, and Yarn alike, including pnpm's isolated `node_modules` and Yarn PnP, where transitive packages never appear in your project's `node_modules` tree.

A WGSL package import resolves from your project's own `node_modules` first, so a copy you installed yourself always wins, and then next to `@vgpu/wgsl` itself, which is what makes a transitive install of `@vgpu/wgsl-std` work. `VGPU-WGSL-PKG-NOTFOUND` therefore means the package is reachable from neither — install it (`npm install <pkg>`) and check the specifier spelling. Third-party and workspace packages work the same way, including `import { customNoise } from "@packages/shaders";` for a `workspace:*` package in a monorepo: see [Publishing WGSL module packages](/docs/guides/publishing-wgsl-packages) for the full resolution order and the `exports` map a package needs.

## Next.js with Turbopack

Turbopack is the default dev bundler in current Next.js versions. Register the loader with a top-level `turbopack.rules` entry; `as: "*.js"` is required so Turbopack treats the loader output as a JavaScript module:

```ts
// next.config.ts
const nextConfig = {
  turbopack: {
    rules: {
      "*.wgsl": {
        loaders: ["@vgpu/wgsl/loader-webpack"],
        as: "*.js",
      },
    },
  },
};

export default nextConfig;
```

Requires Next.js 15.5 or newer for the top-level `turbopack` key. Next 15.0–15.2 used `experimental.turbo.rules`, deprecated since. Turbopack runs webpack-compatible loaders through a bridge: `this.addDependency()` is not honored there, but `@vgpu/wgsl` also tracks transitive `.wgsl` reads through Turbopack's patched `fs.readFile`, so editing an imported module still invalidates.

The same shape is exercised end to end by `examples/next-wgsl` in the vgpu repository.

## Next.js with webpack

`next dev` / `next build` without `--turbopack` use webpack. Push a rule from the `webpack` hook — the loader registers itself for `test: /\.wgsl$/`:

```ts
// next.config.ts
type WebpackConfig = { module?: { rules?: unknown[] } };

const nextConfig = {
  webpack(config: WebpackConfig) {
    config.module ??= {};
    config.module.rules ??= [];
    config.module.rules.push({
      test: /\.wgsl$/,
      loader: "@vgpu/wgsl/loader-webpack",
    });
    return config;
  },
};

export default nextConfig;
```

Keep both blocks when your app runs webpack in one command and Turbopack in another: Next reads `turbopack` only under `--turbopack` and calls `webpack()` only without it, so the two configurations coexist. In a project that has Next's own types available, annotate with `NextConfig` (`import type { NextConfig } from "next";`) instead of the local `WebpackConfig` alias; the alias above only exists so this snippet compiles on its own.

Add `options: { minify: true }` to the rule for production builds. Full option reference: `npx vgpu docs cat /@vgpu/wgsl/loader-webpack/index.docs.md`.

## Vite

```ts
// vite.config.ts — wrap in defineConfig() from "vite" if you want its typing
import { wgslVitePlugin } from "@vgpu/wgsl/loader-vite";

export default { plugins: [wgslVitePlugin()] };
```

## Type `.wgsl` imports in TypeScript

TypeScript does not know what a `.wgsl` module is, so `import shader from "./plasma.wgsl"` fails with `TS2307: Cannot find module` until you add an ambient declaration. `@vgpu/wgsl` ships one — reference it from a `.d.ts` file anywhere in your project (`src/wgsl-env.d.ts` is a good spot):

```text
// src/wgsl-env.d.ts
/// <reference types="@vgpu/wgsl/wgsl-types" />
```

Prefer that one-liner: it stays correct if the emitted shape ever changes. If you would rather declare the module yourself — for example to reuse the exported `ShaderSource` type — put this in `src/wgsl-env.d.ts` instead. It must be the first statement in the file: a `declare module` that follows other code is read as a module augmentation and fails with `TS2664`.

```ts
declare module "*.wgsl" {
  import type { ShaderSource } from "@vgpu/wgsl";
  const source: ShaderSource;
  export default source;
}
```

Either way the default export is a `ShaderSource` object (`{ version: 1, wgsl: string }`), **not** a plain string. Pass it straight to `effect(gpu, source)`, which accepts `string | ShaderSource` — do not reach into `.wgsl` yourself.

## Render it from a client component

WebGPU is browser-only, so the canvas lives in a `"use client"` component and `init()` runs in an effect after mount. Keep the vgpu work in a plain function: it is easier to read, and it is the part worth testing.

```ts
// src/app/plasma.ts
import { clock, effect, frameLoop, init, surface } from "vgpu";
import type { FrameLoopHandle } from "vgpu";
import plasmaShader from "./plasma.wgsl";

/** Starts the render loop on `canvas`; call the returned function to tear it down. */
export function startPlasma(canvas: HTMLCanvasElement): () => void {
  let disposed = false;
  let loop: FrameLoopHandle | undefined;
  let gpu: Awaited<ReturnType<typeof init>> | undefined;

  void (async () => {
    gpu = await init();
    if (disposed) return gpu.dispose();

    const canvasSurface = surface(gpu, canvas, { dpr: [1, 2] });
    const plasma = effect(gpu, plasmaShader, {
      label: "plasma",
      set: { params: { time: 0, texel: canvasSurface.texelSize } },
    });
    canvasSurface.onResize(() => plasma.set({ params: { texel: canvasSurface.texelSize } }));

    const time = clock(gpu);
    loop = frameLoop(gpu, (frame) => {
      plasma.set({ params: { time: time.time } });
      frame.pass(canvasSurface, plasma);
    });
  })();

  return () => {
    disposed = true;
    loop?.stop();
    gpu?.dispose();
  };
}
```

```tsx
// src/app/page.tsx
"use client";

import { useEffect, useRef } from "react";
import { startPlasma } from "./plasma";

export default function Page() {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    return startPlasma(canvas);
  }, []);

  return (
    <main style={{ margin: 0, height: "100vh", overflow: "hidden" }}>
      <canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
    </main>
  );
}
```

The cleanup function matters in development: React's strict mode mounts effects twice, and without `stop()` plus `dispose()` you leak a device and a render loop per remount.

## Validate before you run the app

**`next build`/`next dev` never validate WGSL — neither the webpack loader nor the Turbopack path, for
leaf `.wgsl` files or for import graphs.** The loader/plugin call `resolveShader({ validate: false })`
for import graphs (parsing, purity checks, DCE, mangling, and minification still run, but the
device-backed check does not), and a leaf `.wgsl` file with no imports never calls `resolveShader()`
at all. There is no loader/plugin option to opt into validation — `next build --webpack` and
`next build` (Turbopack) both exit `0` and ship invalid WGSL unchanged. Do not use `next dev`/
`next build` as your shader compiler.

The validation gate is `vgpu check --require-validation`, run in CI or as a pre-commit hook. Check
every `.wgsl` file — including pure helper modules — with the CLI, which resolves the same import
graph the loader does, prints the reflection, and actually validates against a WebGPU device:

```bash
npx vgpu check src/app/plasma.wgsl --require-validation
```

Then prove the pixels in Node instead of squinting at a browser tab: [Getting started](/docs/guides/getting-started) shows the headless render-and-read-pixels loop, and [The default workflow for developing shaders with vgpu](/docs/guides/shader-workflow) is the full playbook.

## Troubleshooting

| Symptom                                               | Cause                                                                                    | Fix                                                                                  |
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `TS2307: Cannot find module './x.wgsl'`               | No ambient declaration for `.wgsl`                                                       | Add the `wgsl-env.d.ts` above                                                        |
| `VGPU-WGSL-PKG-NOTFOUND: Package <pkg> was not found` | A WGSL package import is not installed in `node_modules`                                 | `npm install <pkg>`, or fix the specifier                                            |
| `VGPU-WGSL-RUNTIME-IMPORT`                            | The bundler ran the loader synchronously while the WGSL file has top-level imports       | Let the loader use async mode (webpack/Turbopack/Vite all do by default)             |
| `VGPU-RESOLVE-MODULE-BINDING`                         | An imported `.wgsl` module declares `@group`/`@binding`                                  | Keep resources in the entry shader; modules export only structs and functions        |
| Shader compiles but nothing draws                     | Passing `source.wgsl` (a string field) where the object was expected, or no `frame.pass` | Pass the imported object to `effect(gpu, source)`; render inside `frame`/`frameLoop` |

## See also

* `npx vgpu docs cat /@vgpu/wgsl/loader-webpack/index.docs.md` — every loader option
* `npx vgpu docs cat /@vgpu/wgsl/loader-vite/index.docs.md` — the Vite plugin
* `npx vgpu docs cat /@vgpu/wgsl/runtime/resolve-shader.docs.md` — resolving import graphs without a bundler
* `npx vgpu docs cat /@vgpu/wgsl-std/noise/index.docs.md` — WGSL modules you can import by package name
* [Publishing WGSL module packages](/docs/guides/publishing-wgsl-packages) — ship your own `.wgsl` modules as a package, or share them across a monorepo


---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)