diff --git a/.github/workflows/web-starter-playwright.yml b/.github/workflows/web-starter-playwright.yml
index c8b76721..8d1476c8 100644
--- a/.github/workflows/web-starter-playwright.yml
+++ b/.github/workflows/web-starter-playwright.yml
@@ -69,6 +69,21 @@ jobs:
run: |
yarn test:e2e
+ - name: Install JS dependencies (next)
+ working-directory: web-starter/web/nextjs
+ run: |
+ yarn install
+
+ - name: Install Playwright Browsers (next)
+ working-directory: web-starter/web/nextjs
+ run: |
+ yarn playwright install --with-deps
+
+ - name: Run Playwright tests (next)
+ working-directory: web-starter/web/nextjs
+ run: |
+ yarn test:e2e
+
- name: Create issue on failure (nightly)
if: failure() && github.event_name == 'schedule'
uses: actions/github-script@v6
diff --git a/web-starter/web/nextjs/.gitignore b/web-starter/web/nextjs/.gitignore
new file mode 100644
index 00000000..83089071
--- /dev/null
+++ b/web-starter/web/nextjs/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+.next/
+dist/
+.env*
+yarn.lock
+npm-debug.log*
+.DS_Store
\ No newline at end of file
diff --git a/web-starter/web/nextjs/README.md b/web-starter/web/nextjs/README.md
new file mode 100644
index 00000000..9ef1a63b
--- /dev/null
+++ b/web-starter/web/nextjs/README.md
@@ -0,0 +1,19 @@
+# Introduction
+
+A simple Noir circuit with browser proving with bb.js
+This is a Next.js version, similar to the vite and webpack examples.
+
+Tested with Noir 1.0.0-beta.6, bb 0.84.0, and Next.js 14.
+
+## Setup
+
+```bash
+(cd ../../circuits && ./build.sh)
+yarn
+```
+
+## Run
+
+```bash
+yarn dev
+```
\ No newline at end of file
diff --git a/web-starter/web/nextjs/app/globals.css b/web-starter/web/nextjs/app/globals.css
new file mode 100644
index 00000000..0d1f697a
--- /dev/null
+++ b/web-starter/web/nextjs/app/globals.css
@@ -0,0 +1,45 @@
+body {
+ font-family: Arial, sans-serif;
+ background: #f8f8f8;
+ margin: 0;
+ padding: 0;
+}
+
+.container, main {
+ max-width: 600px;
+ margin: 40px auto;
+ background: #fff;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.08);
+ padding: 32px;
+}
+
+h1 {
+ font-size: 2rem;
+ margin-bottom: 1.5rem;
+}
+
+button {
+ padding: 0.5rem 1.5rem;
+ font-size: 1rem;
+ border-radius: 4px;
+ border: none;
+ background: #0070f3;
+ color: #fff;
+ cursor: pointer;
+ margin-bottom: 1rem;
+}
+
+button:disabled {
+ background: #aaa;
+ cursor: not-allowed;
+}
+
+#result {
+ background: #f4f4f4;
+ border-radius: 4px;
+ padding: 1rem;
+ min-height: 2rem;
+ margin-top: 1rem;
+ font-family: monospace;
+}
\ No newline at end of file
diff --git a/web-starter/web/nextjs/app/layout.tsx b/web-starter/web/nextjs/app/layout.tsx
new file mode 100644
index 00000000..eb0cc2f2
--- /dev/null
+++ b/web-starter/web/nextjs/app/layout.tsx
@@ -0,0 +1,12 @@
+import './globals.css';
+import type { ReactNode } from 'react';
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
\ No newline at end of file
diff --git a/web-starter/web/nextjs/app/page.tsx b/web-starter/web/nextjs/app/page.tsx
new file mode 100644
index 00000000..bbcc4382
--- /dev/null
+++ b/web-starter/web/nextjs/app/page.tsx
@@ -0,0 +1,10 @@
+import ProofComponent from './proof-component';
+
+export default function HomePage() {
+ return (
+
+ Noir UH Starter (Next.js)
+
+
+ );
+}
\ No newline at end of file
diff --git a/web-starter/web/nextjs/app/proof-component.tsx b/web-starter/web/nextjs/app/proof-component.tsx
new file mode 100644
index 00000000..6c970779
--- /dev/null
+++ b/web-starter/web/nextjs/app/proof-component.tsx
@@ -0,0 +1,54 @@
+"use client";
+import { useState } from "react";
+import { UltraHonkBackend } from "@aztec/bb.js";
+import circuit from "../../../circuits/target/noir_uh_starter.json";
+import { Noir } from "@noir-lang/noir_js";
+
+export default function ProofComponent() {
+ const [result, setResult] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ async function generateProof() {
+ setLoading(true);
+ setResult((prev) => prev + "Generating proof...\n\n");
+ try {
+ const noir = new Noir(circuit as any);
+ const honk = new UltraHonkBackend((circuit as any).bytecode, {
+ threads: 8, // This will only work if SharedArrayBuffer is enabled (see next.config.mjs)
+ });
+ const inputs = { x: 3, y: 3 };
+ const { witness } = await noir.execute(inputs);
+ const { proof, publicInputs } = await honk.generateProof(witness);
+ setResult((prev) => prev + "Proof: " + proof + "\n\n");
+ setResult((prev) => prev + "Public inputs: " + publicInputs + "\n\n");
+ const verified = await honk.verifyProof({ proof, publicInputs });
+ setResult((prev) => prev + "Verified: " + verified + "\n\n");
+
+ // Send proof to server for server-side verification
+ setResult((prev) => prev + "Verifying on server...\n\n");
+ const response = await fetch("/api/verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ proof, publicInputs }),
+ });
+ const serverResult = await response.json();
+ setResult(
+ (prev) => prev + "Server verified: " + serverResult.verified + "\n\n"
+ );
+ } catch (error) {
+ setResult((prev) => prev + "Error: " + error + "\n\n");
+ }
+ setLoading(false);
+ }
+
+ return (
+
+
+
+ {result}
+
+
+ );
+}
diff --git a/web-starter/web/nextjs/next-env.d.ts b/web-starter/web/nextjs/next-env.d.ts
new file mode 100644
index 00000000..3cd7048e
--- /dev/null
+++ b/web-starter/web/nextjs/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/web-starter/web/nextjs/next.config.mjs b/web-starter/web/nextjs/next.config.mjs
new file mode 100644
index 00000000..447ed872
--- /dev/null
+++ b/web-starter/web/nextjs/next.config.mjs
@@ -0,0 +1,32 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ webpack: (config) => {
+ config.experiments = {
+ // This is required for @aztec/bb.js as it imports wasm files
+ asyncWebAssembly: true,
+ layers: true,
+ };
+ return config;
+ },
+ // These headers enable SharedArrayBuffer which is required for running
+ // @aztec/bb.js wasm in multiple threads.
+ async headers() {
+ return [
+ {
+ source: "/(.*)",
+ headers: [
+ {
+ key: "Cross-Origin-Embedder-Policy",
+ value: "require-corp",
+ },
+ {
+ key: "Cross-Origin-Opener-Policy",
+ value: "same-origin",
+ },
+ ],
+ },
+ ];
+ },
+};
+
+export default nextConfig;
diff --git a/web-starter/web/nextjs/package.json b/web-starter/web/nextjs/package.json
new file mode 100644
index 00000000..f7513dbb
--- /dev/null
+++ b/web-starter/web/nextjs/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "nextjs-noir",
+ "type": "module",
+ "dependencies": {
+ "@aztec/bb.js": "0.84.0",
+ "@noir-lang/noir_js": "1.0.0-beta.6",
+ "next": "^15.3.4",
+ "react": "18.2.0",
+ "react-dom": "18.2.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.53.0",
+ "@types/node": "^22.10.1",
+ "@types/react": "^18.2.62",
+ "@types/react-dom": "^18.2.19",
+ "typescript": "^5.8.3"
+ },
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "test:e2e": "playwright test"
+ },
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
+}
diff --git a/web-starter/web/nextjs/pages/api/verify.ts b/web-starter/web/nextjs/pages/api/verify.ts
new file mode 100644
index 00000000..96e3b1eb
--- /dev/null
+++ b/web-starter/web/nextjs/pages/api/verify.ts
@@ -0,0 +1,83 @@
+import type { NextApiRequest, NextApiResponse } from 'next';
+import { UltraHonkBackend, BarretenbergVerifier } from '@aztec/bb.js';
+import { promises as fs } from 'fs';
+import path from 'path';
+
+// Load circuit data at module level (outside the handler)
+let circuit: any = null;
+let honk: UltraHonkBackend | null = null;
+
+async function initializeCircuit() {
+ if (!circuit) {
+ const circuitPath = path.resolve(process.cwd(), '../../circuits/target/noir_uh_starter.json');
+ const circuitData = await fs.readFile(circuitPath, 'utf-8');
+ circuit = JSON.parse(circuitData);
+ honk = new UltraHonkBackend(circuit.bytecode, {
+ threads: 8,
+
+ // By default, bb.js downloads CRS files to ~/.bb-crs. For serverless environments where
+ // this path isn't writable, configure an alternate path (e.g. /tmp) using crsPath option
+ // crsPath: `/tmp`
+ });
+ }
+}
+
+export default async function handler(
+ req: NextApiRequest,
+ res: NextApiResponse
+) {
+ if (req.method !== 'POST') {
+ res.setHeader('Allow', ['POST']);
+ return res.status(405).end(`Method ${req.method} Not Allowed`);
+ }
+
+ try {
+ // Initialize circuit if not already done
+ await initializeCircuit();
+
+ const { proof, publicInputs } = req.body;
+
+ if (!proof || !publicInputs) {
+ return res.status(400).json({
+ error: 'Missing proof or publicInputs',
+ verified: false
+ });
+ }
+
+ if (!honk) {
+ throw new Error('Backend not initialized');
+ }
+
+ // Convert proof to Uint8Array if it's not already
+ let proofArray: Uint8Array;
+ if (proof instanceof Uint8Array) {
+ proofArray = proof;
+ } else if (Array.isArray(proof)) {
+ proofArray = new Uint8Array(proof);
+ } else if (typeof proof === 'object' && proof !== null) {
+ // Handle serialized Uint8Array (object with numeric keys)
+ const values = Object.values(proof) as number[];
+ proofArray = new Uint8Array(values);
+ } else {
+ throw new Error('Invalid proof format');
+ }
+
+ const publicInputsArray = Array.isArray(publicInputs) ? publicInputs : [publicInputs];
+
+ const verified = await honk.verifyProof({
+ proof: proofArray,
+ publicInputs: publicInputsArray
+ });
+
+ return res.status(200).json({
+ verified,
+ message: verified ? 'Proof verified successfully' : 'Proof verification failed'
+ });
+ } catch (error) {
+ console.error('Server-side verification error:', error);
+ return res.status(500).json({
+ error: String(error),
+ verified: false
+ });
+ }
+}
\ No newline at end of file
diff --git a/web-starter/web/nextjs/playwright.config.cjs b/web-starter/web/nextjs/playwright.config.cjs
new file mode 100644
index 00000000..1595ca0f
--- /dev/null
+++ b/web-starter/web/nextjs/playwright.config.cjs
@@ -0,0 +1,22 @@
+// playwright.config.js
+// @ts-check
+/** @type {import('@playwright/test').PlaywrightTestConfig} */
+const config = {
+ webServer: {
+ command: 'yarn dev',
+ port: 3000,
+ timeout: 120 * 1000,
+ reuseExistingServer: !process.env.CI,
+ },
+ use: {
+ baseURL: 'http://localhost:3000',
+ headless: true,
+ },
+ projects: [
+ { name: 'chromium', use: { browserName: 'chromium' } },
+ { name: 'firefox', use: { browserName: 'firefox' } },
+ { name: 'webkit', use: { browserName: 'webkit' } },
+ ],
+};
+
+module.exports = config;
\ No newline at end of file
diff --git a/web-starter/web/nextjs/tests/proof-verification.spec.ts b/web-starter/web/nextjs/tests/proof-verification.spec.ts
new file mode 100644
index 00000000..ad23171e
--- /dev/null
+++ b/web-starter/web/nextjs/tests/proof-verification.spec.ts
@@ -0,0 +1,45 @@
+import { test, expect, Page } from '@playwright/test';
+
+// Next.js version
+test('proof verification works in the browser', async ({ page }: { page: Page }) => {
+ await page.goto('/');
+ await page.click('#generateProofBtn');
+ // Wait for the result to contain 'Verified:' with increased timeout
+ let resultText = '';
+ try {
+ await expect(page.locator('#result')).toContainText('Verified:', { timeout: 30000 });
+ resultText = await page.locator('#result').innerText();
+ } catch (e) {
+ // Debug: print the current contents of #result if the check fails
+ resultText = await page.locator('#result').innerText();
+ console.log('DEBUG: #result contents at failure:', resultText);
+ throw e;
+ }
+ // Check that the result contains 'Verified: true' (or similar)
+ expect(resultText).toMatch(/Verified:\s*(true|1)/i);
+});
+
+test('proof is correctly verified server side using Pages Router', async () => {
+ // Import dependencies for proof generation
+ const { UltraHonkBackend } = await import('@aztec/bb.js');
+ const { Noir } = await import('@noir-lang/noir_js');
+
+ // Import circuit data
+ const circuit = await import('../../../circuits/target/noir_uh_starter.json', { with: { type: 'json' } });
+
+ // Prepare inputs matching the circuit
+ const noir = new Noir(circuit.default as any);
+ const honk = new UltraHonkBackend(circuit.default.bytecode, { threads: 8 });
+ const inputs = { x: 3, y: 3 };
+ const { witness } = await noir.execute(inputs);
+ const { proof, publicInputs } = await honk.generateProof(witness);
+
+ // POST to the Pages Router API endpoint
+ const res = await fetch('http://localhost:3000/api/verify', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ proof, publicInputs })
+ });
+ const data = await res.json();
+ expect(data.verified).toBe(true);
+});
\ No newline at end of file
diff --git a/web-starter/web/nextjs/tsconfig.json b/web-starter/web/nextjs/tsconfig.json
new file mode 100644
index 00000000..f29133dd
--- /dev/null
+++ b/web-starter/web/nextjs/tsconfig.json
@@ -0,0 +1,39 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "allowJs": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "outDir": "./dist",
+ "baseUrl": ".",
+ "noEmit": true,
+ "isolatedModules": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ "**/*.json",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist"
+ ]
+}