Justitia

Justitia

I built a legal marketplace in 3 days. Not because I had a burning passion for law (I don't), but because I kept seeing this problem: clients don't know how much legal services cost until they've already paid for a consultation. Lawyers, on the other hand, waste time on intake calls for cases they don't want.

So I built Justitia, a platform where clients post cases, lawyers bid on them, and payment happens upfront through Stripe. Simple concept. Messy execution. Here's how it went.

Except it wasn't simple. At all. So I thought: What if clients posted cases and lawyers bid on them?

The Idea:

  • Reverse the flow: client posts case → lawyers compete with quotes → client picks one
  • Transparent pricing, no surprises
  • Like Upwork, but for legal work. Transparent pricing. No surprises. Simple.

The Stack (Why I Chose This Over Next.js)

Everyone defaults to Next.js + Prisma these days. I went different:
  • React Router v7 instead of Next.js. Vite-based, simpler deploy, no magic
  • tRPC for APIs. Type-safe, no code gen, instant autocomplete
  • Hono as server. Edge-first, fast as hell, Cloudflare Workers-ready
  • Supabase Postgres. Free tier, hosted, PostgREST for backup queries
  • Cloudflare R2 for file storage. No egress fees

Why not Next.js?

  • I wanted control over deployment (Netlify frontend + Render backend)
  • tRPC + Hono = lighter, faster, no RSC complexity
  • Bun made installs instant (spoiled by speed)

The Hardest Problem: Race Conditions

Here's the thing about marketplaces: timing matters. Imagine this:
  1. Client posts a case
  2. Three lawyers submit quotes ($500, $300, $400)
  3. Client picks the $300 quote and pays
  4. But what if two lawyers accept at the same time?
If you don't handle this atomically, you get:
  • Multiple accepted quotes
  • Case assigned to wrong lawyer
  • Payment chaos

Solution: Database Transaction

javascript
await db.transaction(async (tx) => {
  const quote = await tx.query.quotes.findFirst({
    where: and(eq(quotes.id, quoteId), eq(quotes.status, 'proposed'))
  });
  
  if (!quote) throw new Error('Quote already accepted');
  
  // Accept this quote
  await tx.update(quotes)
    .set({ status: 'accepted' })
    .where(eq(quotes.id, quoteId));
    
  // Reject all others
  await tx.update(quotes)
    .set({ status: 'rejected' })
    .where(and(
      eq(quotes.caseId, quote.caseId),
      ne(quotes.id, quoteId)
    ));
});
Learned this the hard way after testing with two browser tabs. Almost shipped with a race condition bug.

Stripe Webhooks: The Hidden Landmine

Stripe webhooks are great until they're not. They can:
  • Be sent multiple times (idempotency matters)
  • Arrive out of order
  • Fail silently

The Fix: Check Before Processing

javascript
const handlePaymentSuccess = async (paymentIntentId: string) => {
  const payment = await db.query.payments.findFirst({
    where: eq(payments.stripePaymentIntentId, paymentIntentId)
  });
  
  if (payment.status === 'succeeded') {
    console.log('Already processed, skipping');
    return; // Idempotent!
  }
  
  // Update quote status, case status, notify lawyer...
};
Also learned: always verify webhook signatures. I didn't at first. Bad idea. Anyone could POST to
/webhooks/stripe
and fake a payment.
javascript
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(rawBody, sig, WEBHOOK_SECRET);

If signature fails → reject. No mercy.

File Security: Attorney-Client Privilege

Legal documents = sensitive. Can't just slap files on S3 with public URLs.

Access Control Rules:

  • Client can access their own case files
  • Lawyer can ONLY access files if quote is accepted
  • Everyone else gets 403
javascript
const canAccessFile = async (userId: string, fileId: string) => {
  const file = await db.query.files.findFirst({
    where: eq(files.id, fileId),
    with: { case: { with: { acceptedQuote: true } } }
  });
  
  if (!file) return false;
  if (file.case.clientId === userId) return true;
  if (file.case.acceptedQuote?.lawyerId === userId) return true;
  
  return false;
};

Files stored in Cloudflare R2 with signed URLs (expire after 1 hour). No permanent public links.

Layer 3: Deployment & Costs (The Real World)

Deployment Stack:

  • Frontend: Netlify (auto-deploy from GitHub)
  • Backend: Render (Docker, free tier with auto-sleep)
  • Database: Supabase (always-on Postgres)
  • Storage: Cloudflare R2 (CDN-backed, no egress fees)

Monthly Cost:

  • $0/month on free tiers (Netlify, Render, Supabase)
  • ~$7/month if you upgrade Render to always-on
  • <$1/month for R2 storage (first 10GB free)

Compare that to AWS: S3 + RDS + EC2 = $50+/month easy.

Performance:

  • Average API response: <100ms (Hono is fast)
  • Cold start on Render free tier: ~2-3 seconds (acceptable for demo)

The Results (What I Learned)

What Worked:
  • tRPC type safety caught 80% of bugs at compile time
  • Bun made dependency installs instant (never going back to npm)
  • Cloudflare R2 = S3 without the egress fee bullshit
What Didn't:
  • Better Auth docs are sparse (had to read source code)
  • Render free tier sleeps after inactivity (cold starts suck for webhooks)
  • No real-time notifications (should've added WebSockets)
Would Do Differently:
  • Add WebSockets for live quote updates
  • Use Cloudflare Workers for backend (edge-first from the start)
  • Add email notifications (currently no alerts when quote is accepted)
Live Demo: