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.
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:
/// <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:
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.
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:
"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-validationnext 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:
{
"registries": {
"@vshaders": "https://vshaders.com/r/{name}.json"
}
}npx shadcn@latest add @vshaders/mesh-gradientThe 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
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.