Building a directory site with WordPress for production
We've all seen the directory site: local contractors, rental properties, service providers, or product marketplaces. Most of them start as a quick WordPress plugin install, then struggle under their own weight when traffic climbs or feature requests pile up. This article walks through building a directory site with WordPress for production—one that scales, doesn't leak data, and generates revenue.
Content model: Custom post types and taxonomies
A directory is only as good as its data structure. We need to think beyond the default WordPress post type and build something intentional. For a local services directory (think plumbers, electricians, HVAC), we'd start with a custom post type for each listing.
function register_listing_post_type() {
register_post_type( 'listing', array(
'label' => 'Listings',
'public' => true,
'has_archive' => true,
'rewrite' => array( 'slug' => 'listings' ),
'supports' => array( 'title', 'editor', 'thumbnail', 'custom-fields' ),
'taxonomies' => array( 'service_category', 'location', 'rating_level' ),
) );
}
add_action( 'init', 'register_listing_post_type' );
This gives us a dedicated listing post type with structured data. Now we need taxonomies to organize them. A directory without proper categorization is just a haystack.
function register_listing_taxonomies() {
register_taxonomy( 'service_category', 'listing', array(
'label' => 'Service Categories',
'hierarchical' => true,
'rewrite' => array( 'slug' => 'service' ),
) );
register_taxonomy( 'location', 'listing', array(
'label' => 'Locations',
'hierarchical' => true,
'rewrite' => array( 'slug' => 'location' ),
) );
register_taxonomy( 'rating_level', 'listing', array(
'label' => 'Rating Tiers',
'hierarchical' => false,
) );
}
add_action( 'init', 'register_listing_taxonomies' );
In production, we've added custom fields via Advanced Custom Fields (ACF) for data like phone number, email, business hours, and service area radius. Store these in post meta, not in the post content. It keeps your admin UI clean and queries efficient.
Real scenario: A real estate directory we built had listings for properties in three regions. By using hierarchical location taxonomies (Region > City > Neighborhood), we could generate region-specific landing pages automatically. The client could manage geography without touching code.
Search and filtering
A directory without good search is unusable. Users expect to filter by location, category, and sometimes rating. WordPress's native post query is powerful but can bog down under filter complexity.
We typically reach for SearchWP or Relevanssi for better search relevance, but for a lean setup, native WP_Query with some optimization works fine:
function get_filtered_listings( $filters = array() ) {
$args = array(
'post_type' => 'listing',
'posts_per_page' => 20,
'paged' => get_query_var( 'paged' ) ?: 1,
);
if ( ! empty( $filters['category'] ) ) {
$args['tax_query'][] = array(
'taxonomy' => 'service_category',
'field' => 'slug',
'terms' => $filters['category'],
);
}
if ( ! empty( $filters['location'] ) ) {
$args['tax_query'][] = array(
'taxonomy' => 'location',
'field' => 'slug',
'terms' => $filters['location'],
);
}
if ( ! empty( $filters['search'] ) ) {
$args['s'] = sanitize_text_field( $filters['search'] );
}
if ( ! empty( $args['tax_query'] ) ) {
$args['tax_query']['relation'] = 'AND';
}
return new WP_Query( $args );
}
On the front end, we render filter controls as a form. Using AJAX to refresh results without a page reload is table stakes now. A simple Fetch call beats a full page reload every time:
function handle_listing_filter() {
check_ajax_referer( 'listing_filter_nonce', 'nonce' );
$filters = array(
'category' => sanitize_text_field( $_POST['category'] ?? '' ),
'location' => sanitize_text_field( $_POST['location'] ?? '' ),
'search' => sanitize_text_field( $_POST['search'] ?? '' ),
);
$query = get_filtered_listings( $filters );
ob_start();
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
get_template_part( 'parts/listing-card' );
}
} else {
echo '<p>No listings found.</p>';
}
wp_reset_postdata();
wp_send_json_success( array( 'html' => ob_get_clean() ) );
}
add_action( 'wp_ajax_listing_filter', 'handle_listing_filter' );
add_action( 'wp_ajax_nopriv_listing_filter', 'handle_listing_filter' );
Key point: Paginate results. Loading 500 listings at once kills both the database and the browser. We paginate to 20 per page and let users navigate.
Front-end submissions and moderation
A directory lives or dies by user-generated content. If submitting a listing is too hard, it won't happen. We build a simple front-end form using a shortcode.
function listing_submission_form() {
if ( ! is_user_logged_in() ) {
return '<p>Please <a href="' . wp_login_url() . '">log in</a> to submit a listing.</p>';
}
ob_start();
?>
'service_category', 'hide_empty' => false ) );
foreach ( $categories as $cat ) {
echo '';
}
?>
Submit Listing
When the form submits, create a listing with status "pending":
if ( $_SERVER['REQUEST_METHOD'] === 'POST' && isset( $_POST['listing_nonce'] ) ) {
if ( ! wp_verify_nonce( $_POST['listing_nonce'], 'submit_listing' ) ) {
wp_die( 'Nonce verification failed.' );
}
$listing_id = wp_insert_post( array(
'post_type' => 'listing',
'post_title' => sanitize_text_field( $_POST['listing_title'] ),
'post_content' => sanitize_textarea_field( $_POST['listing_description'] ),
'post_status' => 'pending',
'post_author' => get_current_user_id(),
) );
if ( $listing_id ) {
update_post_meta( $listing_id, '_listing_email', sanitize_email( $_POST['listing_email'] ) );
update_post_meta( $listing_id, '_listing_phone', sanitize_text_field( $_POST['listing_phone'] ) );
wp_set_post_terms( $listing_id, sanitize_text_field( $_POST['listing_category'] ), 'service_category' );
wp_redirect( add_query_arg( 'submitted', '1' ) );
exit;
}
}
Pending listings go to the admin for review. You can automate basic checks—require phone format validation, check for spam keywords, or use Akismet—but manual review of the first few is wise.
Monetization with WooCommerce
Directories make money by charging for featured listings. WooCommerce isn't always the obvious choice, but it's battle-tested and handles payment processing cleanly.
Create a "Featured Listing" product. When purchased, a custom action flags a listing as featured and sets an expiration date:
function handle_featured_listing_purchase( $order_id ) {
$order = wc_get_order( $order_id );
if ( $order->get_status() !== 'completed' ) {
return;
}
foreach ( $order->get_items() as $item ) {
if ( $item->get_product_id() === FEATURED_PRODUCT_ID ) {
$listing_id = $item->get_meta( 'listing_id' );
$expiry = strtotime( '+30 days' );
update_post_meta( $listing_id, '_featured', 1 );
update_post_meta( $listing_id, '_featured_expiry', $expiry );
}
}
}
add_action( 'woocommerce_order_status_completed', 'handle_featured_listing_purchase' );
Then query featured listings separately in your templates:
$featured = new WP_Query( array(
'post_type' => 'listing',
'meta_query' => array(
array(
'key' => '_featured',
'value' => 1,
'compare' => '=',
),
array(
'key' => '_featured_expiry',
'value' => time(),
'compare' => '>',
'type' => 'NUMERIC',
),
),
) );
A cron job should clean up expired featured flags nightly. In production, we've added a "Promote" button on a user's dashboard that opens the featured product in a modal, making upgrade seamless.
Common mistakes
- Not setting up post status workflows: Pending, published, and rejected all need clear logic. Automate rejections for spam or missing data.
- Storing structured data in post content: Use post meta or custom fields. Your queries will be faster and your data cleaner.
- Ignoring security on front-end forms: Always sanitize input, verify nonces, and rate-limit submissions per user or IP.
- Overlooking pagination performance: LIMIT queries at 20-50 rows per page. Full-table scans kill databases under load.
- Building custom payment logic: Use WooCommerce or Stripe. Don't roll your own.
- Forgetting expiration logic for paid features: A cron job that checks and removes expired flags should run nightly.
Key takeaways
- Build a custom post type for listings and use hierarchical taxonomies for categories and locations. This structure scales with your directory.
- Filter with AJAX to keep the UX fast. Paginate results strictly—20 per page is a safe default.
- Require login for submissions and set listings to pending by default. Moderation is a feature, not overhead.
- Charge for featured listings via WooCommerce. Store expiry dates in post meta and clean them up nightly with a cron job.
- Sanitize and validate all user input. Verify nonces on every form. A directory is a target for spam.






Comments
Loading comments…