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: install, loader config, TypeScript types for .wgsl imports, and the client component that owns the canvas.

Install

Bash
1
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 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:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
// 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$/:

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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

TypeScript
1
2
3
4
// 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):

TypeScript
1
2
// 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.

TypeScript
1
2
3
4
5
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.

TypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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

Do not use next dev as your shader compiler. Check every .wgsl file — including pure helper modules — with the CLI, which resolves the same import graph the loader does and prints the reflection:

Bash
1
npx vgpu check src/app/plasma.wgsl

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

Troubleshooting

SymptomCauseFix
TS2307: Cannot find module './x.wgsl'No ambient declaration for .wgslAdd the wgsl-env.d.ts above
VGPU-WGSL-PKG-NOTFOUND: Package <pkg> was not foundA WGSL package import is not installed in node_modulesnpm install <pkg>, or fix the specifier
VGPU-WGSL-RUNTIME-IMPORTThe bundler ran the loader synchronously while the WGSL file has top-level importsLet the loader use async mode (webpack/Turbopack/Vite all do by default)
VGPU-RESOLVE-MODULE-BINDINGAn imported .wgsl module declares @group/@bindingKeep resources in the entry shader; modules export only structs and functions
Shader compiles but nothing drawsPassing source.wgsl (a string field) where the object was expected, or no frame.passPass 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 — ship your own .wgsl modules as a package, or share them across a monorepo