How to Redirect Draft Posts in WordPress Instead of Showing a 404 (Advanced & Technical)

When you unpublish a post (change its status from publish to draft) WordPress will normally return a 404 Not Found for that URL. For many sites (events, courses, promos) it’s better to redirect those requests to a friendly page (or a contextual landing) rather than serving 404s that frustrate users and may waste link equity.

This guide covers a production-ready implementation, internal rationale, performance considerations, security, logging, testing, multisite behavior, and alternatives (server-level redirects, plugins).

Background: Why hook wp and not init

WordPress builds the main query in the parsing stage so templates and conditional tags work later in the lifecycle. The init action runs too early — the main query (and $wp_query->is_404) isn’t reliable there. The wp action runs after WP has parsed the request and prepared the main query, which makes it the correct hook to inspect is_404 and the requested post slug ($wp_query->get(‘name’)) safely.

Production-ready implementation (annotated)

Create redirect-draft-posts.php in your child theme (or a small mu-plugin). The code below addresses edge cases, offers opt-in per post-type, supports private posts, and logs when needed.

<?php
/**
 * Redirect draft (and optionally private) posts to a custom URL instead of showing 404.
 * Recommended: put this in a mu-plugin (wp-content/mu-plugins/) or in your child theme and require it from functions.php.
 */

defined( 'WPINC' ) || die();

/**
 * Filterable constant-like settings (use filters to configure in themes/plugins).
 */
function wptrdp_get_options() {
    return apply_filters( 'wptrdp_options', [
        'redirect_url'    => 'https://yourdomain.com/content-no-longer-available/',
        'post_statuses'   => [ 'draft' ],       // add 'private' to include private posts
        'post_types'      => [ 'post' ],        // set to ['post','page','event'] or [] for all types
        'use_safe'        => true,              // use wp_safe_redirect() if true
        'status_code'     => 301,               // 301 or 302
        'log'             => true,              // log redirects (error_log)
        'allow_logged_in' => false,             // if true, logged-in users will be redirected too
    ] );
}

/**
 * Core redirector.
 */
function wptrdp_maybe_redirect_draft_404() {
    global $wp_query;

    $opts = wptrdp_get_options();

    // If options disable redirect for logged-in users, skip when logged in
    if ( ! $opts['allow_logged_in'] && is_user_logged_in() ) {
        return;
    }

    if ( empty( $wp_query ) || ! $wp_query->is_404 ) {
        return;
    }

    // Get post slug from main query. On attachments / pagination this may be absent.
    $post_name = $wp_query->get( 'name' );
    if ( empty( $post_name ) ) {
        return;
    }

    // Build query parameters - restrict by status and (optionally) type
    $query_args = [
        'name'           => $post_name,
        'post_status'    => $opts['post_statuses'],
        'posts_per_page' => 1,
        'fields'         => 'ids',
        'suppress_filters' => false,
    ];

    if ( ! empty( $opts['post_types'] ) ) {
        $query_args['post_type'] = $opts['post_types'];
    }

    $posts = get_posts( $query_args );

    if ( empty( $posts ) ) {
        return;
    }

    $post_id = (int) $posts[0];

    // Optional: ignore if this post has a meta flag to opt-out (per-post control)
    if ( get_post_meta( $post_id, '_wptrdp_no_redirect', true ) ) {
        return;
    }

    $redirect_to = $opts['redirect_url'];

    // Optional: allow per-post redirect target stored in post meta
    $per_post = get_post_meta( $post_id, '_wptrdp_redirect_to', true );
    if ( ! empty( $per_post ) ) {
        $redirect_to = $per_post;
    }

    if ( empty( $redirect_to ) ) {
        return;
    }

    // Logging
    if ( $opts['log'] ) {
        $msg = sprintf( 'wptrdp redirect: slug=%s post_id=%d to=%s ref=%s', $post_name, $post_id, $redirect_to, wp_get_referer() ?: 'direct' );
        error_log( $msg );
    }

    // Use safe redirect for external URLs; fall back to wp_redirect
    $status_code = intval( $opts['status_code'] ) === 302 ? 302 : 301;
    if ( $opts['use_safe'] && function_exists( 'wp_safe_redirect' ) ) {
        wp_safe_redirect( $redirect_to, $status_code );
    } else {
        wp_redirect( $redirect_to, $status_code );
    }
    exit;
}
add_action( 'wp', 'wptrdp_maybe_redirect_draft_404', 10 );Code language: HTML, XML (xml)

Notes

  • Put configuration via the filter wptrdp_options in a plugin or theme to avoid editing the file.
  • get_posts() with ‘fields’ => ‘ids’ is slightly lighter than retrieving full posts.
  • suppress_filters => false preserves filtering behavior (useful on multisite or custom query filters).
  • wp_safe_redirect() prevents open-redirects for external URLs. Use when redirect target may be external.

Customizations & advanced patterns

Redirect by post type

If you have custom post types like event, course, or product, include them in post_types or set to an empty array to search across all types.

Per-post redirect targets

Set a post meta _wptrdp_redirect_to to a full URL to redirect that specific draft to a unique page. Useful when you want the old URL to point to a successor article or archived detail.

Example to programmatically set per-post redirect:

update_post_meta( 123, '_wptrdp_redirect_to', 'https://yourdomain.com/new-event-archive/' );Code language: JavaScript (javascript)

Opt-out per-post

If certain drafts should still return 404s, add _wptrdp_no_redirect post meta with truthy value.

Permalinks, rewrite rules & canonical URLs

The code relies on the name query var ($wp_query->get(‘name’)). That works for default post permalinks and %postname%-style permalinks. If you use date-based permalinks (/%year%/%monthnum%/%postname%/) the main query still sets name, but there are cases (e.g., paged requests, queries with additional rewrite rules) where name may be empty. In those edge cases you can fall back to request or parse $_SERVER[‘REQUEST_URI’] — but that’s more error-prone.

Prefer checking $wp_query->get(‘pagename’) and $wp_query->get(‘name’) both where necessary.

Performance & caching

Object cache

get_posts() is cached by default in the object cache for the duration of the request. If an object cache (Redis/Memcached) is present, repeated queries for the same slug are cheap.

Full-page caching (Varnish / WP Super Cache / Nginx)

If you use full-page caching that serves cached HTML to anonymous users, the redirect needs to be generated before the page cache. Options:

  • Configure your page cache to bypass for 404 responses and allow WordPress to handle them.
  • Implement the redirect at the edge (Nginx / Varnish) by maintaining a mapping of expired slugs → redirects (requires synchronization).
  • For hosting platforms that cache aggressively, test in a staging environment.

CDN

If the CDN caches 404 responses, purging or setting correct cache-control headers for the “content-not-available” page is important.

Security & correctness

  • Use wp_safe_redirect() for external destinations to prevent open redirect attacks. It validates redirects to allowed hosts (useful if redirect URLs are data-driven).
  • Always call exit; (or wp_die() in certain contexts) after redirect to stop execution.
  • Avoid redirect loops: ensure the redirect destination returns 200 and does not itself 404/redirect back.

Logging & analytics

Use structured logging to trace redirects:

if ( defined('WP_DEBUG') && WP_DEBUG ) {
    error_log( json_encode([
        'time' => current_time('mysql'),
        'type' => 'wptrdp',
        'slug' => $post_name,
        'post_id' => $post_id,
        'redirect_to' => $redirect_to,
        'ref' => wp_get_referer(),
    ]) );
}Code language: PHP (php)

Send logs to a centralized logging system (Papertrail, ELK) in production rather than error_log.

For analytics, add a 302 redirect with a query parameter for tracking (but prefer 301 for SEO). Or log server-side events to Google Analytics / Measurement Protocol when a redirect occurs.

Testing: curl, WP-CLI, PHPUnit example

cURL check

curl -I -L "https://yourdomain.com/old-draft-slug/"
# Look for: HTTP/1.1 301 Moved Permanently
# Location: https://yourdomain.com/content-no-longer-available/Code language: PHP (php)

WP-CLI to find drafts matching slugs in list

You can quickly search for drafts by slug with WP-CLI:

wp post list --post_status=draft --field=ID,post_name,post_title --format=csv | grep 'old-draft-slug'Code language: PHP (php)

PHPUnit (basic example)

Using the WordPress PHPUnit harness:

public function test_redirect_for_draft_slug() {
    // Create a post, publish then change to draft
    $post_id = $this->factory->post->create( ['post_name' => 'test-draft-redirect', 'post_status' => 'draft'] );
    // Simulate 404 main query - this is complex; simplest is to run the callback directly with global $wp_query mock.
    global $wp_query;
    $wp_query = new WP_Query();
    $wp_query->is_404 = true;
    $wp_query->set('name', 'test-draft-redirect');

    // Capture headers sent by wp_redirect (you may need to mock the function)
    // Alternatively, run integration test against dev server and use cURL assertions.
}Code language: PHP (php)

Because wp_redirect() sends headers, integration tests may be easier using a local container and cURL.

Multisite & REST API considerations

  • On Multisite, ensure get_posts() runs on the correct blog. If slugs overlap across sites and you want site-local behavior, the default code is fine. For cross-site behavior use switch_to_blog() if desired.
  • REST API requests are not affected since the redirect is only triggered in the wp hook on front-end requests. If you want to block REST access to drafts for non-authenticated users, control that via rest_authentication_errors filter.

Alternatives

Server-level redirects

If your expiration workflow can produce a server-side mapping (e.g., generate an Nginx map or .htaccess rules), edge redirects are faster but harder to maintain.

Plugins

There’s no widely used plugin that specifically redirects draft slugs to an archive. Larger redirect plugins (Redirection, RankMath) can do manual mappings but not dynamic detection of draft slugs without custom code.

Robots / X-Robots-Tag

If you prefer search engines to keep the old URL as 404 (and eventually de-index), do nothing. Use X-Robots-Tag: noindex for pages you want de-indexed without redirecting.

Troubleshooting checklist

  • Test while logged out / incognito (logged-in users are exempt by default).
  • Confirm $wp_query->get(‘name’) returns the slug for your permalink structure.
  • Check page-cache / CDN / reverse-proxy isn’t serving old cached 404: purge caches after testing.
  • If redirect doesn’t happen: check plugin/theme conflicts, hook priority, and ensure file is loaded.
  • Avoid accidentally redirecting attachments or slug collisions — restrict by post_type where appropriate.

Leave a Reply

Your email address will not be published. Required fields are marked *