DEV Community

Tony Ciovacco
Tony Ciovacco

Posted on • Originally published at weddingdjfinder.com

How I Built a National Wedding DJ Directory: Next.js 16, Meilisearch, and 9,400 Scraped DJs

Last year I noticed a gap: couples searching for wedding DJs were stuck on Yelp, The Knot, or WeddingWire - all paywalled or cluttered with ads. DJs themselves had no affordable way to get qualified leads. I built WeddingDJFinder.com to fix that. Here's the technical story.

The Core Problem

Finding a wedding DJ is surprisingly painful. Most directories either:

  • Charge DJs $200+/mo for visibility (pricing out small operators)
  • Show couples generic paid placements instead of best-fit matches
  • Have no geographic filtering beyond "search by city name" (useless for metros spanning 3+ counties)

I wanted geo-aware search, real reviews, and a freemium model that let DJs get found for free.

Tech Stack

Layer Tech Why
Framework Next.js 16 (App Router) SSR for SEO-critical city pages
Database PostgreSQL + Prisma 7 Prisma 7's driver adapter pattern is excellent
Search Meilisearch Sub-50ms geo filtering, faceted search, hosted on EC2
Queue BullMQ + Redis Email notifications, search index sync, webhooks
Payments Stripe Subscriptions + webhook idempotency
Media AWS S3 + CloudFront DJ photo galleries, presigned uploads
Email Microsoft Graph API Office 365 transactional delivery
Hosting Laravel Forge + AWS EC2 (t3.large) PM2, nginx reverse proxy

The Data Problem: 9,400 DJs

You can't launch a directory with 0 listings. I scraped Google Maps for DJ profiles across every US metro - 9,431 DJs across 4,923 cities. The scraping pipeline used:

  1. Google Maps API to pull DJ businesses within radius of each city centroid
  2. Crawl4AI for deep scraping (website emails, bios) - killed after OOM at ~3,000 sites
  3. Firecrawl API to recover emails from remaining sites (7.7% hit rate after Crawl4AI)

Result: 4,924 DJs with emails (52.2% of total). The emailless DJs are hidden from public pages - a deliberate choice to keep quality high and avoid misleading couples with dead listings.

Prisma 7 Driver Adapter Pattern

Prisma 7 changed how you initialize the client. The old new PrismaClient() pattern doesn't work - you must pass a driver adapter:

import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
import { PrismaClient } from './generated/prisma/client';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
export const prisma = new PrismaClient({ adapter });
Enter fullscreen mode Exit fullscreen mode

The generated client goes in src/generated/prisma/ (not node_modules/@prisma/client). This trips up a lot of people upgrading from Prisma 5/6.

Meilisearch for Geo Search

Meilisearch's geo filtering is the core of the search experience. Each DJ gets indexed with their lat/long:

await index.addDocuments([{
  id: dj.id,
  name: dj.name,
  city: dj.city,
  state: dj.state,
  _geo: { lat: dj.latitude, lng: dj.longitude },
  genres: dj.genres.map(g => g.name),
  priceMin: dj.priceMin,
  avgRating: dj.avgRating,
  reviewCount: dj.reviewCount,
}]);
Enter fullscreen mode Exit fullscreen mode

Search with a bounding box or radius:

const results = await index.search(query, {
  filter: `_geoRadius(${lat}, ${lng}, ${radiusMeters})`,
  facets: ['genres', 'state'],
  sort: ['avgRating:desc'],
});
Enter fullscreen mode Exit fullscreen mode

The key gotcha: Meilisearch requires _geo (not geo or location). And you must add _geo to filterableAttributes in the index settings before you can use _geoRadius.

The Freemium Model

DJs start on a free tier - they show up in search results but can't receive inquiry messages. One free lead unlocks when they claim their profile. After that, they upgrade to Pro ($49/mo) to receive unlimited leads.

The "one free lead" is protected with an atomic database update:

const updated = await prisma.djProfile.updateMany({
  where: { id: djId, freeLeadsUsed: { lt: 1 } },
  data: { freeLeadsUsed: { increment: 1 } },
});
if (updated.count === 0) {
  // Already used their free lead - block until they upgrade
  return { blocked: true };
}
Enter fullscreen mode Exit fullscreen mode

The updateMany WHERE freeLeadsUsed < 1 is atomic - no race condition possible with concurrent requests.

BullMQ for Background Jobs

Three queues: email, search-index, and webhook. The Stripe webhook handler queues a search-index job so the DJ's new tier appears in results within seconds of payment:

// In Stripe webhook handler
await searchIndexQueue.add('sync-dj', { djId }, {
  attempts: 3,
  backoff: { type: 'exponential', delay: 2000 }
});
Enter fullscreen mode Exit fullscreen mode

One gotcha: don't install standalone ioredis alongside BullMQ - there's a version conflict. Use BullMQ's built-in connection handling with a parsed connection object instead of a raw URL.

SEO: 25,000 Pages in Search Console

City pages are the SEO backbone. With 2,156 launched cities (cities with at least one DJ with an email), each city gets a dedicated page at /[state-slug]/[city-slug]. These are server-rendered with:

  • Schema.org LocalBusiness markup for each DJ card
  • Dynamic <title> and meta description from city + DJ count
  • Internal links to nearby cities, metro pages, and genre pages
  • A conditional FAQ section (only shows questions with real data)

The key insight: never noindex a city page based on DJ count. Even a city with 1 DJ deserves to be indexed - it's a real page with real content and a local search signal. "Thin" pages get enhanced with surrounding content (nearby DJs in adjacent cities, related blog posts) rather than hidden.

What's Working

Traffic is early but the patterns are clear:

  • City + genre combination pages ("Chicago Latin wedding DJ") drive the most qualified traffic
  • Blog posts (50 city guides so far) generate long-tail from couple searches
  • Google crawled all 25K pages within 3 weeks of launching the sitemap

What I'd Do Differently

Avoid BullMQ + ioredis conflicts early. The version matrix is fragile. I'd use Upstash QStash or Vercel Cron for simpler background jobs.

Don't over-engineer the scraping pipeline. Crawl4AI running on a t3.large OOM-killed itself. Firecrawl's API is slower but infinitely more reliable. Pay for the API.

Prisma 7 generated client path matters. If you deploy to a serverless platform, the generated path needs to be predictable. outputPath: "src/generated/prisma" in your schema is the right move.


The full site is live at WeddingDJFinder.com. Happy to answer questions about any part of the stack.

Top comments (0)