{"slug": "i-built-an-ai-courtroom-simulator-with-next-js-and-openai-here-s-the-full", "title": "I Built an AI Courtroom Simulator with Next.js and OpenAI — Here's the Full Technical Breakdown", "summary": "A developer in Jijiga, Ethiopia, built LexAI, a full-stack AI courtroom simulator using Next.js 15, OpenAI's GPT-4o-mini, and Supabase. The app lets law students argue against three AI personas, including a witness that can be caught in contradictions, and features a real-time battle mode for two players. The developer detailed the technical architecture, including role-locked system prompts and Supabase's postgres_changes for real-time updates.", "body_md": "When I started building LexAI I had one question: what would happen if you put three AI personas in a courtroom and let a law student argue against all of them simultaneously?\n\nSix weeks later I had my answer — and a production app that law students are actually using.\n\nThis is the full technical breakdown of how I built it.\n\nMoot court is how law students learn to argue. The problem is brutal:\n\nI am a self-taught developer based in Jijiga, Ethiopia. I have never been to law school. But I recognized a product problem with a clear technical solution — and I built it.\n\nLexAI is a full-stack AI courtroom simulator with:\n\nLive: [lexai-fd92.vercel.app](https://lexai-fd92.vercel.app)\n\nGitHub: [github.com/naimakader/Lexai](https://github.com/naimakader/Lexai)\n\nNext.js 15 App Router\n\nTypeScript\n\nTailwind CSS\n\nSupabase (PostgreSQL + Realtime)\n\nClerk Authentication\n\nOpenAI GPT-4o-mini\n\nVercel OG (Edge Runtime)\n\nFramer Motion\n\nThe core challenge was keeping three AI personas consistent across a long conversation.\n\nEach persona needed to:\n\nMy solution was to send the full conversation history to OpenAI on every request with a role-locked system prompt. Each API call includes the complete transcript so the AI has full context.\n\n``` js\nconst prompt = `\nYou are running a courtroom simulation.\n\nCase facts: ${caseData.facts}\n\nConversation so far:\n${conversation}\n\nRespond with a JSON object with exactly these 5 fields:\n- judgeResponse: The judge's response (1-2 sentences, formal)\n- prosecutionResponse: The prosecution's counter-argument (aggressive)\n- score: 0 to 100 rating the defense's last argument\n- scoreDelta: How much the score changed from previous turn\n- feedback: One short coaching sentence for the defense\n\nReturn only valid JSON. No extra text.\n`\n```\n\nUsing `response_format: { type: \"json_object\" }`\n\non GPT-4o-mini guarantees structured output every time. No parsing failures, no broken JSON.\n\nThis was the feature that surprised me most technically.\n\nThe witness has a prepared testimony. When the user asks questions, the AI tries to stay consistent. But if the user asks a clever question that exposes an inconsistency — the witness stumbles.\n\nThe key insight was in the system prompt:\n\n``` js\nconst prompt = `\nYou are playing the role of a witness in a courtroom cross-examination.\n\nYour original testimony: ${caseData.witness.testimony}\n\nIMPORTANT RULES:\n- Stay consistent with your original testimony unless the attorney \n  asks a very clever question that exposes a contradiction\n- If caught in a contradiction admit it reluctantly but try to explain it away\n- Be evasive and defensive when pressed on weak points\n- Never volunteer information the attorney did not ask for\n\nReturn a JSON object including:\n- witnessResponse: Your answer (1-3 sentences)\n- contradiction: true if the attorney caught a contradiction\n- score: 0 to 100 rating the question's effectiveness\n`\n```\n\nWhen `contradiction: true`\n\ncomes back, a red banner flashes on screen and the score jumps. Users genuinely feel the moment they catch the witness.\n\nThe battle mode was the most technically interesting feature to build.\n\nTwo players join the same room — one as defense, one as prosecution. Every argument one player makes triggers an AI judge response that both players see simultaneously.\n\nThe architecture:\n\n`postgres_changes`\n\nevent to both clients\n\n``` js\nuseEffect(() => {\n  const channel = supabase\n    .channel(`battle_room_${room.id}`)\n    .on(\n      \"postgres_changes\",\n      {\n        event: \"UPDATE\",\n        schema: \"public\",\n        table: \"battle_rooms\",\n        filter: `id=eq.${room.id}`,\n      },\n      (payload) => {\n        setRoom(payload.new)\n      }\n    )\n    .subscribe()\n\n  return () => {\n    supabase.removeChannel(channel)\n  }\n}, [room.id])\n```\n\nThe beauty of this approach is simplicity. I do not need WebSocket servers or complex state synchronization. Supabase handles everything. One database update triggers real-time UI updates across every connected client.\n\nAfter finishing a session users can share their results on LinkedIn and Twitter. When they paste the link, a dynamic preview image appears showing their score, grade, case name, and best argument.\n\nThis uses Vercel's `@vercel/og`\n\nlibrary running on the Edge Runtime:\n\n``` js\nexport const runtime = \"edge\"\n\nexport async function GET(req: NextRequest) {\n  const { searchParams } = new URL(req.url)\n  const caseTitle = searchParams.get(\"case\") || \"State v. Miranda\"\n  const score = searchParams.get(\"score\") || \"0\"\n  const bestArgument = searchParams.get(\"best\") || \"\"\n\n  return new ImageResponse(\n    <div style={{ background: \"#03030A\", width: \"100%\", height: \"100%\" }}>\n      // JSX rendered to a 1200x630 PNG on the edge\n    </div>,\n    { width: 1200, height: 630 }\n  )\n}\n```\n\nEvery share generates a unique image in milliseconds. No pre-rendering, no storage costs.\n\nThis was the bug that cost me the most time.\n\nClerk handles authentication. Supabase handles the database. But Supabase's Row Level Security uses `auth.uid()`\n\nwhich expects Supabase Auth — not Clerk. So RLS policies blocked all reads and writes even for authenticated users.\n\nThe fix was to use the Supabase service role key in all server-side API routes:\n\n``` js\n// lib/supabase-admin.ts\nimport { createClient } from \"@supabase/supabase-js\"\n\nexport const supabaseAdmin = createClient(\n  process.env.NEXT_PUBLIC_SUPABASE_URL!,\n  process.env.SUPABASE_SERVICE_ROLE_KEY!\n)\n```\n\nThe service role client bypasses RLS. I use it in all API routes where I verify the user via Clerk first, then query Supabase with elevated permissions.\n\nThe regular anon client is used only for Supabase Realtime subscriptions on the client side — where I do not need to read or write protected data.\n\nAfter each session, users can replay their entire argument history. Every turn is saved with:\n\nThis data drives a visual bar chart and a turn-by-turn timeline showing exactly where the user won or lost the case.\n\n``` js\nconst newEntry = {\n  turn: (scoreHistory?.length || 0) + 1,\n  score: result.score,\n  argument: currentInput,\n  delta: result.scoreDelta || 0,\n  mode: \"defense\",\n}\n\nconst updatedHistory = [...(scoreHistory || []), newEntry]\n```\n\nThe best argument is calculated on every save:\n\n``` js\nconst bestArgument = updatedHistory.reduce(\n  (best, entry) => entry.score > (best?.score ?? 0) ? entry : best,\n  updatedHistory[0]\n)\n```\n\nThree things I implemented before deploying:\n\n**1. Row Level Security on all tables**\n\nEven though I use the admin client in API routes, RLS is enabled on all tables as a defense-in-depth measure.\n\n**2. Rate limiting**\n\nEach user is limited to 50 API calls per hour. This prevents prompt injection attacks and runaway API costs.\n\n**3. Input sanitization**\n\nAll user inputs are limited to 1000 characters before hitting the AI. This prevents prompt injection and keeps costs predictable.\n\n**Structured JSON outputs are underrated.** Using `response_format: { type: \"json_object\" }`\n\neliminated an entire category of bugs. No more regex parsing, no more broken responses, no more try-catch around JSON.parse for normal flow.\n\n**Supabase Realtime is genuinely magical.** Building multiplayer with WebSockets from scratch would have taken weeks. With Supabase Realtime it took two hours. The postgres_changes subscription is one of the most elegant APIs I have used.\n\n**The hardest part of AI products is not the AI.** It is the state management around the AI. Keeping conversation history consistent, handling loading states, recovering from errors gracefully — that is where the real engineering work is.\n\n**Ship with security from day one.** I added RLS and rate limiting before the first deployment. Going back to add security to a running production app is much harder than building it in from the start.\n\n**Live:** [lexai-fd92.vercel.app](https://lexai-fd92.vercel.app)\n\n**GitHub:** [github.com/naimakader/Lexai](https://github.com/naimakader/Lexai)\n\nIf you are a law student, try arguing State v. Miranda. If you are a developer, look at the battle mode and the OG image generation — those are the two parts I am most proud of technically.\n\nQuestions welcome in the comments.\n\n*Built by Naima — self-taught frontend developer from Jijiga, Ethiopia.*", "url": "https://wpnews.pro/news/i-built-an-ai-courtroom-simulator-with-next-js-and-openai-here-s-the-full", "canonical_source": "https://dev.to/naima_kader_75d582f85fcf9/i-built-an-ai-courtroom-simulator-with-nextjs-and-openai-heres-the-full-technical-breakdown-54jd", "published_at": "2026-08-04 05:13:56+00:00", "updated_at": "2026-08-04 05:41:33.589122+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-products", "developer-tools"], "entities": ["LexAI", "Next.js", "OpenAI", "GPT-4o-mini", "Supabase", "Clerk", "Vercel", "Framer Motion"], "alternates": {"html": "https://wpnews.pro/news/i-built-an-ai-courtroom-simulator-with-next-js-and-openai-here-s-the-full", "markdown": "https://wpnews.pro/news/i-built-an-ai-courtroom-simulator-with-next-js-and-openai-here-s-the-full.md", "text": "https://wpnews.pro/news/i-built-an-ai-courtroom-simulator-with-next-js-and-openai-here-s-the-full.txt", "jsonld": "https://wpnews.pro/news/i-built-an-ai-courtroom-simulator-with-next-js-and-openai-here-s-the-full.jsonld"}}