AI Meeting Intelligence — upload a recording or paste a transcript, get a structured summary with decisions, action items, open questions, and blockers, then push the result to Notion and Slack.
Features · Tech Stack · Quick Start · Environment · Integrations · Deployment
- About
- Features
- Tech Stack
- Monorepo Layout
- Quick Start
- Environment Variables
- Integrations
- Database & Migrations
- Available Scripts
- Project Architecture
- Production Build & Deployment
- Troubleshooting
- Security Notes
- Roadmap
MeetLog turns long, messy meetings into a structured artefact you can actually act on. Upload an audio file (or paste a raw transcript) and the system will:
- Transcribe the audio with AssemblyAI.
- Extract decisions, action items, open questions, blockers, and a summary using Google Gemini.
- Store everything in Postgres and let you browse, search, and edit from a Next.js dashboard.
- Push the result to Notion (as a database page) or Slack (as a formatted message).
The codebase is a TypeScript monorepo with two apps — web (Next.js 16 App Router) and api (Express + Drizzle ORM) — plus one shared types package.
- Two ingest paths: upload an audio file, or paste a raw transcript.
- Auto-generated meeting view: summary, decisions, action items with owner & deadline, open questions, blockers, speakers with turn counts.
- Action item tracking: checkbox to mark complete; overdue owners/deadlines surface in red.
- Full transcript search: in-page search with prev/next match navigation.
- PDF export and opt-in share links for any meeting.
- Notion integration: push a completed meeting to a Notion database with one click; built-in connection test.
- Slack integration: incoming-webhook based, no OAuth app needed; built-in test.
- Dashboard, Action Items, and Analytics pages with TanStack-Query caching.
- Monthly transcription quota: 2 free hours per user, enforced server-side, visible in the sidebar.
- Polished UI: dark mode, responsive layout, micro-animations, in-product help guides.
Next.js 16 · React 19 · TypeScript · Tailwind CSS v4 · TanStack Query v5 · Zustand · React Hook Form + Zod · React Dropzone · Recharts · Lucide React · date-fns · sonner · framer-motion
Express 4 · TypeScript · Drizzle ORM + drizzle-kit · PostgreSQL · Better Auth · BullMQ (or Postgres queue) · AssemblyAI · @google/genai · Cloudinary · @notionhq/client · pdfkit · Multer · Zod · express-rate-limit
@meetlog/shared (packages/shared) — TypeScript types shared between web and api.
meetlog/
├── apps/
│ ├── web/ # Next.js 16 frontend
│ └── api/ # Express backend (Drizzle, queue worker)
├── packages/
│ └── shared/ # TypeScript types shared by both apps
├── package.json # Root workspaces + cross-app scripts
└── README.md
In production, a single Node process serves the API and the built Next.js standalone output on one port.
- Node.js 20+ (Next.js 16 requires it) and npm 10+.
- PostgreSQL 14+ running locally, or a hosted Postgres URL.
- A free Gemini API key from aistudio.google.com/apikey.
- An AssemblyAI key (optional; only needed for audio uploads).
- A Cloudinary account (required for audio uploads).
git clone <your-fork-url> meetlog
cd meetlog
npm install
createdb meetlog # or: psql -U postgres -c "CREATE DATABASE meetlog;"
npm run db:migrate
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env.local
# Set DATABASE_URL and BETTER_AUTH_SECRET in apps/api/.env
npm run devOpen http://localhost:3000 for the web app; the api runs at http://localhost:3001. In dev, the web app proxies /api/* to the api via a Next.js rewrite.
Two env files — one per app. NEXT_PUBLIC_* vars are inlined at build time and require a rebuild to change.
| Variable | Required? | Default | Purpose |
|---|---|---|---|
DATABASE_URL |
yes | postgresql://localhost:5432/meetlog |
Postgres connection string. |
BETTER_AUTH_SECRET |
yes | dev-only fallback | JWT signing secret. Generate with openssl rand -base64 32. |
PORT |
no | 3001 |
HTTP port the api binds to. |
NODE_ENV |
no | development |
development, production, or test. |
APP_URL |
no | http://localhost:3000 |
Public URL of the web app (CORS, OAuth redirects, share links). |
BETTER_AUTH_URL |
no | http://localhost:3001 |
Public URL of the api (Better Auth needs its own origin). |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
no | "" |
Google OAuth credentials (enable "Sign in with Google"). |
CLOUDINARY_CLOUD_NAME / CLOUDINARY_API_KEY / CLOUDINARY_API_SECRET |
no | "" |
Cloudinary credentials. If any is missing, audio uploads fall back to the api server's local disk. |
ASSEMBLYAI_API_KEY |
no | "" |
Audio transcription. Without it, only the "paste transcript" path works. |
GEMINI_API_KEY |
no | "" |
Google Gemini API key. Get one at aistudio.google.com/apikey. |
GEMINI_MODEL |
no | gemini-2.5-flash-lite |
Gemini model name (free tier). |
QUEUE_BACKEND |
no | auto |
auto, postgres, bullmq, or memory. auto uses BullMQ for safe Redis URLs and the durable Postgres queue otherwise. |
REDIS_URL |
no | "" |
Redis URL for BullMQ. Docker Compose sets this to redis://redis:6379. |
ALLOW_UPSTASH_REDIS |
no | false |
Required opt-in before BullMQ uses an Upstash URL (avoids Upstash command-volume costs). |
NOTION_TOKEN_ENCRYPTION_KEY |
no | "" |
32-byte hex string for AES-256 encryption of stored Notion tokens. Generate with openssl rand -hex 32. |
MAX_UPLOAD_MB |
no | 500 |
Max audio upload size in MB. Enforced server-side and echoed to the browser. |
See apps/api/.env.example for the full list, including queue tuning and email (Resend) vars.
Resolution order for the AI service (highest priority first): Gemini API (if
GEMINI_API_KEYis set) → non-AI keyword fallback (always available, lower quality but useful for development).
| Variable | Required? | Default | Purpose |
|---|---|---|---|
NEXT_PUBLIC_API_URL |
no | http://localhost:3001 |
Base URL the browser uses for /api/* requests. In production where the api serves the web bundle, set this to a relative path or the deployed origin. |
NEXT_PUBLIC_USE_MOCK |
no | false |
Set to true to bypass the api and use in-browser mock fixtures (apps/web/src/lib/mockApi.ts). |
NEXT_PUBLIC_MAX_UPLOAD_MB |
no | 500 |
Client-side upload cap. Must agree with the api's MAX_UPLOAD_MB. |
Each user connects their own Notion account from Settings → Integrations → Notion: create an internal integration at notion.com/developers/connections, copy the access token and database id, paste both into Settings, and click Connect. Push to Notion creates a new page in the database; a Test button creates and archives a sample page to confirm the connection. The token is encrypted at rest with NOTION_TOKEN_ENCRYPTION_KEY (AES-256); rotating the key invalidates all stored tokens.
Slack uses incoming webhooks: create a webhook in your Slack workspace, paste the URL into Settings, and MeetLog posts a Block Kit message to that channel when you click Send to Slack on a meeting. A Test button posts a "✅ MeetLog test message" to confirm the webhook is live.
Resend is wired into the env schema but not yet used. When email reminders ship, RESEND_API_KEY and RESEND_FROM_EMAIL will be the only required vars.
The schema lives in apps/api/src/db/schema.ts; SQL migrations are in apps/api/drizzle/. Apply them with:
npm run db:migrateTo generate a new migration after editing the schema:
cd apps/api
npm run db:generate # writes a new .sql file to drizzle/
npm run db:push # apply directly to the dev database| Command | What it does |
|---|---|
npm run dev |
Start web + api concurrently with live reload. Web at :3000, api at :3001. |
npm run build |
Build both apps for production. |
npm run start |
Start the production api. In production it also serves the built web bundle. |
npm run typecheck |
Typecheck the shared types and both apps. |
npm run db:migrate |
Apply all SQL migrations to the database. |
Per-app scripts (e.g. npm run dev:web, npm run lint, npm run worker) live in the relevant apps/*/package.json.
- Server state (meetings, action items, settings, analytics) lives in TanStack Query, keyed in
apps/web/src/lib/hooks.tsso mutations can invalidate exactly the right queries. - Client state (sidebar, modals, auth) lives in Zustand stores in
apps/web/src/store/. - Form state is local React state, mirrored to the server only on submit.
- Data model: User / Session / Account (Better Auth) · Meeting (with status) · MeetingAnalysis (Gemini output) · ActionItem (owner, deadline, completed) · TranscriptUtterance (speaker-tagged segments) · UserSettings (template, Slack webhook, encrypted Notion token + database id).
npm install
npm run build
npm run startThis builds the Next.js app with output: 'standalone' into apps/web/.next/standalone/, transpiles the api with tsc into apps/api/dist/, and starts a single Node process that serves the API, static assets, uploaded files, and the Next.js HTML. A health check is available at GET /api/health.
The default deployment is single-server (one Node process, one port). To split:
- Web → deploy
apps/web/.next/standalone/to Vercel, Cloudflare Pages, or a Node host. - Api → deploy
apps/api/dist/to a Node host. - Set
NEXT_PUBLIC_API_URLin the web env to the api's public origin.
Queue backends:
bullmq(Redis, with separate IORedis connections for Queue vs Worker) is used whenREDIS_URLis set; otherwise a durable Postgres queue (processing_jobstable,FOR UPDATE SKIP LOCKED) is used. Thememorybackend is in-process only. Avoid Upstash for BullMQ unless you accept its command-volume cost — the Postgres queue is the recommended fallback for Upstash deployments.
psql: command not found— Install the Postgres client (macOS:brew install libpq; Windows: use the Stack Builder from the Postgres installer).Error: P1001: Can't reach database server—DATABASE_URLis wrong, the database isn't running, or the connection is blocked. Test withpsql $DATABASE_URL -c "select 1".BetterAuthError: Invalid origin—BETTER_AUTH_URLandAPP_URLneed to be the public origin the browser sees (in dev:http://localhost:3001andhttp://localhost:3000).Notion push failed: Could not find database— Database id is wrong, or the integration wasn't invited. In Notion, open the database →•••→ Connections → inviteMeetLog.Slack responded with 404— Webhook was deleted or revoked. Re-create it in Slack and paste the new URL in Settings.MAX_UPLOAD_MBnot enforced —MAX_UPLOAD_MB(api) andNEXT_PUBLIC_MAX_UPLOAD_MB(web) must agree. Set both to the same value and restart.- TypeScript errors after
npm install— Runnpm run typecheck; often arm -rf node apps/*/node_modules packages/*/node_modules && npm installresolves peer-dep drift. - Dashboard redirect loop to
/auth/signin— Usually a route-ordering bug. Check the api log for a Postgres22P02error followed byECONNREFUSEDin the web log; the named route (e.g./api/meetings/usage) must be registered before any catch-all/:idhandler.
- Never commit
.envfiles — they are git-ignored by default. - Rotate secrets in production —
BETTER_AUTH_SECRET,NOTION_TOKEN_ENCRYPTION_KEY, and any third-party API key.BETTER_AUTH_SECRETrotation invalidates all existing sessions. - Notion access tokens are encrypted at rest (AES-256). Back up
NOTION_TOKEN_ENCRYPTION_KEY— losing it permanently locks all stored tokens. - Slack webhook URLs are stored in plaintext (Slack expects this). If leaked, revoke and create a new one.
- Rate limiting is enabled on the api via
express-rate-limit. - CORS is configured to allow only
APP_URL. Don't widen to*in production.
- Email reminders for upcoming action item deadlines (Resend is wired up).
- Calendar integration to auto-detect upcoming meetings.
- Two-way Slack sync to mark action items done from a message reaction.
- Speaker diarization improvements and editable speaker labels.
- Multi-user workspaces with shared meeting libraries.
- Mobile app (React Native, reusing the same API).