Back to blog
Next.jsAIClaudeAnthropicPrismaTutorial

Build an AI Financial Analysis App That Turns Excel into PowerPoint Decks with Next.js

Pawan Sargar June 27, 2026 11 min read

Learn how to build an AI financial analysis app with Next.js, the Anthropic AI SDK, and Prisma. Upload an Excel workbook, chat with your numbers, and export board-ready PowerPoint decks — with every figure grounded in your real data.

Build an AI Financial Analysis App That Turns Excel into PowerPoint Decks with Next.js

Finance teams live in spreadsheets and die in slide decks. Someone uploads a messy .xlsx, someone else spends an evening copy-pasting numbers into PowerPoint, and the analysis itself — the part that actually matters — gets squeezed into whatever time is left.

In this tutorial we'll build an AI financial analysis app that collapses that whole pipeline. A user uploads an Excel workbook, chats with an AI assistant about the numbers, and gets a board-ready PowerPoint deck back — with every figure pulled straight from a database, not hallucinated by the model.

We'll use Next.js 16, the Anthropic AI SDK for tool-calling with Claude, Prisma + Postgres for storage, SheetJS to parse Excel, and PptxGenJS to generate the deck. Tailwind CSS handles the UI.


What We're Building

A single-page app where a user can:

  • Upload an .xls or .xlsx financial workbook
  • Chat with an AI assistant ("What was gross margin in Q3?", "Compare revenue across periods")
  • Ask for a presentation and download a real .pptx deck with charts and tables

The key design rule: the model never invents numbers. It can only read figures through tools that query the database. That single constraint is what makes the output trustworthy enough to put in front of a board.


Architecture at a Glance

Excel upload  ──▶  Parse (SheetJS)  ──▶  Postgres (Prisma)

User chat  ──▶  Claude + tools  ──────────────┤
                  │   getCatalog / getFinancials

              generateDeck  ──▶  PptxGenJS  ──▶  .pptx download

Three API routes do the work: /api/upload (ingest), /api/chat (the AI loop), and /api/deck (render the file). If you want a primer on structuring a Next.js project at this size, see our guide on building scalable web apps.


Prerequisites

  • A Next.js 16 app (npx create-next-app@latest)
  • An Anthropic API key
  • A Postgres database — a free Neon instance works great
  • Node.js 18+

Install the dependencies:

npm install ai @ai-sdk/anthropic @ai-sdk/react zod \
  @prisma/client prisma xlsx pptxgenjs

Add your secrets to .env:

ANTHROPIC_API_KEY=sk-ant-...
DATABASE_URL=postgresql://...

Step 1: Model the Data with Prisma

A financial workbook boils down to line items (revenue, COGS, cash) that have a value for each period (Q1, Q2, FY2025). Two tables capture that cleanly:

model Company {
  id         String     @id @default(cuid())
  name       String
  periodType String
  periods    String[]
  items      LineItem[]
}
 
model LineItem {
  id        String  @id @default(cuid())
  companyId String
  statement String   // "Income Statement", "Balance Sheet"
  ref       String   // stable code, e.g. "3-1"
  label     String   // human label, e.g. "Net Revenue"
  period    String
  value     Float
  company   Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
 
  @@index([companyId, ref])
}

The ref code is the trick that keeps the AI honest later: instead of matching on fuzzy labels, the model references each figure by a stable code. Run npx prisma migrate dev to push the schema. For more on why a clean schema pays off, the Prisma docs are worth a read.


Step 2: Parse the Excel Workbook

Real financial files are messy — header rows in random places, numbers wrapped in parentheses for negatives, currency symbols everywhere. We use SheetJS to read the workbook, then normalize each cell.

import * as XLSX from 'xlsx';
 
export type ParsedRow = {
  statement: string;
  ref: string;
  label: string;
  period: string;
  value: number;
};
 
// "(1,200)" -> -1200,  "$3.5%" -> 3.5
function parseNumber(v: unknown): number | null {
  if (typeof v === 'number') return Number.isFinite(v) ? v : null;
  if (typeof v !== 'string') return null;
  let s = v.trim();
  if (!s) return null;
  let neg = false;
  if (/^\(.*\)$/.test(s)) { neg = true; s = s.slice(1, -1); }
  s = s.replace(/[$,\s%]/g, '');
  if (s.startsWith('-')) { neg = true; s = s.slice(1); }
  if (!/^\d*\.?\d+$/.test(s)) return null;
  const n = parseFloat(s);
  return Number.isFinite(n) ? (neg ? -n : n) : null;
}
 
export function parseWorkbook(data: ArrayBuffer): ParsedRow[] {
  const wb = XLSX.read(data, { type: 'array', cellDates: true });
  const rows: ParsedRow[] = [];
 
  for (const name of wb.SheetNames) {
    const grid = XLSX.utils.sheet_to_json<unknown[]>(wb.Sheets[name], {
      header: 1,
      blankrows: false,
    });
    // ...find the header row, locate the "Ref" and period columns,
    //    then walk each data row and push { statement, ref, label, period, value }
  }
  return rows;
}

The full parser scans the first 15 rows of each sheet for a Ref column, finds the label and period columns relative to it, and skips anything that isn't a valid ref code. The takeaway: do the messy normalization once, at ingest, so everything downstream sees clean rows.


Step 3: The Upload Route

The upload endpoint parses the file, wipes the previous company (single-workbook demo), and bulk-inserts the rows.

import { NextRequest } from 'next/server';
import { prisma } from '@/lib/prisma';
import { parseWorkbook } from '@/lib/parse';
 
export const runtime = 'nodejs';
 
export async function POST(req: NextRequest) {
  const form = await req.formData();
  const file = form.get('file');
  if (!(file instanceof File)) {
    return Response.json({ error: 'Choose an .xls or .xlsx file.' }, { status: 400 });
  }
 
  const rows = parseWorkbook(await file.arrayBuffer());
  if (!rows.length) {
    return Response.json({ error: 'No financial rows found.' }, { status: 422 });
  }
 
  await prisma.company.deleteMany({});
  const company = await prisma.company.create({
    data: { name: file.name.replace(/\.[^.]+$/, ''), periodType: 'period', periods: [] },
  });
  await prisma.lineItem.createMany({
    data: rows.map((r) => ({ ...r, companyId: company.id })),
  });
 
  return Response.json({ ok: true, count: rows.length });
}

Note runtime = 'nodejs' — SheetJS needs Node APIs, so the Edge runtime won't work here.


Step 4: Give Claude Tools, Not Numbers

This is the heart of the app. We use the Anthropic AI SDK with streamText and define three tools. The model can't see the database directly — it can only call these functions, and every figure it states must come back from one of them.

import { anthropic } from '@ai-sdk/anthropic';
import { convertToModelMessages, streamText, stepCountIs, tool, type UIMessage } from 'ai';
import { z } from 'zod';
import { getCatalog, getFinancials } from '@/lib/catalog';
import { resolveDeck } from '@/lib/deck';
 
export const runtime = 'nodejs';
 
export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();
 
  const result = streamText({
    model: anthropic('claude-sonnet-4-6'),
    system: SYSTEM_PROMPT, // "Every number MUST come from getFinancials. Never invent."
    temperature: 0.2,
    messages: await convertToModelMessages(messages),
    stopWhen: stepCountIs(12),
    tools: {
      getCatalog: tool({
        description: 'List statements, ref codes, labels, and periods. Call this first.',
        inputSchema: z.object({}),
        execute: async () => getCatalog(),
      }),
      getFinancials: tool({
        description: 'Fetch exact figures for the given ref codes (and optional periods).',
        inputSchema: z.object({
          refs: z.array(z.string()),
          periods: z.array(z.string()).optional(),
        }),
        execute: async ({ refs, periods }) => getFinancials(refs, periods),
      }),
      generateDeck: tool({
        description: 'Build a PowerPoint deck spec. Server fills exact figures.',
        inputSchema: deckSchema, // headings, narrative, bullets, refs, periods, chart
        execute: async (input) => resolveDeck(input),
      }),
    },
  });
 
  return result.toUIMessageStreamResponse();
}

A few things make this robust:

  • stepCountIs(12) lets Claude chain tool calls — discover the catalog, fetch the numbers, build the deck — in one turn.
  • Low temperature keeps numeric reasoning steady.
  • A strict system prompt tells the model: call getCatalog to map "revenue" → ref code, then getFinancials for the actual values, and never state a figure that didn't come back from a tool.

If you're new to wiring up an AI chat loop in Next.js, our walkthrough on building a chat interface with Vertex AI covers the streaming UI side in detail — the same useChat pattern applies here.


Step 5: The Catalog and Financials Tools

The tools themselves are thin Prisma queries. getCatalog returns what's available; getFinancials returns exact values for specific refs.

export async function getFinancials(refs: string[], periods?: string[]) {
  const company = await prisma.company.findFirst({ orderBy: { id: 'desc' } });
  if (!company) return [];
 
  return prisma.lineItem.findMany({
    where: {
      companyId: company.id,
      ref: { in: refs },
      ...(periods?.length ? { period: { in: periods } } : {}),
    },
    select: { ref: true, label: true, period: true, value: true },
    orderBy: [{ ref: 'asc' }, { period: 'asc' }],
  });
}

Because the model picks the refs and periods from the catalog it just read, there's no fuzzy guessing — the query is exact, and so is the answer.


Step 6: Generate the PowerPoint Deck

When the user asks for a presentation, Claude calls generateDeck with slide headings, a narrative, bullets, and the refs/periods to visualize. The server resolves those refs into real figures and hands a DeckSpec to PptxGenJS, which writes the actual file.

import pptxgen from 'pptxgenjs';
import type { DeckSpec } from '@/lib/deck';
 
export const runtime = 'nodejs';
 
export async function POST(req: Request) {
  const spec = (await req.json()) as DeckSpec;
  const pptx = new pptxgen();
  pptx.layout = 'LAYOUT_WIDE';
 
  for (const s of spec.slides) {
    const slide = pptx.addSlide();
    slide.addText(s.heading, { x: 0.5, y: 0.4, w: 12.3, fontSize: 24, bold: true });
 
    if (s.chart) {
      slide.addChart(
        s.chart.type === 'bar' ? pptx.ChartType.bar : pptx.ChartType.line,
        s.chart.series,
        { x: 6.9, y: 1.5, w: 6, h: 5, showLegend: true },
      );
    } else if (s.table) {
      slide.addTable([s.table.columns, ...s.table.rows], {
        x: 0.5, y: 1.5, w: 12.3, fontSize: 12, autoPage: true,
      });
    }
  }
 
  const buf = (await pptx.write({ outputType: 'nodebuffer' })) as Buffer;
  return new Response(new Uint8Array(buf), {
    headers: {
      'Content-Type':
        'application/vnd.openxmlformats-officedocument.presentationml.presentation',
      'Content-Disposition': 'attachment; filename="deck.pptx"',
    },
  });
}

Charts and tables are built from database figures, not from text the model wrote — so the slide a user downloads is always backed by their real workbook.


Step 7: A Clean Upload UI with Tailwind

The frontend is intentionally simple — a drop zone and a button. Tailwind keeps it tidy:

'use client';
import { useState } from 'react';
 
export function UploadCard() {
  const [busy, setBusy] = useState(false);
 
  async function onFile(file: File) {
    setBusy(true);
    const body = new FormData();
    body.append('file', file);
    await fetch('/api/upload', { method: 'POST', body });
    setBusy(false);
  }
 
  return (
    <label
      className="flex cursor-pointer flex-col items-center justify-center gap-3
                 rounded-2xl border-2 border-dashed border-gray-300 bg-white/60
                 p-10 text-center transition hover:border-emerald-500 hover:bg-emerald-50"
    >
      <span className="text-sm font-medium text-gray-700">
        {busy ? 'Parsing workbook…' : 'Drop an .xlsx file or click to upload'}
      </span>
      <span className="text-xs text-gray-400">Income statement, balance sheet, cash flow</span>
      <input
        type="file"
        accept=".xls,.xlsx"
        className="hidden"
        onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])}
      />
    </label>
  );
}

That's the full loop: upload → parse → store → chat → deck.


Why "Tools, Not Text" Matters

The single most important decision in this build is that the AI never produces a number on its own. It reads a catalog, requests specific figures, and reasons only over what the database returns. This is the difference between a demo that hallucinates a plausible-looking revenue figure and a tool a finance team will actually trust.

This pattern — give the model read-only tools over your real data — generalizes far beyond finance. Any domain where correctness matters (legal, medical, analytics) benefits from the same constraint. If you're applying AI to other media, our post on building an image caption generator with Gemini Vision shows the same "thin API route, smart model" structure for vision.


Going Further

A few natural next steps:

  • Multi-company support — drop the deleteMany and scope queries by a session or user ID.
  • Streaming deck preview — render slides in the browser before download.
  • Computed metrics as tools — add a computeRatio tool so margins and growth are calculated server-side too.
  • Caching — financial queries repeat often; a thin cache layer keeps things snappy. See our notes on web performance optimization.

Wrapping Up

You now have an AI financial analysis app that ingests Excel, answers questions in natural language, and exports board-ready PowerPoint decks — all grounded in real data through tool-calling. The architecture is small on purpose: three API routes, two tables, and a strict system prompt doing the heavy lifting.

The big idea worth taking with you: let the model orchestrate, but let your database own the facts. That separation is what turns a flashy AI demo into something people can actually rely on.

Happy building — and if you ship something with this, I'd love to hear about it.

Written by
Pawan Sargar
Founder & lead developer at Waystoweb Technologies.

Keep reading

Gemini Vision

Build an AI Image Caption Generator with Gemini Vision and Next.js

Learn how to build an AI-powered image caption generator using Google Gemini Vision API and Next.js. Upload any image and get an instant AI-generated description.

Jun 21, 2026 5 min
GCP

Build a Simple AI Chat Interface with GCP Vertex AI and Next.js

Learn how to connect Google Cloud Vertex AI to a Next.js app and build a simple, working chat interface step by step — no fluff, just code.

Jun 20, 2026 6 min
Blogging

Why & How to Start a Blog in 2025?

Learn how & why to start a blog in 2025 and how to make money with it. Blogging is still the best way to make money online, so don't wait learn blogging.

Nov 11, 2025 12 min