AI Meeting Notes Analyzer
Stream AI-extracted action items from raw meeting notes into a database.
Introduction
30 Second Summary
Meeting notes are messy. Everyone leaves a meeting with different takeaways, and the real action items get buried in rambling transcripts.
In this project, you will build a full stack AI Meeting Notes Analyzer that extracts action items, decisions, and deadlines from raw meeting notes. The AI streams its response in real-time, and every analysis gets saved to a database for future reference.
What You'll Build
A Next.js application that uses Google Gemini AI to transform unstructured meeting notes into organized, actionable data with real-time streaming and persistent storage.
By the end of this project, you'll have:
- A streaming AI route handler using the Vercel AI SDK with Google Gemini that extracts structured data from raw meeting text.
- A Neon PostgreSQL database with a Drizzle ORM schema that persists every meeting analysis for future browsing.
- A polished frontend with real-time streaming display showing action items, decisions, and deadlines as the AI generates them.
- Secret Mission: A project tagging system that lets users filter their meeting history by project name.
Are there any prerequisites?
This project assumes familiarity with Next.js, React, and TypeScript. A basic understanding of REST APIs and SQL databases is helpful too.
Before We Start
Before We Start...
Set Up Your Project and Tools
Before you can build the AI analyzer, you need a working development environment with all the right tools connected.
In this step, you'll scaffold a Next.js app, install the AI and database packages, grab your free API keys, and wire everything together so your app can talk to Google Gemini and Neon PostgreSQL.
In this step, get ready to:
- Create a Next.js app and install all dependencies.
- Get your Google Gemini API key and Neon database connection string.
- Configure Drizzle ORM to connect to your Neon database.
Create a Next.js app and install dependencies
Your project needs Node.js installed to run the Next.js development server and manage packages.
- Check that Node.js is installed by running this command:
node --version
✔️ I see a version number
You're all set. Move on to the next instruction below.
ⓧ Command not found
You need to install Node.js first.
- Download and install Node.js from nodejs.org (choose the LTS version).
- Once the installer finishes, close and reopen your terminal.
- Run node --version again to confirm it works.
- Open your terminal.
How to open a terminal
Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar. Type Terminal (macOS) or PowerShell (Windows), then press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js app by running this command:
npx create-next-app@latest meeting-analyzer
What will the wizard ask?
This command launches an interactive wizard that asks you a series of questions about how to configure your project. Answer each prompt as shown in the list below.
- Enter Yes for the TypeScript prompt.
- Enter Yes for the ESLint prompt.
- Enter Yes for the Tailwind CSS prompt.
- Enter Yes for the src/ directory prompt.
- Enter Yes for the App Router prompt.
- Enter Yes for the Turbopack prompt.
- Enter No for the customize import alias prompt (keep the default @/*).
The wizard creates your project and installs the base Next.js dependencies. This takes about a minute.
- Move into your new project folder by running:
cd meeting-analyzer
Now you need to install the AI and database packages that your project depends on.
- Install the Vercel AI SDK packages by running:
npm install ai @ai-sdk/google @ai-sdk/react
What are these packages?
The ai package provides the core streaming functions like streamText. The @ai-sdk/google package connects the SDK to Google Gemini. The @ai-sdk/react package gives you React hooks like useChat for handling streaming on the frontend.
- Install the Drizzle ORM and Neon database packages by running:
npm install drizzle-orm @neondatabase/serverless
- Install drizzle-kit as a development dependency by running:
npm install -D drizzle-kit
Why is drizzle-kit a dev dependency?
The drizzle-kit package is a CLI tool you only use during development to push schema changes to your database. It never runs in production, so it belongs in devDependencies.
- Confirm the packages installed by running:
ls node_modules/ai node_modules/@ai-sdk node_modules/drizzle-orm
You should see file listings for each folder without any errors.
Seeing 'No such file or directory'?
Make sure you're inside the meeting-analyzer folder. Run pwd to check your current directory. If the install command itself failed, re-run it and check for network errors.
Help me debug my npm install issue
Get your API keys and create the environment file
Your app needs two secrets to function: an API key for Google Gemini (so the AI can analyze text) and a database connection string for Neon (so the app can save results).
Both services have free tiers, so this costs you nothing.
- Open Google AI Studio in your browser and sign in with your Google account.
- Click Get API key in the left sidebar.
- Click Create API key.
- Select a Google Cloud project (or let it create one for you) and click Create API key in existing project.
- Copy the generated API key and save it somewhere safe (like a notes app). You'll paste it into a file shortly.
What is Google AI Studio?
Google AI Studio is a web-based tool where you can create API keys, test prompts, and manage access to Google's Gemini models. The free tier gives you access to Gemini 2.5 Flash with no credit card required.
Now grab your Neon database connection string.
- Open the Neon Console in your browser and sign up or sign in.
- Click Create Project.
- Give your project a name (for example, meeting-analyzer) and click Create Project.
- On your project dashboard, click the Connect button.
- Copy the connection string shown in the modal. It looks like postgresql://user:password@hostname/neondb?sslmode=require.
What is Neon?
Neon is a serverless PostgreSQL database designed for modern apps. It scales to zero when idle (so you're never paying for an inactive database) and comes with a free tier that includes 0.5 GB of storage.
Now store both secrets in an environment file that Next.js reads automatically.
- In the root of your meeting-analyzer folder, create a file called .env.local by running:
touch .env.local
- Open .env.local in your code editor and paste the following, replacing the placeholder values with your actual keys:
GOOGLE_GENERATIVE_AI_API_KEY=your_gemini_api_key_here
DATABASE_URL=your_neon_connection_string_here
Why these exact variable names?
The @ai-sdk/google package automatically reads from GOOGLE_GENERATIVE_AI_API_KEY. Drizzle reads from DATABASE_URL. Using these exact names means zero extra configuration code.
- Save .env.local.
- Confirm the file exists by running:
ls .env.local
You should see the filename printed without errors.
Don't see the file?
Files starting with a dot are hidden by default. If you used a GUI file manager, make sure hidden files are visible. In your terminal, the ls command above works regardless of hidden-file settings.
Help me find my .env.local file
Set up the Drizzle configuration and database connection
Drizzle needs two files to work: a config file that tells drizzle-kit where to find your schema, and a connection module that your app imports when it needs to query the database.
- Create a file called drizzle.config.ts in the root of your meeting-analyzer folder (next to package.json).
- Paste the following code into drizzle.config.ts:
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
What does this config do?
This tells drizzle-kit where your schema lives (./src/db/schema.ts), where to output migration files (./drizzle), that you're using PostgreSQL, and how to connect using your DATABASE_URL environment variable.
- Save drizzle.config.ts.
Now create the database connection module your app will import.
- Create a db folder inside src by running:
mkdir -p src/db
- Create a file called index.ts inside src/db and paste the following code:
import { drizzle } from 'drizzle-orm/neon-http';
export const db = drizzle(process.env.DATABASE_URL!);
What does this file do?
This file creates a single database connection instance using Drizzle's Neon HTTP driver. Any part of your app that needs to query the database imports db from this file.
The HTTP driver is ideal for serverless environments like Next.js API routes because it doesn't need a persistent WebSocket connection.
- Save src/db/index.ts.
- Confirm your folder structure looks correct by running:
ls src/db
You should see index.ts listed in the output.
Seeing an error or empty output?
Make sure you ran mkdir -p src/db from the root of the meeting-analyzer folder. Check that you saved the file as index.ts (not index.tsx or another name).
Help me set up my src/db folder structure
✔️ Awesome, I've got everything!
Double check you've saved all your files before moving on.
ⓧ I'd like to double check the full code
Here are the three files you should have created in this step. Make sure yours match exactly.
GOOGLE_GENERATIVE_AI_API_KEY=your_gemini_api_key_here
DATABASE_URL=your_neon_connection_string_here
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
import { drizzle } from 'drizzle-orm/neon-http';
export const db = drizzle(process.env.DATABASE_URL!);
Your environment is fully configured. Next up, you'll design the database schema that stores your meeting analyses.
Design and Push Your Database Schema
Your app can extract structured data from meeting notes, but without a database, every analysis disappears the moment the user closes the tab. You need a place to store the AI's output so users can browse their history later.
In this step, you'll define a database schema using Drizzle ORM and push it to your Neon PostgreSQL database. The schema describes exactly what shape your meeting data takes: a title, raw notes, and the structured fields the AI will fill in.
In this step, get ready to:
- Define a meetings table schema with columns for structured AI output.
- Push the schema to your Neon database and verify it in the Neon Console.
Create the meetings table schema
A database schema is a blueprint that tells your database what columns to create and what type of data each column holds. Your meetings table needs to store both the raw input (meeting notes) and the structured output the AI extracts (summary, action items, decisions, deadlines).
- Create a new file called schema.ts inside your src/db folder (the same folder where index.ts already lives).
- Paste the following code into src/db/schema.ts:
import { pgTable, serial, text, timestamp, json } from 'drizzle-orm/pg-core';
// Define the meetings table schema
export const meetings = pgTable('meetings', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
rawNotes: text('raw_notes').notNull(),
summary: text('summary'),
actionItems: json('action_items').$type<string[]>(),
decisions: json('decisions').$type<string[]>(),
deadlines: json('deadlines').$type<string[]>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
What does this code do?
This defines a single meetings table with eight columns:
- id is an auto-incrementing primary key.
- title and rawNotes store the human-provided input. Both are required (notNull).
- summary, actionItems, decisions, and deadlines store the AI's structured output. They're nullable because they start empty until the AI fills them in.
- The json columns use .$type<string[]>() to tell TypeScript the JSON data is an array of strings.
- createdAt automatically records when each analysis was saved.
Why JSON columns instead of separate tables?
You could normalize action items, decisions, and deadlines into their own tables with foreign keys. That's the textbook relational approach.
JSON columns are a pragmatic shortcut here. Each meeting's structured output is always read and written as a whole unit. Storing them as JSON arrays keeps the schema simple and the queries fast for this use case.
- Save src/db/schema.ts.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete src/db/schema.ts file:
import { pgTable, serial, text, timestamp, json } from 'drizzle-orm/pg-core';
export const meetings = pgTable('meetings', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
rawNotes: text('raw_notes').notNull(),
summary: text('summary'),
actionItems: json('action_items').$type<string[]>(),
decisions: json('decisions').$type<string[]>(),
deadlines: json('deadlines').$type<string[]>(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
Push the schema to your Neon database
Your schema file is just TypeScript code on your machine. The actual database doesn't know about it yet. You need to push the schema to Neon so it creates the real meetings table with all your columns.
- Push your schema to the Neon database by running this command in your terminal:
npx drizzle-kit push
What does this command do?
drizzle-kit push reads your schema file and your database connection string from drizzle.config.ts. It then connects to your Neon database and creates (or updates) the tables to match your schema definition. No migration files needed.
You should see output indicating that the meetings table was created successfully.
Seeing a connection error?
- Check that your DATABASE_URL in .env.local is the full connection string from the Neon Console (it starts with postgresql://).
- Make sure drizzle.config.ts is in the root of your project (not inside src/).
- Confirm the schema path in your config (./src/db/schema.ts) matches the actual file location.
Help me debug my drizzle-kit push error
- Open the Neon Console in your browser.
- Select your project, then click Tables in the left sidebar.
- You should see the meetings table listed with all eight columns: id, title, raw_notes, summary, action_items, decisions, deadlines, and created_at.
Your database is ready to store meeting analyses. Next up, you'll build the AI route handler that actually extracts structured data from raw meeting notes and streams it back to the user in real-time.
Build the AI Streaming Route Handler
Your database is ready and waiting for meeting data. Now you need the brains of the operation: an AI route that receives raw meeting notes and streams back structured, actionable output in real-time.
In this step, you will create two API route handlers. The first uses Vercel AI SDK with Google Gemini to stream AI-generated analysis. The second handles saving and retrieving meeting records from your database.
In this step, get ready to:
- Create a streaming AI route that extracts structured data from meeting notes.
- Build a meetings API route for saving and fetching analysis history.
Create the AI streaming route
Next.js App Router lets you define API endpoints as files inside the app/api directory. Each route.ts file exports HTTP method handlers like POST or GET.
- Create a new folder path inside your project: app/api/analyze/.
- Inside the analyze folder, create a new file called route.ts.
- Paste the following code into app/api/analyze/route.ts:
import { streamText } from 'ai';
import { google } from '@ai-sdk/google';
export const maxDuration = 30;
const systemPrompt = `You are a meeting notes analyzer. Given raw meeting notes or a transcript, extract and return the following sections in this exact format:
## Summary
A 2-3 sentence summary of what was discussed.
## Action Items
- [Assignee]: Task description
## Decisions
- Decision that was made
## Deadlines
- [Date]: What is due
If a section has no relevant content, write "None identified."`;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: google('gemini-2.5-flash'),
system: systemPrompt,
messages,
});
return result.toUIMessageStreamResponse();
}
What does this code do?
This route handler receives meeting notes from the frontend and streams AI-generated analysis back in real-time.
- maxDuration = 30 tells the serverless runtime to allow up to 30 seconds for the AI to finish responding.
- systemPrompt instructs Gemini to always return output in a consistent markdown format with four sections: Summary, Action Items, Decisions, and Deadlines.
- streamText() from the AI SDK calls Gemini and returns a streaming response object.
- toUIMessageStreamResponse() converts the stream into a format the useChat hook on the frontend can consume.
Why streaming instead of waiting for the full response?
AI models can take several seconds to generate a full response. Streaming sends each chunk of text to the browser as soon as it is generated. The user sees results appearing in real-time instead of staring at a loading spinner.
- Save app/api/analyze/route.ts.
You will not see a visible change yet. This route needs a frontend to call it, which you will build in the next step.
Seeing a red underline on the imports?
Make sure you installed the ai and @ai-sdk/google packages in Step 1. If VS Code is still showing errors, try restarting the TypeScript server by pressing Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and selecting TypeScript: Restart TS Server.
help me fix import errors in my AI route handler
Create the meetings API route
The analyze route handles the AI side. You also need a route for the database side: saving completed analyses and fetching history.
- Create a new folder path: app/api/meetings/.
- Inside the meetings folder, create a new file called route.ts.
- Paste the following code into app/api/meetings/route.ts:
import { db } from '@/db/index';
import { meetings } from '@/db/schema';
import { desc } from 'drizzle-orm';
import { NextResponse } from 'next/server';
// GET: Fetch all meetings, newest first
export async function GET() {
const allMeetings = await db
.select()
.from(meetings)
.orderBy(desc(meetings.createdAt));
return NextResponse.json(allMeetings);
}
// POST: Save a new meeting analysis to the database
export async function POST(req: Request) {
const body = await req.json();
const newMeeting = await db
.insert(meetings)
.values({
title: body.title,
rawNotes: body.rawNotes,
summary: body.summary,
actionItems: body.actionItems,
decisions: body.decisions,
deadlines: body.deadlines,
})
.returning();
return NextResponse.json(newMeeting[0]);
}
What does this code do?
This file exports two handlers that connect to your Neon database via Drizzle ORM.
- GET fetches all meeting records from the database, ordered by createdAt descending so the newest appears first.
- POST receives the parsed analysis (title, raw notes, summary, action items, decisions, deadlines) and inserts it as a new row.
- .returning() tells Drizzle to return the newly inserted record, including its auto-generated id and createdAt values.
- Save app/api/meetings/route.ts.
Like the analyze route, this route will not show visible output until the frontend calls it. You will test both routes end-to-end after building the UI.
Red underline on the @/db imports?
The @/ path alias is configured by create-next-app in your tsconfig.json. Make sure your database files are inside src/db/ (not just db/ at the project root). Check that tsconfig.json has a paths entry mapping @/* to ./src/*.
help me fix my path alias issues
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for both API route files.
import { streamText } from 'ai';
import { google } from '@ai-sdk/google';
export const maxDuration = 30;
const systemPrompt = `You are a meeting notes analyzer. Given raw meeting notes or a transcript, extract and return the following sections in this exact format:
## Summary
A 2-3 sentence summary of what was discussed.
## Action Items
- [Assignee]: Task description
## Decisions
- Decision that was made
## Deadlines
- [Date]: What is due
If a section has no relevant content, write "None identified."`;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: google('gemini-2.5-flash'),
system: systemPrompt,
messages,
});
return result.toUIMessageStreamResponse();
}
import { db } from '@/db/index';
import { meetings } from '@/db/schema';
import { desc } from 'drizzle-orm';
import { NextResponse } from 'next/server';
export async function GET() {
const allMeetings = await db
.select()
.from(meetings)
.orderBy(desc(meetings.createdAt));
return NextResponse.json(allMeetings);
}
export async function POST(req: Request) {
const body = await req.json();
const newMeeting = await db
.insert(meetings)
.values({
title: body.title,
rawNotes: body.rawNotes,
summary: body.summary,
actionItems: body.actionItems,
decisions: body.decisions,
deadlines: body.deadlines,
})
.returning();
return NextResponse.json(newMeeting[0]);
}
Your backend is complete. You have an AI route that streams structured analysis and a meetings route that handles persistence. Next up, you will build the frontend that ties it all together with a real-time streaming display.
Build the Frontend with Streaming Display
Your API routes can receive meeting notes and stream back structured analysis. The backend is ready, but users have no way to interact with it yet.
In this step, you'll build two pages. The main page lets users paste meeting notes and watch the AI analyze them in real-time. A history page displays all past analyses pulled from the database.
In this step, get ready to:
- Create the main analysis page with real-time AI streaming.
- Test the streaming flow with sample meeting notes.
- Build a history page for browsing past analyses.
Create the main analysis page
The Vercel AI SDK provides a useChat hook that handles all the complexity of streaming AI responses. It manages message state, form submission, and real-time text updates with just a few lines of code.
- Open app/page.tsx in your editor and replace its entire contents with the following code:
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
export default function Home() {
// Track the meeting title and whether the analysis has been saved
const [title, setTitle] = useState('');
const [saved, setSaved] = useState(false);
// useChat connects to your /api/analyze route and handles streaming
const { messages, input, handleInputChange, handleSubmit, isLoading } =
useChat({
api: '/api/analyze',
});
// Find the AI's response in the messages array
const assistantMessage = messages.find((m) => m.role === 'assistant');
// Parse the AI's markdown response and save structured data to the database
async function saveAnalysis() {
if (!assistantMessage) return;
const content = assistantMessage.parts
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('');
// Use regex to extract each section from the AI's formatted response
const summaryMatch = content.match(
/## Summary\n([\s\S]*?)(?=\n## Action Items)/
);
const actionItemsMatch = content.match(
/## Action Items\n([\s\S]*?)(?=\n## Decisions)/
);
const decisionsMatch = content.match(
/## Decisions\n([\s\S]*?)(?=\n## Deadlines)/
);
const deadlinesMatch = content.match(/## Deadlines\n([\s\S]*?)$/);
// Convert markdown list items into an array of strings
const parseList = (text: string | undefined) => {
if (!text) return [];
return text
.split('\n')
.filter((line) => line.startsWith('- '))
.map((line) => line.slice(2));
};
await fetch('/api/meetings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title || 'Untitled Meeting',
rawNotes: input,
summary: summaryMatch?.[1]?.trim() || '',
actionItems: parseList(actionItemsMatch?.[1]),
decisions: parseList(decisionsMatch?.[1]),
deadlines: parseList(deadlinesMatch?.[1]),
}),
});
setSaved(true);
}
return (
<main className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">AI Meeting Notes Analyzer</h1>
<div className="mb-4">
<input
type="text"
placeholder="Meeting title (optional)"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg"
/>
</div>
<form onSubmit={handleSubmit} className="mb-6">
<textarea
value={input}
onChange={handleInputChange}
placeholder="Paste your meeting notes here..."
rows={8}
className="w-full p-3 border border-gray-300 rounded-lg mb-3"
/>
<button
type="submit"
disabled={isLoading || !input}
className="bg-blue-600 text-white px-6 py-2 rounded-lg disabled:opacity-50"
>
{isLoading ? 'Analyzing...' : 'Analyze Notes'}
</button>
</form>
{assistantMessage && (
<div className="bg-gray-50 p-6 rounded-lg border">
<h2 className="text-xl font-semibold mb-4">Analysis Result</h2>
<div className="prose max-w-none whitespace-pre-wrap">
{assistantMessage.parts
.filter((p) => p.type === 'text')
.map((p, i) => (
<span key={i}>{p.text}</span>
))}
</div>
{!saved && !isLoading && (
<button
onClick={saveAnalysis}
className="mt-4 bg-green-600 text-white px-4 py-2 rounded-lg"
>
Save to History
</button>
)}
{saved && (
<p className="mt-4 text-green-600 font-medium">
Saved to history!
</p>
)}
</div>
)}
<div className="mt-6">
<a href="/history" className="text-blue-600 hover:underline">
View Analysis History →
</a>
</div>
</main>
);
}
What does this code do?
This is a client component ('use client') because it uses React hooks and handles user interaction in the browser.
- useChat connects to your /api/analyze route and manages message state, streaming, and form submission automatically.
- assistantMessage.parts renders the AI's response as it streams in. Each text part updates live, so the user sees words appearing in real-time.
- saveAnalysis() uses regex to parse the AI's markdown-formatted response into separate sections (Summary, Action Items, Decisions, Deadlines), then POSTs the structured data to /api/meetings.
- The button disables itself while the AI is streaming (isLoading) or when the textarea is empty, preventing duplicate submissions.
- Save app/page.tsx.
- Start your dev server by running:
npm run dev
- Open http://localhost:3000 in your browser. You should see a heading that says "AI Meeting Notes Analyzer", a title input, a large textarea, and an "Analyze Notes" button.
Don't see the page?
- Check your terminal for compilation errors. A missing import or typo in the JSX will prevent the page from rendering.
- Make sure the file is saved as app/page.tsx (not src/app/page.tsx or pages/page.tsx).
- If you see a blank white page, open your browser's developer console (F12) and check for JavaScript errors.
Help me debug my Next.js page not rendering
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
export default function Home() {
const [title, setTitle] = useState('');
const [saved, setSaved] = useState(false);
const { messages, input, handleInputChange, handleSubmit, isLoading } =
useChat({
api: '/api/analyze',
});
const assistantMessage = messages.find((m) => m.role === 'assistant');
async function saveAnalysis() {
if (!assistantMessage) return;
const content = assistantMessage.parts
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('');
const summaryMatch = content.match(
/## Summary\n([\s\S]*?)(?=\n## Action Items)/
);
const actionItemsMatch = content.match(
/## Action Items\n([\s\S]*?)(?=\n## Decisions)/
);
const decisionsMatch = content.match(
/## Decisions\n([\s\S]*?)(?=\n## Deadlines)/
);
const deadlinesMatch = content.match(/## Deadlines\n([\s\S]*?)$/);
const parseList = (text: string | undefined) => {
if (!text) return [];
return text
.split('\n')
.filter((line) => line.startsWith('- '))
.map((line) => line.slice(2));
};
await fetch('/api/meetings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title || 'Untitled Meeting',
rawNotes: input,
summary: summaryMatch?.[1]?.trim() || '',
actionItems: parseList(actionItemsMatch?.[1]),
decisions: parseList(decisionsMatch?.[1]),
deadlines: parseList(deadlinesMatch?.[1]),
}),
});
setSaved(true);
}
return (
<main className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">AI Meeting Notes Analyzer</h1>
<div className="mb-4">
<input
type="text"
placeholder="Meeting title (optional)"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg"
/>
</div>
<form onSubmit={handleSubmit} className="mb-6">
<textarea
value={input}
onChange={handleInputChange}
placeholder="Paste your meeting notes here..."
rows={8}
className="w-full p-3 border border-gray-300 rounded-lg mb-3"
/>
<button
type="submit"
disabled={isLoading || !input}
className="bg-blue-600 text-white px-6 py-2 rounded-lg disabled:opacity-50"
>
{isLoading ? 'Analyzing...' : 'Analyze Notes'}
</button>
</form>
{assistantMessage && (
<div className="bg-gray-50 p-6 rounded-lg border">
<h2 className="text-xl font-semibold mb-4">Analysis Result</h2>
<div className="prose max-w-none whitespace-pre-wrap">
{assistantMessage.parts
.filter((p) => p.type === 'text')
.map((p, i) => (
<span key={i}>{p.text}</span>
))}
</div>
{!saved && !isLoading && (
<button
onClick={saveAnalysis}
className="mt-4 bg-green-600 text-white px-4 py-2 rounded-lg"
>
Save to History
</button>
)}
{saved && (
<p className="mt-4 text-green-600 font-medium">
Saved to history!
</p>
)}
</div>
)}
<div className="mt-6">
<a href="/history" className="text-blue-600 hover:underline">
View Analysis History →
</a>
</div>
</main>
);
}
Test the streaming analysis
The page is rendering, but you haven't seen the streaming magic yet. Time to feed it some meeting notes and watch the AI work in real-time.
- Type Sprint Planning into the meeting title field at the top.
- Paste the following sample meeting notes into the textarea:
Team standup March 15. Present: Sarah, Mike, Lisa.
Sarah said the API integration is done and ready for review.
Mike mentioned he's blocked on the database migration, needs help from DevOps.
Lisa will handle the frontend testing by end of week.
We decided to push the release to next Tuesday instead of Friday.
Mike needs to finish migration by Monday EOD.
Lisa's tests need to pass before Tuesday morning.
- Click Analyze Notes.
You should see text appearing word-by-word in the "Analysis Result" section below the form. The AI streams its response in real-time, organizing the raw notes into Summary, Action Items, Decisions, and Deadlines sections.
How does the streaming work?
When you click the button, useChat sends the textarea content to your /api/analyze route as a message. Your route calls streamText which asks Google Gemini to generate a response.
Instead of waiting for the full response, Gemini sends tokens back one chunk at a time. The useChat hook updates messages on every chunk, causing React to re-render and display the new text instantly.
- Once the streaming finishes, click the green Save to History button that appears below the result.
You should see a green "Saved to history!" confirmation message appear in place of the button.
Seeing an error when you click Analyze?
- Check your terminal for a GOOGLE_GENERATIVE_AI_API_KEY error. Make sure your .env.local file has the correct key and you restarted the dev server after adding it.
- If you see a 404 error, make sure app/api/analyze/route.ts exists and exports a POST function.
- If the save button does nothing, check the browser console for fetch errors. Your DATABASE_URL might be incorrect or your Neon database might be paused (open the Neon Console to wake it).
Help me debug my streaming AI route
Build the meeting history page
You just saved an analysis to the database. Now you need a page where users can browse all their past analyses. This page uses a Next.js server component, which means it fetches data directly on the server without sending any database credentials to the browser.
- Create a new folder called history inside your app folder.
- Create a new file called page.tsx inside app/history/ and paste the following code:
import { db } from '@/db/index';
import { meetings } from '@/db/schema';
import { desc } from 'drizzle-orm';
// Force dynamic rendering so fresh data loads on every visit
export const dynamic = 'force-dynamic';
export default async function HistoryPage() {
// Query all meetings from the database, newest first
const allMeetings = await db
.select()
.from(meetings)
.orderBy(desc(meetings.createdAt));
return (
<main className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Meeting History</h1>
<a href="/" className="text-blue-600 hover:underline mb-4 block">
← Back to Analyzer
</a>
{allMeetings.length === 0 ? (
<p className="text-gray-500">No meetings analyzed yet.</p>
) : (
<div className="space-y-4">
{allMeetings.map((meeting) => (
<div
key={meeting.id}
className="border rounded-lg p-4 bg-white shadow-sm"
>
<div className="flex justify-between items-start mb-2">
<h2 className="text-lg font-semibold">{meeting.title}</h2>
<span className="text-sm text-gray-500">
{new Date(meeting.createdAt).toLocaleDateString()}
</span>
</div>
{meeting.summary && (
<p className="text-gray-700 mb-2">{meeting.summary}</p>
)}
{meeting.actionItems && (meeting.actionItems as string[]).length > 0 && (
<div className="mt-2">
<h3 className="font-medium text-sm text-gray-600">
Action Items:
</h3>
<ul className="list-disc list-inside text-sm">
{(meeting.actionItems as string[]).map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
)}
</main>
);
}
What does this code do?
- This is an async server component. It runs entirely on the server, so it can query the database directly without an API call.
- export const dynamic = 'force-dynamic' tells Next.js to fetch fresh data on every page load instead of caching stale results.
- desc(meetings.createdAt) orders results newest-first so the most recent analysis always appears at the top.
- Each meeting card shows its title, date, summary, and action items. The conditional rendering (&& (meeting.actionItems as string[]).length > 0) hides sections when there's no data.
- Save app/history/page.tsx.
- In your browser, navigate to http://localhost:3000/history. You should see the "Sprint Planning" meeting you saved earlier with its summary and action items listed.
History page showing "No meetings analyzed yet"?
- Go back to the main page and analyze some notes again. Make sure you click Save to History after the streaming completes and see the green "Saved to history!" message.
- Check your terminal for database connection errors. Your Neon database may have paused due to inactivity. Open the Neon Console to confirm it's active.
- Verify that DATABASE_URL in your .env.local starts with postgresql:// and matches the string from the Neon Console.
Help me debug my history page database query
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import { db } from '@/db/index';
import { meetings } from '@/db/schema';
import { desc } from 'drizzle-orm';
export const dynamic = 'force-dynamic';
export default async function HistoryPage() {
const allMeetings = await db
.select()
.from(meetings)
.orderBy(desc(meetings.createdAt));
return (
<main className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Meeting History</h1>
<a href="/" className="text-blue-600 hover:underline mb-4 block">
← Back to Analyzer
</a>
{allMeetings.length === 0 ? (
<p className="text-gray-500">No meetings analyzed yet.</p>
) : (
<div className="space-y-4">
{allMeetings.map((meeting) => (
<div
key={meeting.id}
className="border rounded-lg p-4 bg-white shadow-sm"
>
<div className="flex justify-between items-start mb-2">
<h2 className="text-lg font-semibold">{meeting.title}</h2>
<span className="text-sm text-gray-500">
{new Date(meeting.createdAt).toLocaleDateString()}
</span>
</div>
{meeting.summary && (
<p className="text-gray-700 mb-2">{meeting.summary}</p>
)}
{meeting.actionItems && (meeting.actionItems as string[]).length > 0 && (
<div className="mt-2">
<h3 className="font-medium text-sm text-gray-600">
Action Items:
</h3>
<ul className="list-disc list-inside text-sm">
{(meeting.actionItems as string[]).map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
)}
</main>
);
}
Your frontend is fully functional. Users can paste meeting notes, watch the AI analyze them in real-time, save results, and browse their history. Next up, you'll connect everything end-to-end and run a complete test of the full workflow.
Connect Everything and Test End-to-End
You have all the pieces in place: a streaming AI route, a database API, a frontend with real-time display, and a history page.
Now it is time to see everything work together. In this step, you will run your app and test the complete flow from pasting raw meeting notes to seeing structured results saved in your database.
In this step, get ready to:
- Understand how the save functionality parses streamed AI output into structured data.
- Test the full end-to-end flow with sample meeting notes.
Understand how the save functionality works
Your saveAnalysis function in app/page.tsx handles the bridge between raw AI streaming output and your structured database. It does three things in sequence.
How does saveAnalysis parse the AI output?
The AI returns its analysis as markdown with headings like ## Summary and ## Action Items. Your function uses regex to extract each section into its own variable.
The parseList helper splits bullet-point sections into arrays by filtering for lines that start with - . The result is a clean JSON payload with title, rawNotes, summary, actionItems, decisions, and deadlines that gets POSTed to your /api/meetings endpoint.
This is an unstructured-to-structured data pipeline. Raw text goes in, and typed JSON comes out, ready for database storage.
- Start your development server by running this command:
npm run dev
- Open http://localhost:3000 in your browser.
You should see the AI Meeting Notes Analyzer page with a title input, a large textarea, and an Analyze Notes button.
Page not loading?
Check your terminal for compilation errors. The most common issue is a missing import or a typo in a file path like @/db/index. Make sure your src/db/ folder exists with both index.ts and schema.ts.
Help me debug my Next.js app that won't compile
Test the full flow with sample meeting notes
With the app running, you can now test the complete pipeline. You will paste sample meeting notes, watch the AI extract structured data in real-time, save the result, and verify it appears in your history.
- Type Weekly Standup into the Meeting title input field.
- Copy and paste the following sample meeting notes into the textarea:
Meeting started at 10am. Attendees: Sarah, Mike, and Priya.
Sarah mentioned the API redesign is almost done and she'll have the PR ready by Friday. Mike said the frontend tests are failing because of a dependency update - he'll fix it today. Priya raised that we need to decide on the deployment strategy before next sprint.
We decided to go with blue-green deployments for the new service. Sarah will document the API changes by Thursday. Mike will pair with Priya on the deployment setup next Monday.
Next meeting is Wednesday at 10am.
- Click Analyze Notes.
You should see text appearing word-by-word in the Analysis Result section below the form. The AI streams its response in real-time, organized into Summary, Action Items, Decisions, and Deadlines sections.
What should the streaming output look like?
The AI produces structured markdown. You should see headings like ## Summary followed by a brief paragraph, then ## Action Items with bullet points listing assignees and tasks.
The exact wording varies each time (AI is non-deterministic), but the structure remains consistent thanks to your system prompt.
- Wait for the streaming to finish. A green Save to History button appears once the AI is done.
- Click Save to History.
You should see a green "Saved to history!" confirmation message replace the button.
- Click the View Analysis History link at the bottom of the page.
The history page at /history should display your "Weekly Standup" meeting with today's date, the summary paragraph, and a list of action items extracted by the AI.
Save button not appearing?
The Save to History button only shows when streaming is complete and the analysis has not already been saved. If you see the button is disabled, make sure the AI has finished streaming (the button text changes from "Analyzing..." back to "Analyze Notes").
If clicking the button does nothing, open your browser's Developer Console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and look for network errors on the /api/meetings POST request.
Help me debug why my meeting is not saving to the database
History page showing "No meetings analyzed yet"?
Make sure your DATABASE_URL in .env.local is correct. You can verify the data saved by checking the Neon Console. Open your project, select the Tables tab, and look for rows in the meetings table.
Help me troubleshoot my Neon database connection in Next.js
What did you just prove?
You have a working unstructured-to-structured data pipeline. Raw meeting text goes into Google Gemini, comes back as structured markdown via streaming, gets parsed into typed JSON, and persists in PostgreSQL.
This is the exact pattern behind tools like Notion AI, Otter.ai, and Fireflies. You built it from scratch.
Your AI Meeting Notes Analyzer is fully functional. Next up, the Secret Mission challenges you to add project tagging so you can filter meetings by team or project.
Secret mission
Add Project Tagging
Your meeting analyzer works great, but as your history grows, finding a specific meeting becomes painful. What if you could tag each analysis with a project name and filter your history by project?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses Google Gemini's free tier and Neon's free tier, so there are no ongoing costs.
Resources you used:
- Next.js project files on your local machine.
- Google Gemini API key (free tier, no billing attached).
- Neon PostgreSQL database with a meetings table (free tier, scales to zero automatically).
- Node.js dependencies installed in your project folder.
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend the project with the Secret Mission.
- Your Neon database stays active on the free tier and scales to zero after 5 minutes of inactivity.
- Your Google Gemini API key remains valid with free-tier rate limits.
- Your local project files stay on your Desktop ready to run.
Pause - I'll come back to this later
Shut down your local dev server and leave cloud resources in place. Both Neon and Gemini free tiers cost nothing when idle.
- Stop your Next.js dev server by pressing Ctrl+C in the terminal.
- Your Neon database will automatically scale to zero after 5 minutes of inactivity.
- No need to revoke your Gemini API key. It remains usable when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to fully clean up.
- Delete your local project folder from your Desktop.
- In the Neon Console, select your project and click Settings, then Delete Project to remove your database.
- In Google AI Studio, navigate to your API keys and delete the key you created for this project.
- Delete the .env.local file if you copied it anywhere outside the project folder.
Nice Work!
Nice Work!
You just built a full stack AI-powered tool that goes way beyond basic CRUD. You now have a working application that transforms raw meeting chaos into structured, actionable output in real-time.
Here's what you accomplished:
- Built a streaming AI integration using Vercel AI SDK with Google Gemini to extract structured data from unstructured meeting notes.
- Designed an unstructured-to-structured data pipeline that parses raw text into action items, decisions, deadlines, and summaries.
- Created a Neon PostgreSQL schema with Drizzle ORM for persistent storage and built a history page to browse past analyses.
- Added project tagging and filtering so users can organize their meeting history by project name.
Ready to quiz yourself?