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 the bundler you run:

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

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

export default nextConfig;

On Turbopack, as: "*.js" is required so the loader output is treated as a JavaScript module, and the top-level turbopack key needs Next.js 15.5 or newer. If you run both commands, keep both blocks in the same config: Next reads turbopack only under --turbopack and calls webpack() only without it, so they never conflict.

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.