writing/tutorial/2026/07
TutorialJul 15, 2026·28 min read

DuckDB-WASM in the Browser: Build a Client-Side Analytics Dashboard with Next.js 15

Learn how to run a full analytical SQL database directly in the browser with DuckDB-WASM. This hands-on tutorial walks you through setting up DuckDB-WASM in a Next.js 15 App Router project, querying remote Parquet files over HTTP, analyzing user-uploaded CSVs, and building a zero-backend analytics dashboard.

The analytics stack is collapsing into the client. With the lakehouse ecosystem maturing fast — DuckLake 1.0 and Iceberg v3 landed just this summer — one of the most surprising winners is a database that does not need a server at all. DuckDB-WASM compiles the full DuckDB analytical engine to WebAssembly, which means your users' browsers can scan Parquet files, join tables, and run window functions over millions of rows without a single request hitting your backend.

In this tutorial you will build a complete client-side analytics dashboard with Next.js 15 and DuckDB-WASM. You will query remote Parquet files over HTTP, let users drop in their own CSV files, and render aggregate results — all with zero API routes, zero database servers, and complete data privacy, since uploaded data never leaves the user's machine.

Why Run a Database in the Browser?

Before writing code, it is worth understanding when this architecture shines:

  • Privacy by default. User data is processed locally. For MENA businesses handling sensitive financial or customer data, this removes an entire category of compliance concerns — nothing is transmitted, so nothing needs to be secured in transit or at rest on your servers.
  • Zero backend cost. The heavy lifting (scans, joins, aggregations) runs on the user's CPU. Your hosting bill does not grow with query volume.
  • Instant interactivity. After the initial data load, every filter change and drill-down is a local query. Latency drops from hundreds of milliseconds to single-digit milliseconds.
  • Real SQL. DuckDB supports window functions, CTEs, PIVOT, list aggregations, and direct querying of Parquet, CSV, and JSON files. This is not a toy — it is the same engine powering production data pipelines.

The trade-off: the WASM bundle weighs several megabytes (loaded lazily and cached), and datasets must fit in browser memory — a practical ceiling of a few hundred megabytes to a couple of gigabytes depending on the device. For dashboards, internal tools, and data exploration UIs, that ceiling is rarely a problem.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed
  • pnpm (or npm/yarn) as your package manager
  • Basic knowledge of React and the Next.js App Router
  • Familiarity with SQL (SELECT, GROUP BY, JOIN)
  • A code editor (VS Code recommended)

What You'll Build

A single-page analytics dashboard that:

  1. Boots DuckDB-WASM in a Web Worker so the UI thread stays responsive
  2. Queries a remote Parquet dataset over HTTP with range requests (no full download)
  3. Accepts CSV file uploads and registers them as queryable tables
  4. Runs aggregate SQL and renders results in stat cards and a data table
  5. Ships as a fully static Next.js app — deployable to any CDN

Step 1: Project Setup

Create a fresh Next.js 15 project with TypeScript and Tailwind:

pnpm create next-app@latest duckdb-dashboard --typescript --tailwind --eslint --app --src-dir=false
cd duckdb-dashboard

Install DuckDB-WASM and Apache Arrow (DuckDB returns query results as Arrow tables):

pnpm add @duckdb/duckdb-wasm apache-arrow

That is the entire dependency list. No ORM, no database driver, no API client.

Step 2: Initialize DuckDB-WASM with a Singleton

DuckDB-WASM runs inside a Web Worker. Instantiating it is relatively expensive (fetching the WASM bundle, spawning the worker), so you want exactly one instance shared across your whole app. Create lib/duckdb.ts:

// lib/duckdb.ts
import * as duckdb from "@duckdb/duckdb-wasm";
 
let dbPromise: Promise<duckdb.AsyncDuckDB> | null = null;
 
async function initDuckDB(): Promise<duckdb.AsyncDuckDB> {
  // Select the best bundle for this browser (MVP vs EH vs COI)
  const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
  const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
 
  // Workers can't be created cross-origin, so we wrap the
  // CDN-hosted worker script in a same-origin Blob URL
  const workerUrl = URL.createObjectURL(
    new Blob([`importScripts("${bundle.mainWorker}");`], {
      type: "text/javascript",
    })
  );
 
  const worker = new Worker(workerUrl);
  const logger = new duckdb.ConsoleLogger(duckdb.LogLevel.WARNING);
  const db = new duckdb.AsyncDuckDB(logger, worker);
 
  await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
  URL.revokeObjectURL(workerUrl);
 
  return db;
}
 
export function getDuckDB(): Promise<duckdb.AsyncDuckDB> {
  if (!dbPromise) {
    dbPromise = initDuckDB();
  }
  return dbPromise;
}

A few important details:

  • getJsDelivrBundles() returns URLs for the WASM binaries hosted on jsDelivr. selectBundle() picks the fastest variant the current browser supports — the exception-handling build for modern browsers, the MVP build as a fallback.
  • The Blob URL trick is required because browsers refuse to spawn a Worker from a cross-origin URL. Wrapping the CDN script in importScripts inside a same-origin Blob sidesteps this cleanly.
  • The module-level dbPromise makes this a singleton: React Strict Mode double-invocations and multiple components calling getDuckDB() all share one instance.

If you prefer to self-host the WASM bundles instead of using jsDelivr, copy them from node_modules into your public directory and build a manual bundle map. Self-hosting avoids a third-party dependency in production and lets you set long cache headers yourself.

Step 3: A React Hook for Queries

Next, wrap the database in an ergonomic hook. Create hooks/useDuckDB.ts:

// hooks/useDuckDB.ts
"use client";
 
import { useCallback, useEffect, useRef, useState } from "react";
import type { AsyncDuckDB, AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";
import { getDuckDB } from "@/lib/duckdb";
 
export function useDuckDB() {
  const [ready, setReady] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const dbRef = useRef<AsyncDuckDB | null>(null);
  const connRef = useRef<AsyncDuckDBConnection | null>(null);
 
  useEffect(() => {
    let cancelled = false;
 
    getDuckDB()
      .then(async (db) => {
        if (cancelled) return;
        dbRef.current = db;
        connRef.current = await db.connect();
        setReady(true);
      })
      .catch((e) => setError(String(e)));
 
    return () => {
      cancelled = true;
    };
  }, []);
 
  const query = useCallback(async (sql: string) => {
    if (!connRef.current) throw new Error("DuckDB not ready");
    const result = await connRef.current.query(sql);
    // Arrow Table -> plain JS objects for easy rendering
    return result.toArray().map((row) => row.toJSON());
  }, []);
 
  return { ready, error, query, db: dbRef, conn: connRef };
}

The query helper converts Arrow rows to plain JavaScript objects with toJSON(), which is convenient for rendering. For very large result sets you would keep the Arrow table and read columns directly — Arrow's columnar layout is far more memory-efficient — but for dashboard-sized results (hundreds to thousands of rows), plain objects keep the React code simple.

Everything DuckDB-WASM touches must run client-side. Never import lib/duckdb.ts from a Server Component — the "use client" directive on consuming components is mandatory, and dynamic imports with ssr disabled are the safest pattern for page-level integration.

Step 4: Query Remote Parquet Files over HTTP

Here is where DuckDB-WASM gets genuinely impressive. It can query a Parquet file sitting on any CORS-enabled HTTP server without downloading the whole file. DuckDB reads the Parquet footer, figures out which row groups and columns it needs, and fetches only those byte ranges.

Try it with a public dataset:

const rows = await query(`
  SELECT
    passenger_count,
    COUNT(*) AS trips,
    ROUND(AVG(fare_amount), 2) AS avg_fare
  FROM 'https://blobs.duckdb.org/data/taxi_2019_04.parquet'
  GROUP BY passenger_count
  ORDER BY passenger_count
`);

That is a full aggregation over a remote Parquet file, executed in a browser tab. The first query fetches metadata plus the needed columns; subsequent queries against the same file reuse the buffered data.

For files you control, two things matter:

  1. CORS headers. The server must send Access-Control-Allow-Origin for your domain and allow the Range header. Object stores like Cloudflare R2, S3, and DigitalOcean Spaces all support this via bucket CORS configuration.
  2. Range request support. Almost every CDN and object store supports HTTP range requests. Without them, DuckDB falls back to a full download.

You can also register the URL explicitly, which gives the file a friendly name for repeated use:

import * as duckdb from "@duckdb/duckdb-wasm";
 
await db.registerFileURL(
  "sales.parquet",
  "https://cdn.example.com/data/sales.parquet",
  duckdb.DuckDBDataProtocol.HTTP,
  false
);
 
const summary = await query(`
  SELECT region, SUM(amount) AS revenue
  FROM 'sales.parquet'
  GROUP BY region
  ORDER BY revenue DESC
`);

Publish your dashboard datasets as Parquet rather than CSV or JSON. Parquet is columnar and compressed, so a query touching 3 columns of a 40-column table reads a fraction of the bytes — often 10x to 50x less transfer than the equivalent CSV.

Step 5: Let Users Upload CSV Files

The second data path is user-supplied files. registerFileBuffer places a file's bytes into DuckDB's virtual filesystem, and read_csv (with automatic schema detection) turns it into a table. Create components/CsvUploader.tsx:

// components/CsvUploader.tsx
"use client";
 
import { useState } from "react";
import { useDuckDB } from "@/hooks/useDuckDB";
 
export function CsvUploader({ onLoaded }: { onLoaded: (table: string) => void }) {
  const { ready, db, conn } = useDuckDB();
  const [status, setStatus] = useState<string>("");
 
  async function handleFile(file: File) {
    if (!db.current || !conn.current) return;
    setStatus("Loading " + file.name + "...");
 
    const buffer = new Uint8Array(await file.arrayBuffer());
    await db.current.registerFileBuffer(file.name, buffer);
 
    // Create a real table so later queries skip re-parsing the CSV
    await conn.current.query(`
      CREATE OR REPLACE TABLE uploaded AS
      SELECT * FROM read_csv('${file.name}', header = true, auto_detect = true)
    `);
 
    const count = await conn.current.query(
      `SELECT COUNT(*) AS n FROM uploaded`
    );
    setStatus(`Loaded ${count.toArray()[0].n} rows`);
    onLoaded("uploaded");
  }
 
  return (
    <div className="rounded-lg border border-dashed p-6">
      <input
        type="file"
        accept=".csv"
        disabled={!ready}
        onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
      />
      <p className="mt-2 text-sm text-gray-500">{status}</p>
    </div>
  );
}

Two design decisions worth noting:

  • CREATE TABLE vs querying the file directly. Querying read_csv(...) re-parses the CSV on every query. Materializing it once into a native DuckDB table makes every subsequent query dramatically faster.
  • The file never leaves the browser. arrayBuffer() reads it into memory, registerFileBuffer hands it to the WASM filesystem. There is no upload — a genuine selling point for tools handling payroll, invoices, or customer exports.

Step 6: Build the Dashboard Page

Now assemble the pieces. Create app/dashboard/page.tsx with a client component that runs aggregate queries and renders results:

// app/dashboard/page.tsx
"use client";
 
import { useEffect, useState } from "react";
import { useDuckDB } from "@/hooks/useDuckDB";
import { CsvUploader } from "@/components/CsvUploader";
 
type Row = Record<string, unknown>;
 
export default function DashboardPage() {
  const { ready, error, query } = useDuckDB();
  const [rows, setRows] = useState<Row[]>([]);
  const [columns, setColumns] = useState<string[]>([]);
  const [sql, setSql] = useState(
    "SELECT * FROM 'https://blobs.duckdb.org/data/taxi_2019_04.parquet' LIMIT 20"
  );
  const [elapsed, setElapsed] = useState<number | null>(null);
 
  async function run(sqlText: string) {
    const start = performance.now();
    try {
      const result = (await query(sqlText)) as Row[];
      setElapsed(Math.round(performance.now() - start));
      setRows(result);
      setColumns(result.length ? Object.keys(result[0]) : []);
    } catch (e) {
      alert(String(e));
    }
  }
 
  useEffect(() => {
    if (ready) run(sql);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ready]);
 
  if (error) return <p className="p-8 text-red-600">DuckDB failed: {error}</p>;
  if (!ready) return <p className="p-8 animate-pulse">Booting DuckDB-WASM...</p>;
 
  return (
    <main className="mx-auto max-w-5xl p-8 space-y-6">
      <h1 className="text-2xl font-bold">In-Browser Analytics</h1>
 
      <CsvUploader onLoaded={() => run("SELECT * FROM uploaded LIMIT 50")} />
 
      <textarea
        className="w-full rounded border p-3 font-mono text-sm"
        rows={4}
        value={sql}
        onChange={(e) => setSql(e.target.value)}
      />
      <div className="flex items-center gap-4">
        <button
          onClick={() => run(sql)}
          className="rounded bg-amber-500 px-4 py-2 font-medium text-white"
        >
          Run Query
        </button>
        {elapsed !== null && (
          <span className="text-sm text-gray-500">{elapsed} ms</span>
        )}
      </div>
 
      <div className="overflow-x-auto rounded border">
        <table className="w-full text-sm">
          <thead className="bg-gray-50">
            <tr>
              {columns.map((c) => (
                <th key={c} className="px-3 py-2 text-left font-semibold">
                  {c}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {rows.map((r, i) => (
              <tr key={i} className="border-t">
                {columns.map((c) => (
                  <td key={c} className="px-3 py-2">
                    {String(r[c])}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </main>
  );
}

Start the dev server and open the dashboard:

pnpm dev
# open http://localhost:3000/dashboard

You now have a working SQL workbench in the browser. Type any DuckDB SQL — window functions, PIVOT, summarize, list comprehensions — and it executes locally.

Step 7: Useful Analytical Queries

To make the dashboard feel like a product rather than a SQL console, wire preset queries into stat cards. A few patterns that showcase DuckDB's analytical strength:

Instant dataset profiling — DuckDB's SUMMARIZE gives min, max, average, null percentage, and approximate distinct counts for every column in one statement:

SUMMARIZE SELECT * FROM uploaded;

Time-series rollups with date truncation:

SELECT
  date_trunc('month', order_date) AS month,
  COUNT(*) AS orders,
  SUM(amount) AS revenue,
  SUM(SUM(amount)) OVER (ORDER BY date_trunc('month', order_date)) AS cumulative
FROM uploaded
GROUP BY 1
ORDER BY 1;

Top-N per group with window functions:

SELECT * FROM (
  SELECT
    region,
    product,
    SUM(amount) AS revenue,
    ROW_NUMBER() OVER (PARTITION BY region ORDER BY SUM(amount) DESC) AS rank
  FROM uploaded
  GROUP BY region, product
) WHERE rank <= 3;

Each of these runs in milliseconds on hundreds of thousands of rows — on a laptop, in a tab.

Testing Your Implementation

Verify the full flow works end to end:

  1. Boot check: Load /dashboard with DevTools open. The Network tab should show the WASM bundle loading once (roughly 6 MB, then cached). The page should render "Booting DuckDB-WASM..." followed by the taxi dataset preview.
  2. Remote Parquet: Run the aggregation query from Step 4. Watch the Network tab — you should see range requests (HTTP 206 responses), not a full file download.
  3. CSV upload: Drop in any CSV. Confirm the row count appears and SELECT * FROM uploaded LIMIT 5 returns your data. Check the Network tab again: no upload request should exist.
  4. Memory behavior: Load a larger CSV (50-100 MB) and run SUMMARIZE. The tab's memory grows but the UI stays responsive, because DuckDB runs in its worker thread.

Troubleshooting

"Failed to construct Worker" or CORS errors on boot. You are likely instantiating the worker directly from the jsDelivr URL. Use the Blob URL wrapper from Step 2.

"window is not defined" during build. A Server Component is importing DuckDB code. Ensure every consumer has "use client" and consider a dynamic import with ssr: false for the whole dashboard.

Remote Parquet query fails with an HTTP error. The hosting server is missing CORS headers, or blocks the Range header. Test with the DuckDB-hosted sample file first to isolate whether the problem is your bucket configuration.

Queries on uploaded CSVs are slow. You are re-reading the CSV each time. Materialize it with CREATE TABLE as shown in Step 5.

BigInt values render as errors. DuckDB returns 64-bit integers as BigInt, and JSON.stringify throws on BigInt. Cast in SQL (::DOUBLE or ::VARCHAR) or convert with String() when rendering, as the table component above does.

Next Steps

  • Persist data across sessions with DuckDB's OPFS support (opening a database with an opfs:// path) so uploaded datasets survive page reloads.
  • Add charts — pair query results with a charting library and re-run queries as users change filters; local latency makes brushing and cross-filtering feel native.
  • Explore the lakehouse angle — our recent guide to DuckLake 1.0 and Iceberg v3 shows the server-side counterpart of this stack.
  • Compare with PGlite — for transactional (rather than analytical) in-browser workloads, see our tutorial on PGlite, Postgres compiled to WASM.

Conclusion

You built a genuinely serverless analytics dashboard: DuckDB-WASM boots in a Web Worker, scans remote Parquet files with range requests, ingests user CSVs without uploading a byte, and answers analytical SQL in milliseconds. For dashboards, internal tools, and privacy-sensitive data products, this architecture deletes the most expensive parts of the traditional stack — the API layer, the database server, and the compliance burden of holding user data you never actually needed.

The mental model shift is the real takeaway: the browser is now a legitimate deployment target for analytical databases. Ship the engine to the data's owner, and most of your backend disappears.