Blog · programming, web development

Hide a WordPress post from listings but keep the URL – custom post type as “private”

2026-07-29

Private events you can’t advertise

Client calls: “We’re running a closed event for selected clients. Invitations go by email. But the event page needs to be accessible so people can sign up. Only we don’t want anyone accidentally landing on it from the blog or the «our events» section on the homepage”.

So: a WordPress post has to not appear anywhere we don’t want it to appear, but work under a direct URL. You can’t do this with the “private” post status in wp-admin, because there private means “visible only to logged-in admin” – and we want something different. Someone with a link has to enter without logging in.

The solution comes down to one ACF field and a few query filters in the theme.

ACF: “hide from listings” field

To the custom post type (in my case it was events – CPT called “events”) I add an ACF true/false field. I name it “hide_from_listings” and place it in the right column of the editor (sidebar), so it’s visible right at edit time.

Field configuration in the ACF JSON (to be added as a group in acf-json/):

{
    "key": "group_hide_from_listings",
    "title": "Post visibility",
    "fields": [
        {
            "key": "field_hide_from_listings",
            "label": "Hide from listings and pickers",
            "name": "hide_from_listings",
            "type": "true_false",
            "instructions": "The post stays accessible via direct URL, but won't appear in listings or pickers.",
            "ui": 1,
            "ui_on_text": "YES",
            "ui_off_text": "NO"
        }
    ],
    "location": [[{
        "param": "post_type",
        "operator": "==",
        "value": "events"
    }]],
    "position": "side"
}

Marketing gets a toggle. Flips it to YES – the post becomes “private”. Saves – done. The post URL still works, but disappears from listings. All the rest of the config stays the same.

Helper function – one place to edit

Before we hook into the main loop, I extract the meta_query array into a separate helper function. Because the same array will show up in a moment in several places – in pre_get_posts and in every ACF picker. When the ACF field name changes in the future, I edit one place instead of hunting across the theme.

/**
 * Returns a meta_query excluding posts with hide_from_listings = 1.
 * Used in pre_get_posts and in ACF blocks with pickers.
 */
function get_hide_from_listings_meta_query() {
    return array(
        'relation' => 'OR',
        array(
            'key'     => 'hide_from_listings',
            'compare' => 'NOT EXISTS',
        ),
        array(
            'key'     => 'hide_from_listings',
            'value'   => '1',
            'compare' => '!=',
        ),
    );
}

Filter on the main loop: /blog/, /news/ etc.

WordPress in most themes renders listings through the main loop – default WP_Query handled by the template hierarchy (index.php, home.php, archive.php). Hooking into that works through the pre_get_posts action.

I add to functions.php in the theme:

function my_exclude_hidden_from_loop( $query ) {
    if ( is_admin() || !$query->is_main_query() ) {
        return;
    }

    // Pages where we render listings - blog, author/tag archive,
    // search, CPT events archive (default /events/ listing)
    $is_listing = $query->is_home()
        || $query->is_author()
        || $query->is_tag()
        || $query->is_search()
        || $query->is_post_type_archive( 'events' );

    if ( $is_listing ) {
        // Preserve any existing meta_query (from other plugins)
        // and append ours as another condition
        $meta_query = $query->get( 'meta_query' ) ?: array();
        $meta_query[] = get_hide_from_listings_meta_query();
        $query->set( 'meta_query', $meta_query );
    }
}
add_action( 'pre_get_posts', 'my_exclude_hidden_from_loop' );

Two nuances worth pointing out. First – is_post_type_archive(‘events’). If a custom post type has has_archive => true, WordPress automatically generates a listing under /events/. Without this condition, hidden events would still show up there despite all the other filters.

Second – fetching the existing meta_query via $query->get(‘meta_query’) ?: array() and appending ours instead of overwriting. If another plugin (WooCommerce, MemberPress, whatever) also adds a meta_query on the same loop, our filter appends alongside instead of overwriting. May never be needed, but costs one extra line.

Why relation OR with NOT EXISTS

This piece is crucial and not obvious at first glance. I want to exclude posts marked as hidden – but MOST posts don’t have this meta field at all (because I added it only for events, but the listing also includes regular post, case-studies etc.).

If I wrote a simpler query like “hide_from_listings != 1”, WordPress would exclude posts marked as 1, but also skip all those that don’t have this meta at all (because in SQL NULL is not equal to “1” but also not “different from 1” in the classic sense).

Solution: OR with two conditions. Take a post if it has no meta field (NOT EXISTS) OR has a meta field with a value other than 1 (!=). The first catches the mass of posts without this field, the second catches posts consciously marked as public. Only those with a value of exactly “1” are excluded.

Filter on ACF pickers in the theme

The theme has “selected posts” blocks with ACF config – on the homepage sliders, in the footer featured posts, on topic pages carousels. All of them use WP_Query or get_posts, but they don’t go through pre_get_posts (because that’s not the main loop).

Each of these blocks needs the same meta_query. Thanks to the helper function, the call in the ACF block comes down to a single line:

// In the block template posts-picker.php or events-picker.php:
$picked_posts = get_posts(array(
    'post_type'      => 'events',
    'posts_per_page' => 4,
    'orderby'        => 'date',
    'order'          => 'DESC',
    'post_status'    => 'publish',
    'meta_query'     => array( get_hide_from_listings_meta_query() ),
));

You can’t do this globally with one filter – because pickers build queries differently and render differently. But the helper function call is short and readable. In my case I had to add it in three places: two ACF blocks with pickers and the main blog listing loop.

What it does NOT do

Honestly: this solution does NOT set the post as noindex for Google. The post still has a URL, still appears in the SEO plugin’s XML sitemap (in my case Yoast), still can show up in Google search results when someone types a phrase matching the content.

The client wanted ONLY: don’t show in listings on the site. Google indexing is a separate matter. If in a given case Google needed to be blocked too – I’d add a Yoast filter to mark these posts as noindex. But that’s a different business decision and a different layer.

Summary

Three blocks, whole workflow:

  • ACF true/false field in the right editor column – marketing sets visibility themselves
  • pre_get_posts filter for main loops (blog, tag, author, search, CPT archive)
  • Meta_query in each picker ACF in the theme – inline in the block PHP

The whole mechanism is based on one meta field, filters that don’t touch post_status and don’t generate 404s. The direct URL works unchanged. Marketing gets control without the risk of breaking the site.

Back to all posts

Got a project to talk about?

Just tell me what you need - it does not have to be technical, that is my job. I will get back to you and tell you straight whether and how I can help.