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:
- Boots DuckDB-WASM in a Web Worker so the UI thread stays responsive
- Queries a remote Parquet dataset over HTTP with range requests (no full download)
- Accepts CSV file uploads and registers them as queryable tables
- Runs aggregate SQL and renders results in stat cards and a data table
- 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-dashboardInstall DuckDB-WASM and Apache Arrow (DuckDB returns query results as Arrow tables):
pnpm add @duckdb/duckdb-wasm apache-arrowThat 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
importScriptsinside a same-origin Blob sidesteps this cleanly. - The module-level
dbPromisemakes this a singleton: React Strict Mode double-invocations and multiple components callinggetDuckDB()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:
- CORS headers. The server must send
Access-Control-Allow-Originfor your domain and allow theRangeheader. Object stores like Cloudflare R2, S3, and DigitalOcean Spaces all support this via bucket CORS configuration. - 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,registerFileBufferhands 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/dashboardYou 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:
- Boot check: Load
/dashboardwith 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. - 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.
- CSV upload: Drop in any CSV. Confirm the row count appears and
SELECT * FROM uploaded LIMIT 5returns your data. Check the Network tab again: no upload request should exist. - 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.