Key Takeaway
Build an interactive speech analysis engine with Next.js, Supabase, and Claude AI. Technical walkthrough covering database schema, API routes, and prompts.
When I first looked at the brief for The Speech Site, the client wanted something that sounded deceptively simple: an interactive archive of historical speeches, organised by geopolitical rivalry, with the ability to generate new rhetoric using AI. The moment I started mapping out what that actually meant in database terms, I realised we were building something far more ambitious than a content library. We were building a relational engine for political confrontation itself.
Most historical archives on the web are glorified blog rolls. Speeches are stored as flat HTML blobs inside a WordPress database, tagged with a date and maybe a speaker name. If you want to understand the rhetorical escalation between Iran and America across forty-seven years, you are forced to open dozens of tabs and mentally reconstruct the timeline yourself. There is no spatial context. There is no relational mapping. There is certainly no way to ask the system to generate a new speech in the cadence of a specific era.
That friction is what the project set out to eliminate. The architecture needed to do three things simultaneously: store speeches as deeply relational data, render them as interactive visualisations with sub-second load times, and expose the entire corpus to an AI engine capable of generating original rhetoric on demand.
The Database Schema: Modelling Confrontation as Relational Data
The first decision was the most consequential. Rather than storing speeches as documents, I modelled them as nodes in a relational graph. The Supabase PostgreSQL database uses five core tables:
- speakers — stores each historical figure with columns for
name,role,nation,party_affiliation, andactive_years. A speaker like Khamenei has a different rhetorical profile to Khatami, and the schema needs to capture that distinction. - speeches — the central table, holding
title,full_text,delivered_date,location,audience, and critically, aspeaker_idforeign key linking back to the speakers table. - eras — defines named periods like "Axis of Evil Era," "Maximum Pressure Campaign," or "JCPOA Diplomacy." Each era has a
start_date,end_date, anddescription. Speeches are linked to eras viaera_id. - events — contextual moments that triggered or surrounded a speech: embassy seizures, nuclear test announcements, UN resolutions. Each event has a
date,description, andseverity_score. - tension_levels — a junction table linking speeches to a numeric tension score (1-10) with a
rationaletext field explaining why that score was assigned. This is what powers the colour-coded timeline visualisation.
The foreign keys create a web of relationships that a flat CMS could never reproduce. A single query can now answer: "Show me all speeches delivered by Iranian leaders during the Axis of Evil era where the tension level exceeded 7 and a military event occurred within 30 days." That query takes roughly 12 milliseconds on Supabase's PostgreSQL engine. On WordPress with custom fields, you would be looking at multiple JOIN-equivalent plugin queries taking several seconds each.
| Table | Key Columns | Purpose |
|---|---|---|
| speakers | name, role, nation, active_years | Historical figures and profiles |
| speeches | title, full_text, date, speaker_id | Central speech archive |
| eras | name, start_date, end_date | Named historical periods |
| events | date, description, severity_score | Contextual trigger events |
| tension_levels | speech_id, score (1-10), rationale | Conflict intensity mapping |




Next.js App Router Architecture
The front end uses Next.js 14 with the App Router, and the route structure mirrors the data model precisely. The top-level routes map to collections:
/rivalries/[rivalry-slug] → e.g., /rivalries/iran-vs-america
/rivalries/[rivalry-slug]/[era] → e.g., /rivalries/iran-vs-america/axis-of-evil
/speeches/[speech-id] → individual speech view
/speakers/[speaker-slug] → speaker profile with all speeches
/generate → Claude-powered speech generation tool
Each collection page uses Incremental Static Regeneration (ISR) with a revalidation window of 3,600 seconds. When the editorial team adds a new speech to the Supabase database, the next visitor to that collection page triggers a background regeneration. The page they see is the cached version; the next visitor after that gets the updated content. This means we get the performance of a fully static site with the flexibility of a dynamic CMS.
The individual speech pages use generateStaticParams to pre-render the most-accessed speeches at build time, while long-tail speeches are rendered on-demand and then cached. For a corpus of roughly 2,400 speeches, this keeps the build time under four minutes on Vercel's Pro tier.
API routes handle the dynamic operations. The /api/generate endpoint accepts a POST request with parameters like era, speaker style, tone, and topic, then proxies the request to Claude. The /api/search endpoint handles full-text queries against the speech corpus. Both routes run as Vercel Serverless Functions with a 10-second timeout, which is more than adequate for the workloads involved.
Supabase: Beyond Basic Storage
Supabase is doing far more than acting as a database in this architecture. Three features made it indispensable.
Row Level Security (RLS) is enabled on every table. The editorial team authenticates via Supabase Auth (using Magic Link email login), and RLS policies ensure that only authenticated editors can insert or update speeches. Public users can read the data but cannot modify it. This eliminates the need for a separate admin API — the same Supabase client is used by both the public site and the editorial dashboard, with RLS handling the access control at the database level.
Real-time subscriptions power a collaborative annotation feature. When a historian is reviewing a speech and adds a contextual note, other editors see the annotation appear in real time without refreshing the page. This uses Supabase's Realtime engine, which broadcasts PostgreSQL changes over WebSocket connections. The front end subscribes to changes on the annotations table filtered by speech_id, so editors only receive updates relevant to the speech they are currently viewing.
Edge Functions handle the API proxying to Claude. Rather than exposing the Anthropic API key in a Vercel serverless function, the Claude requests are routed through a Supabase Edge Function running on Deno. This keeps the API key within the Supabase environment and allows rate limiting at the database level — each authenticated user is limited to 20 generation requests per day, tracked via a generation_log table.
Search Architecture: Finding Speeches by Theme, Era, and Speaker
Full-text search was a requirement from day one. Users needed to search not just by speaker name or date, but by rhetorical theme — queries like "nuclear deterrence" or "economic sanctions" needed to surface relevant speeches regardless of whether those exact phrases appeared in the title.
The implementation uses PostgreSQL's native tsvector and tsquery functionality, exposed through Supabase's SQL editor. Each speech has a generated column called search_vector that combines the title, full text, and era description into a single searchable index:
ALTER TABLE speeches ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(full_text, '')), 'B') ||
setweight(to_tsvector('english', coalesce(location, '')), 'C')
) STORED;
The weighting ensures that title matches rank higher than body text matches, which rank higher than location matches. A GIN index on the search_vector column keeps query times below 20 milliseconds even across the full corpus.
For fuzzy matching — handling misspellings like "Ahmadinejad" — we also enabled the pg_trgm extension and created trigram indexes on the speakers.name column. This allows similarity searches that return results even when the user's spelling is approximate, which is critical when dealing with transliterated names from non-Latin scripts.
5 Core Tables
Relational schema modelling speeches as graph nodes — not flat HTML blobs
Performance: Core Web Vitals and CDN Strategy
Performance was non-negotiable. The site needed to hit Google's Core Web Vitals thresholds consistently, both for SEO and because the target audience includes researchers on institutional networks that may have unpredictable bandwidth.
The key metrics we targeted and achieved:
- Largest Contentful Paint (LCP): under 1.8 seconds. Achieved primarily through ISR and aggressive edge caching via Vercel's CDN. The cover images for each speech are served through
next/imagewith automatic WebP/AVIF conversion and responsive sizing. - Cumulative Layout Shift (CLS): under 0.05. All images have explicit
widthandheightattributes, and the timeline component reserves its layout space before data loads using a skeleton screen. - Interaction to Next Paint (INP): under 150 milliseconds. The timeline filtering (toggling between "US Only," "Iran Only," and "All") uses client-side state with React's
useMemoto avoid unnecessary re-renders. The speech data is already loaded; filtering simply changes which items are visible.
Images are the largest payload on most pages. Every speech cover image is processed through next/image with the following configuration: quality set to 75, format auto-negotiated between WebP and AVIF based on browser support, and sizes configured to serve 640px on mobile, 1024px on tablet, and 1280px on desktop. This alone reduced total image payload by approximately 60% compared to serving unoptimised JPEGs.
The Claude Integration: From Archive to Generative Tool
The generative feature is what transforms this from a reference site into a tool. Users can select a historical era, a speaker's rhetorical style, and a modern topic, and Claude will generate an original speech that mirrors the cadence and argumentative structure of that era's rhetoric.
The prompt engineering is where the relational database pays dividends. When a user requests a speech in the style of "Cold War American diplomacy," the API route does not just send a generic instruction to Claude. It queries the database for all speeches from that era, calculates the average sentence length, identifies the most frequent rhetorical devices (tricolon, anaphora, antithesis), and includes three representative excerpts in the prompt context. Claude then generates content that genuinely reflects the patterns of that period, not just a generic approximation.
Each generation request consumes roughly 2,000-4,000 tokens of input (the contextual examples plus the user's instructions) and produces 800-1,500 tokens of output. At Anthropic's current pricing for the Claude Sonnet model, that works out to approximately £0.008 to £0.025 per generation. Even at scale, this is a negligible cost.
What It Costs to Run
One of the questions I get asked most frequently is what a stack like this actually costs per month. Here is the real breakdown:
- Supabase Pro: $25/month (approximately £20). This gives you 8GB database storage, 250GB bandwidth, 100GB file storage, and unlimited API requests. The free tier works for development, but the 500MB database limit and lack of daily backups make it unsuitable for production.
- Vercel Pro: $20/month (approximately £16). Required for ISR with custom revalidation intervals, serverless function execution beyond the hobby tier limits, and preview deployments for the editorial team.
- Claude API costs: Variable, but typically $15-30/month (£12-24) based on approximately 1,000 speech generations per month. The Edge Function rate limiting keeps this predictable.
- Domain and DNS: Approximately £10/year via Cloudflare, negligible on a monthly basis.
Total monthly cost: approximately £48-60. Compare that to a managed WordPress hosting plan with equivalent performance (WP Engine at £23/month minimum, plus premium plugins for custom fields, search, and API integrations) and you are looking at similar costs but with far greater architectural flexibility and no plugin dependency risk.
Deployment Pipeline: From Commit to Production
The deployment workflow uses GitHub Actions for CI and Vercel for hosting. The pipeline runs as follows:
- Push to a feature branch triggers a GitHub Action that runs TypeScript type checking, ESLint, and the test suite (primarily integration tests that verify the Supabase queries return expected shapes).
- Vercel automatically creates a preview deployment for every pull request. The editorial team can review content changes on a unique preview URL before anything touches production.
- Merging to
maintriggers the production build. Vercel runs the Next.js build, generates static pages viagenerateStaticParams, and deploys to the edge network. - Database migrations are managed via the Supabase CLI. Migration files live in
/supabase/migrations/and are applied manually to production after testing on a staging Supabase project. This is deliberately not automated — schema changes to a production database should always involve human review.
The entire cycle from commit to live production takes approximately three minutes for a code change, or four minutes if the change triggers a full static regeneration of all speech pages.
Trade-offs and What I Would Change
No architecture is without compromises. The main trade-off here is editorial complexity. Adding a new speech requires inserting records into multiple tables (speakers, speeches, eras, events, tension_levels) with correct foreign key relationships. For a technical editorial team, this is fine — they use the Supabase dashboard directly. For a non-technical team, you would need to build a custom admin interface, which adds development time and ongoing maintenance.
If I were starting this project again, I would consider using Supabase's new Branching feature for database staging environments, which was not mature enough when we began. I would also evaluate whether the Edge Functions could be replaced with Vercel's native serverless functions now that Vercel supports longer execution times on Pro plans, which would simplify the deployment topology.
The core architectural decision — modelling speeches as relational data rather than flat documents — remains the right call. It is what makes the timeline visualisation, the multi-dimensional search, and the contextually aware AI generation possible. A flat archive could never support any of those features without extensive, fragile workarounds.
Related guides: If you found this useful, see our guide on Architecting for LLMs: Why We Built a Luxury Travel Directory on Sanity and Cloudflare and How to Configure GoHighLevel Missed-Call Text-Back Correctly.
To see this architecture operating in a live environment — complete with the Claude-powered speechwriting engine and the dynamic historical rivalry timelines — the production deployment is accessible at The Speech Site.