Build an AI Meeting Notes SaaS with Next.js and PostgreSQL
Build and deploy an AI meeting notes SaaS with Next.js, PostgreSQL and the OpenAI API. Store transcripts, generate structured summaries and keep costs predictable on a small VPS.
Build a focused meeting-notes SaaS that accepts a transcript, generates structured notes with an LLM and stores the result for later retrieval. This MVP deliberately starts with text transcripts; audio transcription can be added after the workflow is reliable. Verified 2026-08-23.
What we’re building
The finished MVP lets a signed-in user:
- Paste a meeting transcript and title.
- Generate summary, decisions, action items and follow-up questions.
- Store the original transcript and generated notes in PostgreSQL.
- Reopen previous meetings through a JSON API.
- Keep the OpenAI key server-side.
- Run the application and database locally with Docker Compose.
It is not a video-conferencing integration or an audio transcription product. That boundary keeps the first version cheap, testable and useful: users can export a transcript from their existing meeting tool and paste it here.
flowchart LR
Browser --> API[Next.js route handler]
API --> DB[(PostgreSQL)]
API --> AI[OpenAI Responses API]
API --> DB
What you’ll learn
- How to design a transcript-to-notes workflow.
- How to validate and persist user input with PostgreSQL.
- How to call an LLM from a server-only Next.js route.
- How to return structured JSON from model output.
- How to deploy a small AI SaaS with Docker Compose and Caddy.
Final architecture
The MVP uses one Next.js process and one PostgreSQL database. The route validates the request, inserts a pending meeting, calls the model, validates the JSON response and updates the meeting. A failed model call marks the record as failed instead of leaving an invisible partial result.
For short transcripts this synchronous flow is sufficient. At larger volumes, move generation to a database-backed worker so an HTTP request is not held open while the model responds.
Prerequisites
- Node.js 22.12 or newer.
- npm 10 or newer.
- Docker Engine and Compose v2.
- PostgreSQL 17, supplied by Compose locally.
- An OpenAI API account and API key.
- A VPS with 2 GB RAM and a DNS record for production.
1. Create the project
npx [email protected] meeting-notes --ts --eslint --app --src-dir=false --use-npm --import-alias '@/*'
cd meeting-notes
npm install pg zod openai
npm install -D @types/pg
mkdir -p app/api/meetings lib migrations
2. Configure PostgreSQL and the OpenAI key
Create .env.example:
DATABASE_URL=postgres://meeting:change_me@db:5432/meeting_notes
OPENAI_API_KEY=your_openai_api_key_here
OPENAI_MODEL=gpt-4o-mini
APP_URL=http://localhost:3000
Copy it locally and replace the database password and API key:
cp .env.example .env
Where to get the API key
- Open the OpenAI Platform dashboard.
- Go to API keys.
- Select Create new secret key.
- Copy the value beginning with
sk-into.env. - Do not commit
.envor put the key in browser code.
3. Create the database schema
Create migrations/001_init.sql:
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE meetings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
transcript TEXT NOT NULL CHECK (length(transcript) BETWEEN 20 AND 100000),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'failed')),
notes JSONB,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX meetings_created_idx ON meetings (created_at DESC);
Apply it locally after PostgreSQL is running:
psql "$DATABASE_URL" -f migrations/001_init.sql
The notes column stores validated JSON rather than arbitrary model text. In a multi-user product, add user_id and authorization checks before exposing records to accounts.
4. Add the database client
Create lib/db.ts:
import { Pool } from 'pg'
const connectionString = process.env.DATABASE_URL
if (!connectionString) throw new Error('DATABASE_URL is not configured')
export const db = new Pool({
connectionString,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
})
Every query uses positional parameters. Never concatenate a title or transcript into SQL.
5. Define the notes contract
Create lib/notes.ts:
import { z } from 'zod'
export const notesSchema = z.object({
summary: z.string().min(1).max(5000),
decisions: z.array(z.string().min(1).max(500)).max(30),
actionItems: z.array(z.object({
task: z.string().min(1).max(500),
owner: z.string().max(120).nullable(),
dueDate: z.string().max(80).nullable(),
})).max(50),
followUpQuestions: z.array(z.string().min(1).max(500)).max(20),
})
export type MeetingNotes = z.infer<typeof notesSchema>
6. Call the model from the server
Create lib/generate-notes.ts:
import OpenAI from 'openai'
import { notesSchema, type MeetingNotes } from './notes'
const apiKey = process.env.OPENAI_API_KEY
const model = process.env.OPENAI_MODEL
if (!apiKey || !model) throw new Error('OpenAI configuration is incomplete')
const openai = new OpenAI({ apiKey })
export async function generateNotes(transcript: string): Promise<MeetingNotes> {
const response = await openai.responses.create({
model,
input: [
{ role: 'system', content: 'Extract meeting notes. Return only JSON with summary, decisions, actionItems and followUpQuestions. Each action item must contain task, owner and dueDate. Use null when the transcript does not identify an owner or date. Do not invent facts.' },
{ role: 'user', content: transcript },
],
})
return notesSchema.parse(JSON.parse(response.output_text))
}
The model call is server-only. The prompt limits invention and Zod rejects malformed output before it reaches PostgreSQL.
7. Build the meeting API
Create app/api/meetings/route.ts:
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { generateNotes } from '@/lib/generate-notes'
const schema = z.object({
title: z.string().trim().min(1).max(200),
transcript: z.string().trim().min(20).max(100000),
})
export async function GET() {
try {
const result = await db.query('SELECT id, title, status, notes, error_message, created_at, updated_at FROM meetings ORDER BY created_at DESC LIMIT 50')
return NextResponse.json({ meetings: result.rows })
} catch {
return NextResponse.json({ error: 'Unable to list meetings' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const body = schema.parse(await request.json())
const inserted = await db.query('INSERT INTO meetings (title, transcript) VALUES ($1, $2) RETURNING id, title, status, created_at', [body.title, body.transcript])
const meeting = inserted.rows[0]
try {
const notes = await generateNotes(body.transcript)
const updated = await db.query('UPDATE meetings SET status = $1, notes = $2::jsonb, updated_at = now() WHERE id = $3 RETURNING id, title, status, notes, created_at, updated_at', ['completed', JSON.stringify(notes), meeting.id])
return NextResponse.json({ meeting: updated.rows[0] }, { status: 201 })
} catch {
await db.query('UPDATE meetings SET status = $1, error_message = $2, updated_at = now() WHERE id = $3', ['failed', 'Notes generation failed', meeting.id])
return NextResponse.json({ error: 'Notes generation failed', meetingId: meeting.id }, { status: 502 })
}
} catch (error) {
if (error instanceof z.ZodError) return NextResponse.json({ error: 'Invalid title or transcript', details: error.flatten() }, { status: 400 })
return NextResponse.json({ error: 'Unable to create meeting' }, { status: 500 })
}
}
Before making this public, add authentication, a user_id column, ownership checks, rate limiting and an asynchronous worker. Otherwise anyone reaching the endpoint can spend your API budget.
8. Run and test locally
docker run --name meeting-postgres --rm -e POSTGRES_USER=meeting -e POSTGRES_PASSWORD=change_me -e POSTGRES_DB=meeting_notes -p 5432:5432 -d postgres:17-alpine
psql "$DATABASE_URL" -f migrations/001_init.sql
npm run dev
Test the API:
curl -X POST http://localhost:3000/api/meetings \
-H 'content-type: application/json' \
-d '{"title":"Product planning","transcript":"The team agreed to ship the import flow on Friday. Alex owns the migration and Sam will write the release notes. We will review support feedback next week."}'
curl http://localhost:3000/api/meetings
Valid input should return 201 and completed notes. Short input returns 400; model or network failure returns 502 and stores a failed record.
9. Containerize the application
Create Dockerfile:
FROM node:22.12-alpine AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:22.12-alpine AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22.12-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder --chown=app:app /app/.next/standalone ./
COPY --from=builder --chown=app:app /app/.next/static ./.next/static
USER app
EXPOSE 3000
CMD ["node", "server.js"]
Set output: 'standalone' in next.config.ts.
Create docker-compose.yml:
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: meeting
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: meeting_notes
volumes:
- postgres_data:/var/lib/postgresql/data
app:
build: .
restart: unless-stopped
depends_on:
- db
environment:
DATABASE_URL: postgres://meeting:${POSTGRES_PASSWORD}@db:5432/meeting_notes
OPENAI_API_KEY: ${OPENAI_API_KEY}
OPENAI_MODEL: ${OPENAI_MODEL}
APP_URL: ${APP_URL}
ports:
- "127.0.0.1:3000:3000"
volumes:
postgres_data:
Production deployment
Use a 2 GB Ubuntu 24.04 VPS: 2 shared vCPU, 40 GB SSD and about 1 TB bandwidth. Typical cost is $5–10/month; verify current official provider pricing before buying. The VPS needs no GPU because inference is remote.
- Create a non-root deployment user.
- Allow SSH, HTTP and HTTPS only.
- Install Docker Engine and Compose v2.
- Copy the project and a protected production
.env. - Start with
docker compose up -d --build. - Apply
migrations/001_init.sqlusing a trustedpsqlclient. - Do not expose PostgreSQL’s port.
Create Caddyfile for HTTPS:
notes.example.com {
reverse_proxy 127.0.0.1:3000
}
Replace the hostname with a domain you control, point DNS to the VPS, allow ports 80/443 and run Caddy on the host. Caddy obtains and renews certificates automatically.
Security considerations
- Keep API and database keys server-side and out of Git.
- Add authentication, ownership checks and rate limiting before launch.
- Limit transcript size and provider spend.
- Redact passwords, API keys and personal data from transcripts.
- Use parameterized SQL and generic external-service errors.
- Use HTTPS and secure HTTP-only cookies after adding authentication.
- Review the provider’s current data-processing terms for confidential meetings.
Backups and monitoring
Back up daily off-server and retain 14 copies:
docker compose exec -T db pg_dump -U meeting meeting_notes | gzip > "backups/meeting-notes-$(date +%F).sql.gz"
Test restoration monthly. Monitor database disk/connections, generation failures, latency, pending records and provider spend. Alert above 80% disk usage and on repeated 502 responses.
Cost
Assumptions: 300 meetings/month, 10,000 input tokens and 1,500 output tokens per meeting, one VPS and daily backups. Model pricing is usage-based and changes; verify the official pricing page.
| Service | Monthly estimate |
|---|---|
| 2 GB VPS | $5–10 |
| Domain | $1–2 averaged annually |
| PostgreSQL | Included |
| HTTPS with Caddy | $0 |
| Off-site backups | $1–3 |
| Model API | Current provider pricing |
| Infrastructure subtotal | $6–15 + model usage |
Production checklist
- Authentication and ownership checks.
- Size limits and rate limiting.
- Provider spend limit.
- Secrets outside Git.
- Off-site backups and restore test.
- HTTPS and private database port.
- Error, latency and cost monitoring.
- Model and dependency versions reviewed.
Common problems
- Missing API key: check the server environment, not browser code.
- Invalid JSON: validate model output and use bounded retries.
- Database refused: use
dbinside Compose, notlocalhost. - High bills: lower transcript limits and cap requests.
- Duplicate generation: add idempotency keys and a worker before scaling.
Improvements
- Add authentication, quotas and Stripe billing.
- Add bounded audio transcription jobs.
- Add calendar integrations with explicit OAuth consent.
- Add Markdown/PDF export and full-text search.
- Move generation to a queue with idempotency and dead-letter handling.
Conclusion
A useful AI meeting-notes product needs no GPU or video integration on day one. Next.js, PostgreSQL and a server-side model call validate the core workflow cheaply. Start with strict limits, structured output, ownership checks and cost monitoring; add audio only after users rely on the transcript flow.
Official references
- Next.js documentation
- PostgreSQL documentation
- OpenAI API documentation
- OpenAI API pricing
- Docker Compose documentation
- Caddy reverse proxy documentation
Environment variables
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string. |
POSTGRES_PASSWORD |
Compose | PostgreSQL initialization password. |
OPENAI_API_KEY |
Yes | Server-side provider key. |
OPENAI_MODEL |
Yes | Model identifier from current official docs. |
APP_URL |
Yes | Public application origin. |