general6 min read

Building a Self-Service Reporting Portal with DataStoryBot

Tutorial: build a simple Next.js app where users upload CSVs and get instant data reports — using DataStoryBot as the analysis backend.

By DataStoryBot Team

Building a Self-Service Reporting Portal with DataStoryBot

Most reporting tools require training. Tableau takes weeks to learn. SQL dashboards require query skills. Even "self-service" BI tools need someone to set up the data models first.

A DataStoryBot-powered reporting portal is different: upload a CSV, get a narrative report with charts. No training. No configuration. No data modeling. The analysis is automatic, and the output is in plain English.

This tutorial builds a minimal Next.js application that does exactly that. Users upload a CSV, optionally provide a steering prompt ("focus on sales trends" or "compare regions"), and get back a full data story with charts.

Architecture

Browser ──upload──> Next.js API Route ──upload──> DataStoryBot /upload
                                      ──analyze─> DataStoryBot /analyze
                                      ──refine──> DataStoryBot /refine
                                      ──files───> DataStoryBot /files
Browser <──story── Next.js API Route <──results──

The Next.js API route proxies all DataStoryBot calls. The browser never talks to DataStoryBot directly. This keeps your API credentials server-side and lets you add caching, rate limiting, and access control.

Step 1: Project Setup

npx create-next-app@latest reporting-portal --typescript --app --tailwind
cd reporting-portal
npm install react-markdown

Step 2: The Upload Page

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

import { useState } from "react";
import ReactMarkdown from "react-markdown";

export default function ReportingPortal() {
  const [file, setFile] = useState<File | null>(null);
  const [steering, setSteering] = useState("");
  const [report, setReport] = useState<any>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleAnalyze() {
    if (!file) return;
    setLoading(true);
    setError(null);

    try {
      const formData = new FormData();
      formData.append("file", file);
      if (steering) formData.append("steering", steering);

      const res = await fetch("/api/analyze", {
        method: "POST",
        body: formData,
      });

      if (!res.ok) throw new Error("Analysis failed");
      const data = await res.json();
      setReport(data);
    } catch (err) {
      setError("Analysis failed. Please try again.");
    } finally {
      setLoading(false);
    }
  }

  return (
    <main className="mx-auto max-w-4xl px-4 py-12">
      <h1 className="text-3xl font-bold">Data Report Generator</h1>
      <p className="mt-2 text-gray-600">
        Upload a CSV file and get an AI-generated analysis with charts.
      </p>

      <div className="mt-8 space-y-4">
        <div>
          <label className="block text-sm font-medium">CSV File</label>
          <input
            type="file"
            accept=".csv"
            onChange={(e) => setFile(e.target.files?.[0] || null)}
            className="mt-1 block w-full text-sm file:mr-4 file:rounded file:border-0 file:bg-blue-600 file:px-4 file:py-2 file:text-sm file:text-white hover:file:bg-blue-700"
          />
        </div>

        <div>
          <label className="block text-sm font-medium">
            Focus (optional)
          </label>
          <input
            type="text"
            value={steering}
            onChange={(e) => setSteering(e.target.value)}
            placeholder="e.g., Compare revenue across regions"
            className="mt-1 block w-full rounded border px-3 py-2 text-sm"
          />
        </div>

        <button
          onClick={handleAnalyze}
          disabled={!file || loading}
          className="rounded bg-blue-600 px-6 py-2 text-white disabled:opacity-50"
        >
          {loading ? "Analyzing..." : "Generate Report"}
        </button>
      </div>

      {error && (
        <div className="mt-4 rounded bg-red-50 p-4 text-red-700">{error}</div>
      )}

      {report && (
        <div className="mt-8">
          <div className="prose max-w-none">
            <ReactMarkdown>{report.narrative}</ReactMarkdown>
          </div>
          <div className="mt-6 grid gap-4 md:grid-cols-2">
            {report.charts.map((chart: any, i: number) => (
              <figure key={i} className="rounded border p-2">
                <img src={chart.url} alt={chart.caption} className="w-full" />
                <figcaption className="mt-1 text-center text-xs text-gray-500">
                  {chart.caption}
                </figcaption>
              </figure>
            ))}
          </div>
        </div>
      )}
    </main>
  );
}

Step 3: The API Route

// app/api/analyze/route.ts
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { randomUUID } from "crypto";

const DSB_URL = "https://datastory.bot/api";
const CHARTS_DIR = join(process.cwd(), "public", "charts");

export async function POST(request: NextRequest) {
  const formData = await request.formData();
  const file = formData.get("file") as File;
  const steering = formData.get("steering") as string | null;

  if (!file) {
    return NextResponse.json({ error: "No file provided" }, { status: 400 });
  }

  // Upload to DataStoryBot
  const uploadForm = new FormData();
  uploadForm.append("file", file);

  const uploadRes = await fetch(`${DSB_URL}/upload`, {
    method: "POST",
    body: uploadForm,
  });

  if (!uploadRes.ok) {
    return NextResponse.json({ error: "Upload failed" }, { status: 500 });
  }

  const { containerId } = await uploadRes.json();

  // Analyze
  const analyzePayload: any = { containerId };
  if (steering) analyzePayload.steeringPrompt = steering;

  const storiesRes = await fetch(`${DSB_URL}/analyze`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(analyzePayload),
  });

  if (!storiesRes.ok) {
    return NextResponse.json({ error: "Analysis failed" }, { status: 500 });
  }

  const stories = await storiesRes.json();

  if (!stories.length) {
    return NextResponse.json(
      { error: "No patterns found in the data" },
      { status: 422 }
    );
  }

  // Refine top story
  const refineRes = await fetch(`${DSB_URL}/refine`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      containerId,
      selectedStoryTitle: stories[0].title,
    }),
  });

  if (!refineRes.ok) {
    return NextResponse.json({ error: "Refinement failed" }, { status: 500 });
  }

  const report = await refineRes.json();

  // Download and save charts
  await mkdir(CHARTS_DIR, { recursive: true });
  const charts = [];

  for (const chart of report.charts || []) {
    const imgRes = await fetch(
      `${DSB_URL}/files/${containerId}/${chart.fileId}`
    );
    const buffer = Buffer.from(await imgRes.arrayBuffer());
    const filename = `${randomUUID()}.png`;
    await writeFile(join(CHARTS_DIR, filename), buffer);
    charts.push({
      url: `/charts/${filename}`,
      caption: chart.caption,
    });
  }

  return NextResponse.json({
    narrative: report.narrative,
    charts,
    title: stories[0].title,
    storyCount: stories.length,
  });
}

Step 4: Run It

npm run dev

Open http://localhost:3000, upload a CSV, and get your report.

Adding Features

Multiple Story Selection

Let users choose which story angle to refine instead of always taking the first:

// In the frontend, after analysis returns stories:
{stories && !report && (
  <div className="mt-6 space-y-3">
    <h2 className="font-semibold">Choose a story angle:</h2>
    {stories.map((story: any) => (
      <button
        key={story.id}
        onClick={() => refineStory(story.title)}
        className="block w-full rounded border p-4 text-left hover:bg-gray-50"
      >
        <div className="font-medium">{story.title}</div>
        <div className="mt-1 text-sm text-gray-600">{story.summary}</div>
      </button>
    ))}
  </div>
)}

Split the API route into two endpoints: /api/analyze (returns story angles) and /api/refine (generates the full report for a selected story).

Export to PDF

Add a "Download PDF" button that sends the narrative + charts to a PDF generation endpoint. See PDF data reports from AI for the implementation.

Access Control

Wrap the API route with your authentication middleware:

export async function POST(request: NextRequest) {
  const session = await getSession(request);
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Rate limit per user
  const rateOk = await checkRateLimit(session.userId, 10, "1h");
  if (!rateOk) {
    return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 });
  }

  // ... rest of analysis logic
}

Analysis History

Store past analyses in a database so users can revisit them:

// After generating a report, save it
await db.reports.create({
  userId: session.userId,
  title: stories[0].title,
  narrative: report.narrative,
  charts: charts.map(c => c.url),
  fileName: file.name,
  createdAt: new Date(),
});

Add a /history page that lists past reports with their narratives and charts.

Production Considerations

Caching. Identical files with identical steering prompts should return cached results. Hash the file content + steering prompt as the cache key.

File size limits. DataStoryBot accepts up to 50 MB. Add client-side and server-side validation to reject larger files with a helpful error message.

Timeout handling. Analysis can take 30-120 seconds. Use server-sent events or polling to show progress, rather than a spinner with no feedback.

Chart storage. The /public/charts approach works for development. In production, use S3/R2/Vercel Blob with CDN caching.

Error messages. Map DataStoryBot's error responses to user-friendly messages: "Your CSV couldn't be parsed" instead of "422 Unprocessable Entity."

What to Read Next

For the DataStoryBot API fundamentals this portal builds on, see getting started with the DataStoryBot API.

For adding PDF export, read PDF data reports from AI.

For the React integration pattern in more detail, see integrating DataStoryBot into a React application.

For error handling and retry patterns, read error handling and retry patterns for data analysis APIs.

Ready to find your data story?

Upload a CSV and DataStoryBot will uncover the narrative in seconds.

Try DataStoryBot →