DuckVizBeta
Guides

Next.js Integration

Build a full-stack app with DuckViz packages and Next.js App Router.

This guide walks through building a complete Next.js application that uses DuckViz packages to fetch data from an API, ingest it into DuckDB, explore it with the Explorer, and display it on a Dashboard.

Just want to start coding?

npx duckviz create-app my-app

…scaffolds everything in this guide — providers, SDK proxy, customFetch rewrite, and four working routes (/dashboard, /explorer, /report, /deck) wired to a shared demo dataset. Read on if you want to wire DuckViz into an existing Next.js app instead, or if you want to understand what the scaffold puts in place.

Working example

The complete code for this guide is available in the duckviz-examples/next-app repository.

Prerequisites

  • Node.js 18+ or Bun 1.2+
  • A DuckViz account with an API token (for AI features)

1. Create a Next.js project

npx create-next-app@latest my-duckviz-app --typescript --app
cd my-duckviz-app

2. Install packages

npm install @duckviz/db @duckviz/explorer @duckviz/dashboard @duckviz/ui @duckviz/widgets @duckviz/sdk
npm install @duckdb/duckdb-wasm apache-arrow d3 zustand @tanstack/react-query @radix-ui/themes

3. Set up providers

Create a providers file that wraps your app with DuckDB and theme providers:

// app/providers.tsx
"use client";

import type { ReactNode } from "react";
import { useState } from "react";
import { DuckvizDBProvider } from "@duckviz/db";
import { DuckvizThemeProvider, ALL_PRESETS } from "@duckviz/ui";

export function Providers({ children }: { children: ReactNode }) {
  const [mode, setMode] = useState<"light" | "dark">("dark");
  const [preset] = useState(ALL_PRESETS[0]!); // terracotta

  return (
    <DuckvizThemeProvider preset={preset} mode={mode}>
      <DuckvizDBProvider persistence arrowIngest batchSize={5000} logLevel="warn">
        {children}
      </DuckvizDBProvider>
    </DuckvizThemeProvider>
  );
}

Then use it in your root layout:

// app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

4. Create an API data source

Create an API route that returns data for ingestion:

// app/api/users/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  // In production, fetch from your real data source
  const users = Array.from({ length: 1000 }, (_, i) => ({
    id: i + 1,
    name: `User ${i + 1}`,
    email: `user${i + 1}@example.com`,
    department: ["Engineering", "Sales", "Marketing", "Support"][i % 4],
    salary: Math.round(50000 + Math.random() * 100000),
    joined: new Date(2020 + Math.floor(i / 250), i % 12, 1).toISOString(),
  }));

  return NextResponse.json({ users });
}

5. Set up the SDK proxy (for AI features)

Create a catch-all route that proxies AI requests through your backend:

// app/api/duckviz/[...route]/route.ts
import { createDuckvizHandlers } from "@duckviz/sdk/next";

const handler = createDuckvizHandlers({
  token: process.env.DUCKVIZ_API_TOKEN!,
});

export const GET = handler;
export const POST = handler;

Add your token to .env.local:

DUCKVIZ_API_TOKEN=dvz_live_your_token_here

6. Create a dashboard store

A minimal Zustand store to manage dashboards:

// stores/dashboard.ts
import { create } from "zustand";
import type { RecommendedWidget } from "@duckviz/explorer";

export interface DashboardWidget extends RecommendedWidget {
  id: string;
}

export interface Dashboard {
  id: string;
  name: string;
  widgets: DashboardWidget[];
}

interface DashboardStore {
  dashboards: Dashboard[];
  createDashboard: (name: string) => string;
  addWidget: (dashboardId: string, widget: RecommendedWidget) => void;
}

let seq = 0;

export const useDashboardStore = create<DashboardStore>((set) => ({
  dashboards: [],
  createDashboard: (name) => {
    const id = crypto.randomUUID();
    set((s) => ({
      dashboards: [...s.dashboards, { id, name, widgets: [] }],
    }));
    return id;
  },
  addWidget: (dashboardId, widget) =>
    set((s) => ({
      dashboards: s.dashboards.map((d) =>
        d.id === dashboardId
          ? { ...d, widgets: [...d.widgets, { ...widget, id: `w-${++seq}` }] }
          : d,
      ),
    })),
}));

7. Build the main page

Put it all together — fetch data, hand it to the Explorer, and let it handle ingestion via DuckvizDataset:

// app/page.tsx
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import {
  Explorer,
  type ExplorerDashboardRef,
  type RecommendedWidget,
} from "@duckviz/explorer";
import type { DuckvizDataset } from "@duckviz/db";
import { useDashboardStore } from "../stores/dashboard";

// Rewrite API paths to go through our proxy
const customFetch = (input: RequestInfo | URL, init?: RequestInit) => {
  if (typeof input === "string" && input.startsWith("/api/")) {
    input = input.replace(/^\/api\//, "/api/duckviz/");
  }
  return fetch(input, init);
};

export default function Page() {
  const dashboards = useDashboardStore((s) => s.dashboards);
  const addWidget = useDashboardStore((s) => s.addWidget);
  const createDashboard = useDashboardStore((s) => s.createDashboard);

  const [datasets, setDatasets] = useState<DuckvizDataset[] | null>(null);

  // Fetch data once — Explorer handles DuckDB ingestion
  useEffect(() => {
    (async () => {
      const res = await fetch("/api/users");
      const { users } = await res.json();
      setDatasets([{ name: "Users", data: users, tableName: "t_users" }]);
    })();
  }, []);

  const explorerDashboards: ExplorerDashboardRef[] = useMemo(
    () => dashboards.map((d) => ({ id: d.id, name: d.name })),
    [dashboards],
  );

  const handleAddWidget = useCallback(
    (dashboardId: string, widget: RecommendedWidget) => {
      addWidget(dashboardId, widget);
      return { ok: true };
    },
    [addWidget],
  );

  if (!datasets) return <p>Loading...</p>;

  return (
    <Explorer
      datasets={datasets}
      dashboards={explorerDashboards}
      onAddWidgetToDashboard={handleAddWidget}
      onCreateDashboard={(name) => createDashboard(name)}
      authenticated={true}
      customFetch={customFetch}
    />
  );
}

8. Run the app

npm run dev

Open http://localhost:3000. You should see the Explorer with the Users table loaded. The AI widget panel will generate chart recommendations automatically.

Key patterns to note

customFetch for API proxying

The Explorer calls /api/widget-flow/* for AI features. The customFetch prop rewrites these to /api/duckviz/widget-flow/*, which hits the SDK proxy route you created.

datasets for programmatic data

Instead of using the Explorer's file-upload flow, you pass row data as DuckvizDataset[] — Explorer auto-ingests into DuckDB on mount and drops the tables on unmount. If you need to control ingestion yourself (streaming, pre-existing tables), use the ExplorerDataset[] shape instead. See the Datasets Mode Guide for both variants.

Dashboard callback wiring

The Explorer communicates with your dashboard store through three callbacks:

  • onAddWidgetToDashboard — user clicks "+" on a widget
  • onCreateDashboard — user creates a new dashboard from the widget panel
  • dashboards — read-only list of available dashboards

Persistence

With persistence enabled on DuckvizDBProvider, tables ingested directly via db.ingest() survive browser refreshes. Auto-ingested DuckvizDataset tables are dropped on unmount by default — pass dropTablesOnUnmount={false} to keep them.

Next steps