Setup

This page takes a Next.js project from zero to a rendering shader: install vgpu, wire the WGSL loader, type the imports, render a first effect, and validate it. A short Vite section closes it out.

Install

npm install vgpu @vgpu/wgsl

@vgpu/wgsl is already a dependency of vgpu, but install it directly anyway: your config names its loader and your types reference it, and package managers with isolated node_modules (pnpm, Yarn PnP) hide transitive packages from your project.

Wire the WGSL loader

Register the loader for both bundlers. Next reads the turbopack key only under --turbopack and calls webpack() only without it, so the two blocks cover both commands and never conflict.

next.config.ts
import type { NextConfig } from "next";

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

export default nextConfig;

as: "*.js" is required so Turbopack treats the loader output as a JavaScript module. The top-level turbopack key needs Next.js 15.5 or newer.

Type .wgsl imports

TypeScript does not know what a .wgsl module is until you add an ambient declaration. @vgpu/wgsl ships one; reference it from a .d.ts file anywhere in your project:

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

With that in place, the default export of a .wgsl import is a ShaderSource object ({ version, wgsl }), not a string. Pass it whole to effect(), which accepts either; do not reach into .wgsl yourself.

A first effect

The shader is a fragment entry point and one uniform. effect() injects the vertex stage and exposes the interpolated uv:

app/glow.wgsl
struct Params { time: f32 }
@group(0) @binding(0) var<uniform> params: Params;

@fragment fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
  return vec4f(uv, abs(sin(params.time)), 1.0);
}

Initialize one Gpu for the application, then keep the per-canvas wiring in a plain function: surface() for the canvas, effect() for the shader, and frameLoop() to drive it.

app/glow.ts
import { clock, effect, frameLoop, surface } from "vgpu";
import type { FrameLoopHandle, Gpu } from "vgpu";
import glowShader from "./glow.wgsl";

/** Borrows the app's Gpu; the returned cleanup never disposes that context. */
export function startGlow(gpu: Gpu, canvas: HTMLCanvasElement): () => void {
  const canvasSurface = surface(gpu, canvas, { dpr: [1, 2] });
  const glow = effect(gpu, glowShader, {
    set: { params: { time: 0 } },
  });

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

  return () => {
    loop.stop();
    canvasSurface.dispose();
  };
}

WebGPU is browser-only, so the canvas lives in a client component and the loop starts in an effect after mount:

app/page.tsx
"use client";

import { useEffect, useRef } from "react";
import { init } from "vgpu";
import { startGlow } from "./glow";

// One context for the application module; every canvas borrows it.
const gpu = init();

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

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    let disposed = false;
    let stop: (() => void) | undefined;
    void gpu.then((context) => {
      if (!disposed) stop = startGlow(context, canvas);
    });
    return () => {
      disposed = true;
      stop?.();
    };
  }, []);

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

The cleanup function matters in development: React's strict mode mounts effects twice. Stop the loop and dispose the canvas surface on every unmount; the shared Gpu remains owned by the application.

Validate the shader

npx vgpu check app/glow.wgsl --require-validation

next dev and next build never validate WGSL. The loader resolves imports, prunes and mangles, but skips the device-backed check, and a build exits 0 shipping broken WGSL unchanged. vgpu check resolves the same import graph the loader does, validates against a real device, and prints the shader's reflection. Run it on entry shaders after every edit; an entry shader covers every module it imports.

Register the effect registry

Complete effects install through the shadcn CLI. Register the @vshaders namespace once in your components.json and every effect is one add away:

components.json
{
  "registries": {
    "@vshaders": "https://vshaders.com/r/{name}.json"
  }
}
npx shadcn@latest add @vshaders/mesh-gradient

The CLI needs a valid components.json around that registries block; if the project has none, Add an effect shows a minimal hand-written one (no Tailwind setup required) plus everything an install lands in your tree. Skipping the namespace also works: npx shadcn@latest add takes the item URL https://vshaders.com/r/<name>.json directly.

Vite

vite.config.ts
import { wgslVitePlugin } from "@vgpu/wgsl/loader-vite";

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

The plugin resolves .wgsl imports and handles HMR. The types reference and the validation step are the same as above.

Next: add an effect from the registry, or install a module package like @vshaders/sdf and import it from your own shader.