I Built an AI Courtroom Simulator with Next.js and OpenAI — Here's the Full Technical Breakdown 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. 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? Six weeks later I had my answer — and a production app that law students are actually using. This is the full technical breakdown of how I built it. Moot court is how law students learn to argue. The problem is brutal: I 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. LexAI is a full-stack AI courtroom simulator with: Live: lexai-fd92.vercel.app https://lexai-fd92.vercel.app GitHub: github.com/naimakader/Lexai https://github.com/naimakader/Lexai Next.js 15 App Router TypeScript Tailwind CSS Supabase PostgreSQL + Realtime Clerk Authentication OpenAI GPT-4o-mini Vercel OG Edge Runtime Framer Motion The core challenge was keeping three AI personas consistent across a long conversation. Each persona needed to: My 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. js const prompt = You are running a courtroom simulation. Case facts: ${caseData.facts} Conversation so far: ${conversation} Respond with a JSON object with exactly these 5 fields: - judgeResponse: The judge's response 1-2 sentences, formal - prosecutionResponse: The prosecution's counter-argument aggressive - score: 0 to 100 rating the defense's last argument - scoreDelta: How much the score changed from previous turn - feedback: One short coaching sentence for the defense Return only valid JSON. No extra text. Using response format: { type: "json object" } on GPT-4o-mini guarantees structured output every time. No parsing failures, no broken JSON. This was the feature that surprised me most technically. The 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. The key insight was in the system prompt: js const prompt = You are playing the role of a witness in a courtroom cross-examination. Your original testimony: ${caseData.witness.testimony} IMPORTANT RULES: - Stay consistent with your original testimony unless the attorney asks a very clever question that exposes a contradiction - If caught in a contradiction admit it reluctantly but try to explain it away - Be evasive and defensive when pressed on weak points - Never volunteer information the attorney did not ask for Return a JSON object including: - witnessResponse: Your answer 1-3 sentences - contradiction: true if the attorney caught a contradiction - score: 0 to 100 rating the question's effectiveness When contradiction: true comes back, a red banner flashes on screen and the score jumps. Users genuinely feel the moment they catch the witness. The battle mode was the most technically interesting feature to build. Two 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. The architecture: postgres changes event to both clients js useEffect = { const channel = supabase .channel battle room ${room.id} .on "postgres changes", { event: "UPDATE", schema: "public", table: "battle rooms", filter: id=eq.${room.id} , }, payload = { setRoom payload.new } .subscribe return = { supabase.removeChannel channel } }, room.id The 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. After 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. This uses Vercel's @vercel/og library running on the Edge Runtime: js export const runtime = "edge" export async function GET req: NextRequest { const { searchParams } = new URL req.url const caseTitle = searchParams.get "case" || "State v. Miranda" const score = searchParams.get "score" || "0" const bestArgument = searchParams.get "best" || "" return new ImageResponse