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
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:
Non-technical moderation — I need to approve/reject
listings without touching code. WordPress admin is
perfect for this.ACF (Advanced Custom Fields) — 21 custom fields
per listing, exposed via REST API. No custom API
endpoints needed.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
}
}
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
}
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',
]);
});
// 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 })
}
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
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}`
}
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);
When I approve in WordPress admin:
- Change
listing_status→approved - Click Update
- 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>
)}
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'),
})
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)