DEV Community

Cover image for I Built an AI Courtroom Simulator with Next.js and OpenAI — Here's the Full Technical Breakdown
Naima Kader
Naima Kader

Posted on

I Built an AI Courtroom Simulator with Next.js and OpenAI — Here's the Full Technical Breakdown

I Built an AI Courtroom Simulator That Lets Law Students Practice Against a Judge, Prosecutor, and Witness — Here's How

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.


The Problem

Moot court is how law students learn to argue. The problem is brutal:

  • A professional coach costs $500 per hour
  • Most law schools offer fewer than 12 practice sessions per year
  • Students who cannot afford coaching lose more cases
  • There was no free, intelligent alternative

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.


What I Built

LexAI is a full-stack AI courtroom simulator with:

  • 3 AI personas — a strict federal judge, an aggressive prosecutor, and a defensive witness
  • Real-time argument scoring — every argument rated 0 to 100 on logic, precedent, and persuasiveness
  • Witness cross-examination with contradiction detection
  • Session replay timeline showing score progression across every turn
  • Real-time multiplayer battle mode — two students argue opposite sides simultaneously
  • Professor dashboard with class management and student analytics
  • Shareable session cards with dynamic OG image generation on the edge
  • 10 landmark cases including Miranda, Brown v. Board, Roe v. Wade, Apple v. Samsung

Live: lexai-fd92.vercel.app
GitHub: github.com/naimakader/Lexai


The Stack

Next.js 15 App Router
TypeScript
Tailwind CSS
Supabase (PostgreSQL + Realtime)
Clerk Authentication
OpenAI GPT-4o-mini
Vercel OG (Edge Runtime)
Framer Motion


The Hardest Technical Problem — Multi-Persona AI State

The core challenge was keeping three AI personas consistent across a long conversation.

Each persona needed to:

  1. Respond in character without breaking tone
  2. Remember what was said earlier in the session
  3. React to the user's specific argument — not a generic response

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.

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.
`
Enter fullscreen mode Exit fullscreen mode

Using response_format: { type: "json_object" } on GPT-4o-mini guarantees structured output every time. No parsing failures, no broken JSON.


The Witness Contradiction Detection System

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:

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
`
Enter fullscreen mode Exit fullscreen mode

When contradiction: true comes back, a red banner flashes on screen and the score jumps. Users genuinely feel the moment they catch the witness.


Real-Time Multiplayer with Supabase Realtime

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:

  1. Player 1 creates a room — gets a 6-letter code
  2. Player 2 enters the code — joins as prosecution
  3. When either player submits an argument, the API route calls OpenAI, saves the updated messages to Supabase, and returns the response
  4. Supabase Realtime fires a postgres_changes event to both clients
  5. Both UIs update simultaneously
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])
Enter fullscreen mode Exit fullscreen mode

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.


Dynamic OG Images on the Edge

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:

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(
    <div style={{ background: "#03030A", width: "100%", height: "100%" }}>
      // JSX rendered to a 1200x630 PNG on the edge
    </div>,
    { width: 1200, height: 630 }
  )
}
Enter fullscreen mode Exit fullscreen mode

Every share generates a unique image in milliseconds. No pre-rendering, no storage costs.


The Clerk + Supabase Auth Problem

This was the bug that cost me the most time.

Clerk handles authentication. Supabase handles the database. But Supabase's Row Level Security uses auth.uid() which expects Supabase Auth — not Clerk. So RLS policies blocked all reads and writes even for authenticated users.

The fix was to use the Supabase service role key in all server-side API routes:

// lib/supabase-admin.ts
import { createClient } from "@supabase/supabase-js"

export const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)
Enter fullscreen mode Exit fullscreen mode

The 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.

The 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.


The Session Replay Timeline

After each session, users can replay their entire argument history. Every turn is saved with:

  • The argument text
  • The score for that turn
  • The score delta — how much it went up or down
  • Whether it was a defense turn or witness turn

This data drives a visual bar chart and a turn-by-turn timeline showing exactly where the user won or lost the case.

const newEntry = {
  turn: (scoreHistory?.length || 0) + 1,
  score: result.score,
  argument: currentInput,
  delta: result.scoreDelta || 0,
  mode: "defense",
}

const updatedHistory = [...(scoreHistory || []), newEntry]
Enter fullscreen mode Exit fullscreen mode

The best argument is calculated on every save:

const bestArgument = updatedHistory.reduce(
  (best, entry) => entry.score > (best?.score ?? 0) ? entry : best,
  updatedHistory[0]
)
Enter fullscreen mode Exit fullscreen mode

Security

Three things I implemented before deploying:

1. Row Level Security on all tables

Even though I use the admin client in API routes, RLS is enabled on all tables as a defense-in-depth measure.

2. Rate limiting

Each user is limited to 50 API calls per hour. This prevents prompt injection attacks and runaway API costs.

3. Input sanitization

All user inputs are limited to 1000 characters before hitting the AI. This prevents prompt injection and keeps costs predictable.


What I Learned

Structured JSON outputs are underrated. Using response_format: { type: "json_object" } eliminated an entire category of bugs. No more regex parsing, no more broken responses, no more try-catch around JSON.parse for normal flow.

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.

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.

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.


What Is Next

  • Voice arguments — speak your legal argument instead of typing
  • AI feedback report — a full written analysis of your performance after each session
  • More landmark cases — currently at 10, targeting 50 by end of year
  • Mobile app — React Native version for studying on the go

Try It

Live: lexai-fd92.vercel.app
GitHub: github.com/naimakader/Lexai

If 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.

Questions welcome in the comments.


Built by Naima — self-taught frontend developer from Jijiga, Ethiopia.

Top comments (0)