DEV Community

Cover image for I Built a Free Business Directory for Small Business Owners — Here's the Tech Stack and What I Learned
Junaid Ali
Junaid Ali

Posted on

I Built a Free Business Directory for Small Business Owners — Here's the Tech Stack and What I Learned

I've been building web tools for small business owners
for over a year now.

52+ free tools live on SmallBusinessOwners.co — invoice
generators, proposal writers, rate calculators, contract
builders. All free, all built for one audience.

But the thing I kept hearing from users was always the same:

"I have the tools. I just can't get found."

So I built a Business Directory on top of the existing platform.

This post is about the technical decisions I made,
what worked, what didn't, and what I'd do differently.


The Tech Stack

Here's what I chose and why:

Frontend — Next.js 15 on Vercel

// ISR — listings update within 60 seconds of approval
export const revalidate = 60
Enter fullscreen mode Exit fullscreen mode

Next.js 15 with ISR (Incremental Static Regeneration)
was the obvious choice. Directory pages need to be:

  • Fast (static serving)
  • Fresh (new listings appear quickly)
  • SEO friendly (each listing needs its own URL)

ISR with revalidate: 60 means every listing page
is statically generated but updates within 60 seconds
of a change in WordPress.

Perfect for a directory.


Backend — Headless WordPress

This was the controversial choice.

Most devs would reach for Supabase or a custom API here.

I chose WordPress because:

  1. Non-technical moderation — I need to approve/reject
    listings without touching code. WordPress admin is
    perfect for this.

  2. ACF (Advanced Custom Fields) — 21 custom fields
    per listing, exposed via REST API. No custom API
    endpoints needed.

  3. Already familiar — fast to ship

The WordPress REST API returns all ACF fields like this:

{
  "id": 42,
  "slug": "my-business",
  "title": { "rendered": "My Business Name" },
  "acf": {
    "owner_name": "John Smith",
    "business_name": "My Business",
    "category": "freelancer",
    "description": "We build things...",
    "country": "United States",
    "city": "New York",
    "website_url": "https://mybusiness.com",
    "listing_status": "approved",
    "is_featured": false,
    "is_verified": true,
    "founding_member": true,
    "view_count": 47
  }
}
Enter fullscreen mode Exit fullscreen mode

Next.js fetches this server-side only.
WordPress URL never exposed to the browser.


Auth — Supabase

// Protect the submit page
export default async function SubmitPage() {
  const user = await requireAuth()
  // redirects to /auth/login if not authenticated

  return 
}
Enter fullscreen mode Exit fullscreen mode

Supabase handles:

  • Email/password signup
  • Google OAuth
  • Session management

When a user submits a listing, their Supabase user ID
and email are attached to the WordPress post via ACF fields.

This lets me:

  • Know who submitted what
  • Let users edit their own listing later
  • Prevent duplicate submissions

Image Uploads

This one was tricky.

The form needs profile photos and cover photos.
I didn't want to deal with S3/Cloudinary for MVP.

Solution: upload directly to WordPress Media Library
via a custom REST endpoint:

// WordPress functions.php
add_action('rest_api_init', function() {
  register_rest_route('custom/v1', '/upload-image', [
    'methods'  => 'POST',
    'callback' => 'handle_image_upload',
    'permission_callback' => '__return_true',
  ]);
});
Enter fullscreen mode Exit fullscreen mode
// Next.js API route proxies the upload
// Never exposes WordPress credentials to browser
export async function POST(request: Request) {
  const formData = await request.formData()
  const file = formData.get('image') as File

  const wpFormData = new FormData()
  wpFormData.append('image', file)

  const response = await fetch(
    process.env.WP_API_URL + '/custom/v1/upload-image',
    {
      method: 'POST',
      headers: {
        'Authorization': 'Basic ' + process.env.WP_AUTH_TOKEN,
      },
      body: wpFormData,
    }
  )

  const data = await response.json()
  return Response.json({ success: true, url: data.url, id: data.id })
}
Enter fullscreen mode Exit fullscreen mode

The ACF Image field type expects an attachment ID (integer)
not a URL. Took me longer than I'd like to admit to figure
that one out.

// Wrong ❌
acf: { profile_photo: "https://..." }

// Correct ✅  
acf: { profile_photo: 42 } // attachment ID
Enter fullscreen mode Exit fullscreen mode

WordPress Authentication

Started with JWT tokens. Had expiry issues.

Switched to WordPress Application Passwords (built into
WP core since 5.6) with Basic Auth:

const credentials = btoa(`${username}:${appPassword}`)

headers: {
  'Authorization': `Basic ${credentials}`
}
Enter fullscreen mode Exit fullscreen mode

Never expires. Zero config. Should have started here.


The Data Model

21 ACF fields per listing:
Owner Info: owner_name, email, profile_photo
Business: business_name, category, subcategory,
description, services_tags
Location: country, city
Links: website_url, instagram_handle,
linkedin_url, twitter_handle
Contact: phone_number, business_hours, price_range
Community: support_message
Media: cover_photo
Admin: listing_status, is_featured, is_verified,
founding_member, view_count, submission_date,
submitter_email, submitter_id

Category is a Select field with these options:

  • freelancer
  • online_business
  • local_business
  • ecommerce
  • agency
  • other

listing_status controls what appears publicly.
Only approved listings are returned by the API.


The Moderation Flow

This was important to get right.

Every submission starts as pending.

// WordPress REST API filter
// Only returns approved listings to public
add_filter('rest_business_listing_query', 
function($args, $request) {
  if (!current_user_can('edit_posts')) {
    $args['meta_query'] = [[
      'key'   => 'listing_status',
      'value' => 'approved',
    ]];
  }
  return $args;
}, 10, 2);
Enter fullscreen mode Exit fullscreen mode

When I approve in WordPress admin:

  1. Change listing_statusapproved
  2. Click Update
  3. Within 60 seconds the listing appears on the site

No cache clearing. No redeployment. ISR handles it.


The Founding Member Idea

First 100 businesses get:

  • Free listing forever (even after we go paid)
  • Founding Member badge on their profile

This created urgency and gave early users a reason
to submit immediately instead of "maybe later".

// Badge display logic
{listing.acf.founding_member && (
  <span className="founding-badge">
    🏅 Founding Member
  </span>
)}
Enter fullscreen mode Exit fullscreen mode

What I'd Do Differently

1. Start with Application Passwords
Wasted 2 hours debugging JWT expiry issues.
WordPress Application Passwords work perfectly
and never expire.

2. Use Cloudinary for images
WordPress Media Library works but Cloudinary gives
you automatic optimization, CDN delivery, and
image transformations for free.

3. Add search earlier
Full text search is harder to add after the fact.
Should have built it from day one.

4. Rate limiting on the submit endpoint
Bots found the submission form. Add rate limiting
from the start:

// Should have done this from day one
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(3, '1 h'),
})
Enter fullscreen mode Exit fullscreen mode

Results So Far

  • ✅ Directory live and accepting submissions
  • ✅ First listings approved and showing
  • ✅ Founding Member spots filling up
  • ✅ Zero downtime since launch

The Stack Summary

Frontend: Next.js 15 + TypeScript + Tailwind CSS
Hosting: Vercel (frontend) + Hostinger (WordPress)
CMS: WordPress (headless) + ACF
Database: WordPress MySQL + Supabase (auth)
Auth: Supabase Auth + Google OAuth
Images: WordPress Media Library
API: WordPress REST API + ACF to REST API


Try It

If you're a freelancer or small business owner
the directory is free to join.

First 100 get a Founding Member badge.

👉 SmallBusinessOwners.co/directory


Happy to answer any technical questions in the comments.

What would you have built differently?

Top comments (0)