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 |
| 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:
- Google Maps API to pull DJ businesses within radius of each city centroid
- Crawl4AI for deep scraping (website emails, bios) - killed after OOM at ~3,000 sites
- 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 });
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,
}]);
Search with a bounding box or radius:
const results = await index.search(query, {
filter: `_geoRadius(${lat}, ${lng}, ${radiusMeters})`,
facets: ['genres', 'state'],
sort: ['avgRating:desc'],
});
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 };
}
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 }
});
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
LocalBusinessmarkup 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)