· 14 min read

Building an Event Photo Platform with OCR Bib Detection

How I architected a full-stack monorepo platform for event photographers to upload, auto-tag bib numbers via OCR, and let runners search and download their photos.

Full-Stack OCR Next.js Express TypeScript Monorepo
// table of contents (37 sections)

Try it live — The OCR demo is hosted on GitHub Pages and the source code is on GitHub.


Live Demo

Try the OCR bib detection right here — upload an event photo and see detected bib numbers. Everything runs in your browser, no images are uploaded to any server:

Race events, marathons, and sporting competitions generate thousands of photos. Every participant wants to find their photos quickly — and the fastest way is searching by bib number. But manually tagging 15,000+ photos with bib numbers is slow, expensive, and error-prone.

So I built a complete Event Photo Platform — a full-stack monorepo where photographers upload photos, the system automatically detects bib numbers using dual OCR providers, and participants can search and download their photos instantly.

In this post, I’ll walk through the entire architecture, tech stack, database design, OCR pipeline, and the decisions I made along the way.


The Problem

Here’s the workflow that event photographers and organizers deal with:

  1. A marathon has 10,000 runners and 5 photographers
  2. Each photographer captures ~2,000–3,000 photos
  3. That’s roughly 15,000 images that need bib number tagging
  4. Runners want to search by their bib number and download their photos

Manual tagging at 10 seconds per photo = 40+ hours of labor. And humans misread numbers, miss partial bibs, and get fatigued.

The solution: a platform that automates bib detection, manages the upload pipeline, and provides a clean search experience for participants.


Architecture Overview

I chose a monorepo architecture with clear separation between the API, admin dashboard, and public-facing web app:

event-photo-platform/
├── apps/
│   ├── api/              # Express.js REST API (port 4001)
│   │   └── src/
│   │       ├── index.ts
│   │       ├── config/       # S3, env, Redis config
│   │       ├── middleware/   # Auth, upload, error handling
│   │       └── modules/      # Feature-based API modules
│   │           ├── auth/
│   │           ├── events/
│   │           ├── photos/
│   │           ├── ocr/
│   │           ├── search/
│   │           ├── downloads/
│   │           ├── settings/
│   │           └── users/
│   ├── dashboard/        # Next.js admin/photographer portal (port 3002)
│   │   └── src/app/
│   │       ├── admin/        # Admin pages
│   │       ├── photographer/ # Photographer pages
│   │       └── login/
│   └── web/              # Next.js public site (port 3000)
│       └── src/app/          # Public event browsing + search
├── packages/
│   ├── database/         # Drizzle ORM + PostgreSQL schema
│   │   ├── src/
│   │   │   ├── schema.ts
│   │   │   └── connection.ts
│   │   └── seed.ts
│   ├── types/            # Shared TypeScript types
│   └── utils/            # Shared utilities
├── docker-compose.yml
├── .env.example
└── README.md

Why Monorepo?

  • Shared types between API and both frontends — no duplicate interfaces
  • Single bun install for all apps
  • Atomic changes — update a type in packages/types, and both frontends + API get it
  • Shared database package — schema defined once, used everywhere

Tech Stack

Here’s every technology I used and why:

LayerTechnologyWhy
RuntimeBunFastest JS runtime, native TypeScript, drop-in Node replacement
APIExpress.js 5 + TypeScriptMature, well-documented, massive middleware ecosystem
FrontendNext.js 15 + React 19App Router, SSR for public SEO, fast dashboard
StylingTailwind CSS 4Utility-first, rapid UI development
DatabasePostgreSQL 16Reliable, JSONB support, full-text search potential
ORMDrizzle ORMType-safe, lightweight, SQL-like syntax, great migrations
StorageMinIO (S3-compatible)Self-hosted S3, presigned URLs, cost-effective
CacheRedis 7Search result caching, BullMQ job queue backend
Job QueueBullMQOCR processing is async — queue it, retry on failure
OCR PrimaryGoogle Cloud Vision API~95% accuracy on bib detection
OCR FallbackTesseract.jsFree, local, no API needed — ~50% accuracy
AuthJWT (access + refresh tokens)Stateless auth, works across microservices
Image ProcessingSharpResize, crop, grayscale, sharpen for OCR preprocessing
ContainerizationDocker ComposePostgreSQL, Redis, MinIO — one command to start

Infrastructure with Docker Compose

Everything you need to run locally in a single docker-compose.yml:

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: event_photo
    ports: ["5432:5432"]
    volumes: [postgres_data:/var/lib/postgresql/data]

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]
    volumes: [redis_data:/data]

  minio:
    image: minio/minio:latest
    ports: ["9000:9000", "9001:9001"]
    volumes: [minio_data:/data]
    command: server /data --console-address ":9001"

One command: docker compose up -d — and you have PostgreSQL, Redis, and S3-compatible storage running.


Database Schema

I designed the schema with Drizzle ORM. Here’s the complete schema:

Users Table

export const users = pgTable('users', {
  id: uuid('id').defaultRandom().primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  passwordHash: varchar('password_hash', { length: 255 }).notNull(),
  fullName: varchar('full_name', { length: 255 }).notNull(),
  phone: varchar('phone', { length: 50 }),
  role: varchar('role', { length: 20 }).default('user').notNull(),
  avatarUrl: text('avatar_url'),
  isActive: boolean('is_active').default(true).notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
});

Roles: admin, event_admin, photographer, user — each with different access levels.

Events Table

export const events = pgTable('events', {
  id: uuid('id').defaultRandom().primaryKey(),
  name: varchar('name', { length: 500 }).notNull(),
  slug: varchar('slug', { length: 500 }).notNull().unique(),
  description: text('description'),
  eventDate: date('event_date').notNull(),
  location: varchar('location', { length: 500 }),
  city: varchar('city', { length: 255 }),
  coverImageUrl: text('cover_image_url'),
  status: varchar('status', { length: 20 }).default('upcoming').notNull(),
  createdBy: uuid('created_by').references(() => users.id),
}, (table) => [
  index('idx_event_slug').on(table.slug),
  index('idx_event_status').on(table.status, table.eventDate),
]);

Status flow: upcomingactivecompletedarchived

Photos Table

export const photos = pgTable('photos', {
  id: uuid('id').defaultRandom().primaryKey(),
  eventId: uuid('event_id').references(() => events.id, { onDelete: 'cascade' }).notNull(),
  photographerId: uuid('photographer_id').references(() => users.id).notNull(),
  originalUrl: text('original_url').notNull(),
  previewUrl: text('preview_url').notNull(),
  thumbnailUrl: text('thumbnail_url'),
  fileSize: bigint('file_size', { mode: 'number' }),
  width: integer('width'),
  height: integer('height'),
  downloadCount: integer('download_count').default(0).notNull(),
  status: varchar('status', { length: 20 }).default('active').notNull(),
}, (table) => [
  index('idx_photo_event').on(table.eventId, table.status),
]);

Photos are stored as S3 keys (not URLs). Presigned URLs are generated on-the-fly for secure access.

Bib Tag Table

export const photoBibTags = pgTable('photo_bib_tags', {
  id: uuid('id').defaultRandom().primaryKey(),
  photoId: uuid('photo_id').references(() => photos.id, { onDelete: 'cascade' }).notNull(),
  bibNumber: varchar('bib_number', { length: 50 }).notNull(),
  taggedBy: varchar('tagged_by', { length: 20 }).default('manual').notNull(),
  confidenceScore: decimal('confidence_score', { precision: 5, scale: 4 }),
}, (table) => [
  index('idx_bib_search').on(table.bibNumber, table.photoId),
]);

taggedBy values:

  • manual — photographer tagged it by hand
  • ocr_auto — OCR detected it automatically
  • ocr_verified — OCR detected + human confirmed

Supporting Tables

  • event_photographers — many-to-many: which photographers are assigned to which events
  • download_logs — tracks every download with IP and user agent
  • platform_settings — key-value store for global config (like ocr_provider)

API Design

Express.js with a modular route structure. Each feature is its own module with routes, controller logic, and middleware:

Auth Module

POST /api/auth/register     → Register new user
POST /api/auth/login        → Login, get access + refresh JWT tokens
POST /api/auth/refresh      → Refresh expired access token
GET  /api/auth/me           → Get current authenticated user

Events Module

GET  /api/events            → List all events (with photo counts)
GET  /api/events/:slug      → Get event by slug
POST /api/events            → Create event (admin only)
PUT  /api/events/:id        → Update event (admin only)
POST /api/events/:id/assign → Assign photographer to event (admin only)

Photos Module

POST /api/photos/upload          → Upload single photo (photographer+)
POST /api/photos/bulk-upload     → Upload up to 500 photos at once
GET  /api/photos/event/:eventId  → List photos with presigned URLs + bib tags
DELETE /api/photos/:id           → Soft-delete photo
POST /api/photos/:id/tag-bib     → Manual bib number tagging

Search & Download

GET /api/search?event=slug&bib=123   → Search photos by bib number
GET /api/photos/:id/download         → Get presigned download URL

Photo Upload Flow

Here’s what happens when a photographer uploads a photo:

router.post('/upload', authMiddleware, requireRole('photographer'), upload.single('photo'), async (req, res) => {
  const { eventId } = req.body;
  const file = req.file;

  // 1. Generate unique S3 key
  const key = `events/${eventId}/originals/${Date.now()}-${randomHex()}.${ext}`;

  // 2. Upload to MinIO/S3
  await uploadToS3(key, file.buffer, file.mimetype);

  // 3. Save metadata to database
  const [photo] = await db.insert(photos).values({
    eventId,
    photographerId: req.user.userId,
    originalUrl: key,
    fileSize: file.size,
  }).returning();

  // 4. Enqueue OCR job (async — doesn't block response)
  await enqueueOCR(photo.id, key, file.buffer);

  res.status(201).json({ success: true, data: photo });
});

The OCR processing happens asynchronously via BullMQ — the photographer gets an instant response, and bib detection runs in the background.


The OCR Pipeline (Most Interesting Part)

This is the heart of the platform. I built a dual-provider OCR system with sophisticated preprocessing and confidence scoring.

Architecture

Photo Upload


BullMQ Queue (async)


┌──────────────────────┐
│   OCR Provider        │
│   (from settings)     │
├──────────────────────┤
│  Google Vision API    │ ← ~95% accuracy, $1.50/1000 images
│  OR                   │
│  Tesseract.js         │ ← ~50% accuracy, free, local
└──────────────────────┘


Confidence Scoring

    ├── ≥ 75% → Auto-tag immediately
    ├── ≥ 40% → Tag for review
    └── < 40% → Skip (noise)

Tesseract Provider (Free — Default)

The Tesseract provider is the most complex part. It uses a multi-pass strategy to maximize detection:

Pass 1: Raw full image OCR

  • Resize large images (>2000px width) for performance
  • Run Tesseract with 3 different PSM (Page Segmentation Mode) modes: 11, 6, 3
  • Extract all digit sequences from raw text output

Pass 2: Preprocessed full image

  • Convert to grayscale
  • Normalize contrast
  • Apply sharpening
  • Run multi-PSM OCR again

Pass 3: Digit-only OCR

  • Force Tesseract to only look for digits (0-9 whitelist)
  • PSM 6 mode — treats image as a uniform block of text
  • Better at finding numbers in noisy backgrounds

Pass 4: Torso crop region

  • Crop to the upper-center 60% width × middle 40% height — where bibs typically appear on runners
  • Preprocess (grayscale + sharpen)
  • Run both multi-PSM and digit-only OCR
  • Boost confidence scores for crop matches (they’re in the bib zone)

Scoring algorithm:

// Each candidate gets a confidence score based on:
// 1. Occurrence count (found in multiple passes = higher confidence)
// 2. Digit length (5 digits = standard bib format = big boost)
// 3. Tesseract's own confidence score

const occurrenceBoost = occurrences >= 4 ? 0.82
  : occurrences >= 3 ? 0.72
  : occurrences >= 2 ? 0.60
  : 0.45;

// Digit length modifier
if (digitLength === 5) occurrenceBoost += 0.20;    // Standard bib
else if (digitLength >= 4 && <= 6) occurrenceBoost += 0.10;
else if (digitLength <= 3) occurrenceBoost *= 0.6;  // Likely noise

// Final: weighted blend
const confidence = (occurrenceBoost * 0.7) + (tesseractConfidence * 0.3);

False positive filtering:

  • Years (2020-2027) are filtered out — they’re everywhere on banners
  • Numbers under 2 digits are ignored
  • Numbers over 7 digits are ignored

Google Vision Provider (Paid — Higher Accuracy)

The Google Vision provider is simpler but more accurate:

const [result] = await client.textDetection(imageBuffer);
const detections = result.textAnnotations;

// Extract bib patterns: 4-5 digit numbers, optionally prefixed with letters
const bibPatterns = [/\b(\d{4,5})\b/g, /\b([A-Z]{1,3}\d{4,5})\b/g];

Google Vision handles the hard parts — it’s robust to angles, lighting, and partial occlusion. At ~95% accuracy, it’s reliable enough for auto-tagging.

Provider Switching

The platform stores the active OCR provider in the platform_settings table. Admin can switch between providers from the dashboard without restarting the server:

const [setting] = await db.select().from(platformSettings)
  .where(eq(platformSettings.key, 'ocr_provider'));
const providerName = setting?.value || 'tesseract';
const provider = getProvider(providerName);

Search Functionality

Participants search by bib number. Here’s how it works:

  1. User enters bib number on the public web app
  2. API queries the photo_bib_tags table indexed on (bib_number, photo_id)
  3. Join with photos to get image metadata
  4. Generate presigned URLs for thumbnails and previews
  5. Cache results in Redis for 5 minutes (same bib = same results)

The index on bib_number makes lookups fast even with millions of tag rows.


Frontend Architecture

Public Web App (Port 3000)

  • Browse all events with cover images
  • View event details and photo gallery
  • Search by bib number across events or within a specific event
  • Download photos with one click

Dashboard (Port 3002)

Photographer views:

  • My Events — assigned events with photo counts
  • Upload Photos — single or bulk upload with drag-and-drop
  • My Photos — grid view with bib tagging and soft-delete

Admin views:

  • Events — create, edit, change status
  • Photographers — register new photographers, assign to events
  • Settings — OCR provider, platform configuration

Role-Based Access Control

RoleAccess
adminEverything — manage events, users, settings, all photos
event_adminManage events, assign photographers, view reports
photographerUpload photos to assigned events, tag bibs, delete own photos
userBrowse events, search by bib, download photos

Middleware enforcement:

router.post('/upload', authMiddleware, requireRole('photographer', 'admin'), handler);

Key Design Decisions

1. Presigned URLs Instead of Serving Files

Photos are never proxied through the API. Instead, I generate S3 presigned URLs that expire after 1 hour. This keeps the API lightweight and lets MinIO/S3 handle the heavy lifting of serving images.

2. Soft Delete for Photos

When a photographer “deletes” a photo, I just set status = 'deleted' instead of removing it from S3. This prevents accidental data loss and allows recovery.

3. Async OCR with BullMQ

OCR processing (especially Tesseract) is CPU-intensive. Running it synchronously would block the upload response. BullMQ handles queuing, concurrency (5 workers), retries, and failure logging.

4. Monorepo with Shared Packages

The packages/types and packages/database packages are shared across all three apps. Change a database column type, and the API + both frontends get the update automatically.

5. S3-Compatible Storage

Using MinIO locally means the same code that works in development can switch to AWS S3 in production by changing environment variables. No code changes needed.


Getting Started

# 1. Install dependencies
bun install

# 2. Start infrastructure
docker compose up -d

# 3. Run database migrations
cd packages/database && bun run db:push

# 4. Seed sample data (optional)
bun run db:seed

# 5. Start all apps
bun run --filter './apps/*' dev

The platform will be running at:


What I Learned

Building this platform taught me several things:

  1. OCR is harder than it looks. Bib numbers are small, often blurred, sometimes rotated. The multi-pass Tesseract approach was necessary because no single OCR configuration catches everything.

  2. Job queues are essential for image processing. Synchronous OCR would have killed upload performance. BullMQ with Redis made the async pipeline reliable.

  3. S3 presigned URLs are the right pattern. Never proxy large files through your API. Let the storage layer serve them directly.

  4. Monorepos scale well for full-stack projects. Shared types and database schema across apps eliminated entire categories of bugs.

  5. Confidence scoring needs domain knowledge. Generic OCR confidence isn’t enough. Knowing that bibs are 4-5 digits, typically on the torso, and that year numbers are false positives dramatically improved accuracy.


What’s Next

Some features I’m considering for v2:

  • Face recognition — match runners by face when bib isn’t visible
  • Real-time processing — WebSocket notifications when OCR completes
  • Watermarking — automatic watermarked previews for unpaid downloads
  • Payment integration — let photographers sell high-res downloads
  • Mobile app — Flutter companion app for on-site photo uploads

The full source code is available on GitHub — it’s open source!

Comments