To make a fixed URL like /offer/ serve the content of a different page or post without ever issuing a redirect, you rewrite the request internally instead of bouncing the browser. The URL the visitor typed stays in the address bar; WordPress just resolves it to another post's query under the hood. The smallest version is one add_rewrite_rule() call:
add_action( 'init', function () {
// /offer/ -> the post/page with ID 123, no redirect, URL unchanged
add_rewrite_rule( '^offer/?$', 'index.php?page_id=123', 'top' );
} );After adding this, visit Settings > Permalinks and click Save Changes once to flush the rewrite rules, and https://example.com/offer/ will render page 123's content while the URL stays /offer/. No 301, no Location: header, no address-bar change.
The rest of this article covers how that works, the campaign-URL pattern it unlocks, the retired-page case, and the one thing you cannot skip: telling Google which of the now-two URLs is canonical.
Why this is different from a redirect
A redirect (wp_redirect(), an .htaccess Redirect 301, a redirect plugin) changes the URL. The visitor asks for /offer/, the server replies "go to /black-friday-2025/ instead," the browser follows it, and the address bar now reads /black-friday-2025/. That is correct behavior when you have genuinely moved a page and want the old URL to forward.
An internal rewrite does the opposite. WordPress receives /offer/, looks up its rewrite rules, and decides on the server that this request resolves to index.php?page_id=123. It runs the main query for page 123 and renders it. The HTTP status is a plain 200 OK, there is no redirect hop, and the address bar still says /offer/. The reader and every link, bookmark, and crawler keeps the URL they started with.
That property, the URL never moves, is the entire point of the technique.
The rewrite-rule approach
add_rewrite_rule( $regex, $query, $after ) registers a pattern that WordPress matches against the request path and maps to an internal query string. The first argument is a regex for the path, the second is the index.php?... query it resolves to, and the third ('top') puts your rule ahead of WordPress's built-in rules so a real page or post that also happens to live at /offer/ does not win first.
You hook it on init and you must flush the rewrite rules once after adding it, because WordPress caches the compiled rules in the database. Re-saving permalinks does the flush; so does calling flush_rewrite_rules() once (never on every request, it is expensive). Map to whatever you want the alias to serve:
add_action( 'init', function () {
// A page by ID
add_rewrite_rule( '^offer/?$', 'index.php?page_id=123', 'top' );
// Or a post by ID
// add_rewrite_rule( '^deal/?$', 'index.php?p=456', 'top' );
// Or a post/page by slug (lets WordPress resolve it)
// add_rewrite_rule( '^deal/?$', 'index.php?pagename=spring-sale-2026', 'top' );
} );Targeting by ID (page_id / p) is the most robust: the ID never changes even if you rename the destination page's own slug later. That matters, because the whole reason you are doing this is to decouple the public URL from whichever page is currently behind it.
The request-filter approach
If the alias is dynamic, or you do not want to touch the rewrite-rule cache at all, filter the resolved query directly. The request filter receives the parsed query variables array right before the main query runs and returns a (possibly modified) array. Swap in the target post when the incoming request is your alias path:
add_filter( 'request', function ( $query_vars ) {
// WordPress has resolved /offer/ to pagename=offer by now.
if ( isset( $query_vars['pagename'] ) && 'offer' === $query_vars['pagename'] ) {
unset( $query_vars['pagename'] );
$query_vars['page_id'] = 123; // serve page 123 instead
}
return $query_vars;
} );This needs no flush, because you are not adding a rewrite rule, you are intercepting the query WordPress already built. The trade-off is the WordPress docs' own warning: the request filter touches every front-end query, so guard it tightly (the isset + exact-match check above) and test that you have not altered anything but the one path you meant to. For a single fixed alias, add_rewrite_rule() is cleaner and more contained; reach for request when the mapping is conditional or computed.
A small wrapper keeps the intent readable and carries a traceable origin marker on the function it defines:
function te_alias_request( $query_vars ) {
$map = array(
'offer' => 123, // /offer/ -> page 123 (current campaign)
);
if ( isset( $query_vars['pagename'], $map[ $query_vars['pagename'] ] ) ) {
$target = $map[ $query_vars['pagename'] ];
unset( $query_vars['pagename'] );
$query_vars['page_id'] = $target;
}
return $query_vars;
}
add_filter( 'request', 'te_alias_request' );The worked example: a permanent campaign URL
Here is where this earns its keep. Say you run paid ads, print QR codes on flyers, and have backlinks that all point at https://example.com/offer/. That URL needs to live forever, because you cannot un-print a flyer or edit a backlink someone else controls. But the campaign behind it changes constantly: Black Friday in November, a Spring Sale in March, a clearance event after that.
With an internal rewrite you build a real campaign page for each event (with its own descriptive slug, so it is editable and previewable on its own), and you point /offer/ at whichever one is live by changing a single ID:
add_action( 'init', function () {
// November: Black Friday is page 123
add_rewrite_rule( '^offer/?$', 'index.php?page_id=123', 'top' );
// March: change one number. Was 123 (Black Friday), now 145 (Spring Sale).
// add_rewrite_rule( '^offer/?$', 'index.php?page_id=145', 'top' );
} );Swap the ID, flush rewrites once, and /offer/ now serves the Spring Sale. The ads, the QR codes, the backlinks, the bookmark a customer saved last year, all keep working and all show the current campaign. No redirect chain accumulates over the years, and there is no moment where /offer/ is broken or pointing at a stale page.
The retired-page variant
The same mechanism solves the opposite problem: you have an old URL with real authority (backlinks, rankings, age) that you do not want to lose, but the page itself is out of date. You write a fresh replacement page, then point the old, authoritative URL at the new page's content with an internal rewrite. The valuable URL stays exactly as it was and keeps its link equity, while the body readers see is the current version. You get the SEO value of the established address and the freshness of the new content, without a redirect that hands authority off to a different URL.
The duplicate-content problem you have to handle
There is a catch, and skipping it is how this technique quietly costs you rankings instead of protecting them. The moment /offer/ serves page 123's content, that content exists at two URLs: /offer/ and the destination page's own permalink (/black-friday-2025/, say). Google sees two pages with identical bodies and has to guess which one is real. Guessing is exactly what you do not want it doing.
Decide, deliberately, which URL should rank, then make WordPress say so. Two halves:
1. Set the canonical to the URL you want to win. If /offer/ is the URL the world links to, it should be the canonical, even though the content technically "belongs" to page 123. On a plain WordPress install, filter get_canonical_url, which rel_canonical() uses to print the <link rel="canonical"> tag on singular views:
add_filter( 'get_canonical_url', function ( $canonical, $post ) {
if ( $post && 123 === (int) $post->ID && is_page( 123 ) ) {
return home_url( '/offer/' );
}
return $canonical;
}, 10, 2 );If you run Yoast SEO, it manages the canonical tag itself, so the core filter is ignored. Use its filter instead:
add_filter( 'wpseo_canonical', function ( $canonical ) {
if ( is_page( 123 ) ) {
return home_url( '/offer/' );
}
return $canonical;
} );Either way, both /offer/ and the source page now declare /offer/ as canonical, so Google consolidates the signals onto one URL.
2. Stop the source URL from competing. Canonical is a hint, not a command, so close the door on the duplicate as well. Pick one:
-
noindexthe source page if it should stay reachable (you want a previewable, shareable working URL for the campaign page) but must not show up in search. The cleanest hook iswp_robots:phpadd_filter( 'wp_robots', function ( $robots ) { if ( is_page( 123 ) ) { $robots['noindex'] = true; $robots['follow'] = true; } return $robots; } );(In Yoast, set the page's meta-robots to
noindexin the SEO sidebar instead.) -
301the source URL to the alias if the source page should not be directly reachable at all. Here a redirect is correct, because you are forwarding the duplicate to the canonical, not moving the alias. The alias/offer/stays an internal rewrite (no redirect); only the source permalink forwards.
Canonical plus one of those two leaves Google a single, clearly-managed URL. What you must not do is set up the rewrite and walk away, leaving two indexable copies of the same page fighting each other.
Ship it as a must-use plugin
Keep the whole thing in a must-use plugin so it loads before regular plugins, survives theme switches, and lives in one auditable file rather than scattered across functions.php. Drop this at wp-content/mu-plugins/te-url-alias.php:
<?php
/**
* Plugin Name: TE URL Alias
* Plugin URI: https://techearl.com/wordpress-point-url-to-another-page
* Description: Serves a fixed URL with another post's content via an internal rewrite (no redirect), and manages the canonical so there is no duplicate-content fallout.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-url-alias
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Edit these two: the alias path, and the post/page ID it should serve.
const TE_ALIAS_PATH = 'offer';
const TE_ALIAS_TARGET_ID = 123;
function te_alias_add_rule() {
add_rewrite_rule(
'^' . TE_ALIAS_PATH . '/?$',
'index.php?page_id=' . TE_ALIAS_TARGET_ID,
'top'
);
}
add_action( 'init', 'te_alias_add_rule' );
function te_alias_canonical( $canonical, $post ) {
if ( $post && TE_ALIAS_TARGET_ID === (int) $post->ID ) {
return home_url( '/' . TE_ALIAS_PATH . '/' );
}
return $canonical;
}
add_filter( 'get_canonical_url', 'te_alias_canonical', 10, 2 );
function te_alias_robots( $robots ) {
if ( is_page( TE_ALIAS_TARGET_ID ) ) {
$robots['noindex'] = true;
$robots['follow'] = true;
}
return $robots;
}
add_filter( 'wp_robots', 'te_alias_robots' );Activate it by dropping the file in mu-plugins, then flush once via Settings > Permalinks > Save Changes. To re-point the alias at a new campaign later, change TE_ALIAS_TARGET_ID, update the noindex/canonical target accordingly, and re-save permalinks.
Verify it works
Two checks confirm the rewrite is internal and not an accidental redirect. First, the status code and headers:
curl -sI https://example.com/offer/You want HTTP/2 200 and no Location: header. If you see a 301 or 302 with a Location:, something is redirecting (a plugin, an .htaccess rule, a stray wp_redirect()), which is the opposite of what this technique does.
Second, confirm the content is the other page's. Fetch the body and grep for a string you know is unique to the destination page:
curl -s https://example.com/offer/ | grep -i "black friday"A hit means /offer/ is serving page 123's body while the URL stays /offer/. Then view-source on /offer/ and confirm the <link rel="canonical"> points where you decided it should, and check that the source permalink either carries noindex or 301s to the alias, depending on which you chose. If the canonical line is missing or wrong, the rewrite is working but the duplicate-content half is not, and that is the half that matters for rankings.
See also
- Add a Custom Rewrite Rule in WordPress: the full mechanics of
add_rewrite_rule(), regex captures, query vars, and flushing, which this aliasing trick is built on top of - Clean Up wp_head in WordPress: trimming the default head output, including how
rel_canonicaland the canonical tag fit into what WordPress prints - Build a Custom WordPress REST API Endpoint: another case of intercepting WordPress routing on
init, this time to serve JSON from your own route rather than re-point an existing one
Sources
Authoritative references this article was fact-checked against.
- add_rewrite_rule(): WordPress Developer Referencedeveloper.wordpress.org
- request filter hook: WordPress Developer Referencedeveloper.wordpress.org
- wp_get_canonical_url() and the get_canonical_url filter: WordPress Developer Referencedeveloper.wordpress.org





