Build an AI Financial Analysis App That Turns Excel into PowerPoint Decks with Next.js
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
.xlsor.xlsxfinancial workbook - Chat with an AI assistant ("What was gross margin in Q3?", "Compare revenue across periods")
- Ask for a presentation and download a real
.pptxdeck 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 downloadThree 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 pptxgenjsAdd 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
temperaturekeeps numeric reasoning steady. - A strict system prompt tells the model: call
getCatalogto map "revenue" → ref code, thengetFinancialsfor 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
deleteManyand scope queries by a session or user ID. - Streaming deck preview — render slides in the browser before download.
- Computed metrics as tools — add a
computeRatiotool 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.