NextJS Integration
No iframes. No hacks. A native React component that just works.
Architecture
Required Configuration
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
// SharedArrayBuffer requires Cross-Origin Isolation
source: '/(.*)',
headers: [
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
{
key: 'Cross-Origin-Embedder-Policy',
value: 'require-corp',
},
],
},
];
},
webpack: (config) => {
// Handle .wasm files
config.experiments = {
...config.experiments,
asyncWebAssembly: true,
};
// Serve worker files from public/
config.module.rules.push({
test: /\.worker\.js$/,
type: 'asset/resource',
});
return config;
},
};
module.exports = nextConfig;
Public Assets
public/
├── n64/
│ ├── n64.wasm (Emulator core, ~3MB)
│ ├── n64.js (Emscripten glue)
│ ├── n64.worker.js (Thread worker)
│ └── audio-processor.js (AudioWorklet)
The React Component
// components/N64Emulator.tsx
'use client';
import { useRef, useEffect, useState, useCallback } from 'react';
interface N64EmulatorProps {
onReady?: () => void;
onError?: (error: Error) => void;
onFpsUpdate?: (fps: number) => void;
controlMode?: 'touch' | 'gamepad' | 'keyboard';
shader?: 'none' | 'crt' | 'sharp';
className?: string;
}
export function N64Emulator({
onReady,
onError,
onFpsUpdate,
controlMode = 'gamepad',
shader = 'none',
className,
}: N64EmulatorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [loaded, setLoaded] = useState(false);
const [rom, setRom] = useState<ArrayBuffer | null>(null);
const coreRef = useRef<any>(null);
// Initialize WASM core
useEffect(() => {
if (!canvasRef.current || !rom) return;
let cleanup: (() => void) | undefined;
(async () => {
try {
const { initN64 } = await import('@trickbook/n64-wasm');
const core = await initN64({
canvas: canvasRef.current!,
romData: rom,
threads: true,
simd: true,
audioWorklet: true,
shader,
});
coreRef.current = core;
setLoaded(true);
onReady?.();
// FPS reporting
if (onFpsUpdate) {
core.onFps((fps: number) => onFpsUpdate(fps));
}
cleanup = () => core.destroy();
} catch (err) {
onError?.(err as Error);
}
})();
return () => cleanup?.();
}, [rom, shader]);
// ROM loading via file picker
const handleFile = useCallback(async (file: File) => {
const buffer = await file.arrayBuffer();
setRom(buffer);
}, []);
// Drag and drop
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}, [handleFile]);
return (
<div
className={className}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
<canvas
ref={canvasRef}
width={640}
height={480}
style={{ width: '100%', aspectRatio: '4/3', imageRendering: 'pixelated' }}
/>
{!rom && (
<div className="rom-picker-overlay">
<input
type="file"
accept=".z64,.n64,.v64,.rom"
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
/>
<p>Drop a .z64 ROM file here or click to select</p>
</div>
)}
{loaded && controlMode === 'touch' && (
<TouchControls core={coreRef.current} />
)}
</div>
);
}
The Page
// app/play/page.tsx
'use client';
import dynamic from 'next/dynamic';
// Dynamic import — WASM cannot be server-rendered
const N64Emulator = dynamic(
() => import('@/components/N64Emulator').then(m => m.N64Emulator),
{ ssr: false }
);
export default function PlayPage() {
return (
<main className="flex flex-col items-center min-h-screen bg-black p-4">
<h1 className="text-2xl font-bold text-green-400 mb-4 font-mono">
N64.wasm
</h1>
<N64Emulator
controlMode="gamepad"
shader="none"
onFpsUpdate={(fps) => console.log(`${fps} FPS`)}
className="w-full max-w-4xl"
/>
<p className="text-gray-500 text-sm mt-4 font-mono">
Provide your own legally obtained ROM file.
We do not host or distribute game ROMs.
</p>
</main>
);
}
Hooks API
useEmulator
function useEmulator(options: EmulatorOptions) {
return {
core: N64Core | null,
status: 'idle' | 'loading' | 'running' | 'paused' | 'error',
fps: number,
loadRom: (data: ArrayBuffer) => Promise<void>,
pause: () => void,
resume: () => void,
reset: () => void,
saveState: () => Promise<Uint8Array>,
loadState: (data: Uint8Array) => Promise<void>,
};
}
useRecording
function useRecording(canvas: HTMLCanvasElement | null) {
return {
isRecording: boolean,
startRecording: () => void,
stopRecording: () => Promise<Blob>,
clipLast30Seconds: () => Promise<Blob>,
shareClip: (blob: Blob) => Promise<void>,
};
}
useSaveStates
function useSaveStates(core: N64Core | null) {
return {
saves: SaveState[],
save: (slot: number) => Promise<void>,
load: (slot: number) => Promise<void>,
exportSave: (slot: number) => Promise<Blob>,
importSave: (data: ArrayBuffer) => Promise<void>,
shareState: (slot: number) => Promise<string>, // Returns shareable URL
syncToCloud: () => Promise<void>,
};
}
COOP/COEP Considerations
The Cross-Origin-Embedder-Policy: require-corp header means:
- All
<img>,<script>,<link>resources must either be same-origin OR haveCross-Origin-Resource-Policy: cross-originheader - Third-party scripts (analytics, fonts) need the
crossoriginattribute - CDN resources need CORS headers
// Google Fonts — add crossorigin
<link
href="https://fonts.googleapis.com/..."
rel="stylesheet"
crossOrigin="anonymous"
/>
// Images from CDN
<img src="https://cdn.example.com/img.png" crossOrigin="anonymous" />
warning
If you can't add COOP/COEP to your entire site (breaks third-party embeds), scope it to the /play route only using Next.js header matching:
source: '/play/:path*',
Deployment
The site deploys to n64.weshuber.com via:
- S3 bucket for static assets
- CloudFront for CDN + custom headers
- Route53 for DNS
- ACM for SSL certificate
See the infrastructure section for AWS setup details.