An editor hits publish on a breaking story and pushes it to a million followers.
The headline is sharp. The link beneath it is forty characters of
%D8%A7%D9%84%D8%B0..., unreadable, often cut off by the platform, telling the
reader nothing about where the tap will take them.
That is what a long URL looks like on social when the slug is Arabic. WordPress
stores and emits the slug percent-encoded, so a clean Arabic headline turns into
a wall of %XX bytes the moment it leaves your site:
/الذكاء-الاصطناعي → /%D8%A7%D9%84%D8%B0%D9%83%D8%A7%D8%A1-%D8%A7%D9%84%D8%A7%D8%B5%D8%B7%D9%86%D8%A7%D8%B9%D9%8A
(We have written before about how those Arabic slugs get truncated in the first
place. This is the other half of the
story: what happens to them out in the world.)
Every serious newsroom solves this the same way, with branded short links:
sho.rt/9Kx2a instead of the wall of bytes. Short, on brand, and the same length
whether the article is Arabic, English, or anything else. The only real question
is whether you buy that capability from a service like Bitly or TinyURL, or build
it yourself.
We did both, on purpose. Here is why, and how.
Why both
A short link, once shared, is effectively permanent. It lives in tweets, Telegram
channels, WhatsApp forwards, and printed QR codes for years. So the day a vendor
raises its prices, retires a feature, or simply shuts down, every one of those
links dies, and the traffic with them. For a newsroom whose archive is its
capital, that is not an acceptable dependency.
So we set one rule: the ability to resolve our own short links must never depend
on a company we do not control. Buy the convenience, own the lifeline.
In practice that means Bitly serves the short domain today, because it gives the
newsroom polished click analytics and a link UI editors like. But sitting behind
it, fully in sync, is an in-house resolver running on our own AWS edge, ready to
take over with a single DNS change. The reader never sees the seam, because the
short codes are identical on both fronts. This runs on the same WordPress and AWS
stack we have written about before:
WordPress on EC2 behind an Application Load Balancer, Aurora for the database, and
CloudFront at the edge.
One short code, two interchangeable fronts. DNS decides who answers, so failover is a single DNS change, and the reader always lands on the same canonical article.
The one decision everything hangs on
Before any infrastructure, there is a single design choice that makes the rest
possible: one short ID, minted once in WordPress, reused everywhere. The same
string is the key in our database, the key in DynamoDB, and the custom back-half
on Bitly.
Because 9Kx2a is literally the same value in all three places, switching who
answers the short domain is invisible to anyone holding the link. There is no
remapping, no lookup table between vendors, no links left behind. That one
decision is what turns "own the lifeline" from a slogan into a DNS change.
Everything below serves it.
Generating the ID
The ID is drawn from a 64-character, URL-safe alphabet: A-Z, a-z, 0-9, plus
- and _. Those 64 characters all live in a URL path without encoding, so the
short link stays clean no matter what.
We generate it only when a post is published, never on draft. That keeps the
keyspace clean (no IDs burned on posts that never go live), avoids leaking a URL
for unpublished work, and means a short link never resolves to a 404 or a private
draft. A short link should only ever exist for something a reader can actually
read.
The generator uses a cryptographic random source, not rand(), so codes are not
guessable or enumerable, and it leans on a UNIQUE constraint to stay correct:
const SHORTLINK_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
const SHORTLINK_LENGTH = 5; // 64^5 ≈ 1.07B. Use 6 for ≈ 68.7B of headroom.
function mantek_generate_short_id(): string {
$alphabet = SHORTLINK_ALPHABET;
$max = strlen( $alphabet ) - 1;
$id = '';
for ( $i = 0; $i < SHORTLINK_LENGTH; $i++ ) {
$id .= $alphabet[ random_int( 0, $max ) ]; // CSPRNG: not guessable
}
return $id;
}
// Runs on the publish transition only. The UNIQUE index on short_id turns a
// collision into a failed INSERT, so we simply re-mint and try again.
function mantek_assign_short_id( int $post_id ): string {
global $wpdb;
$table = $wpdb->prefix . 'short_links';
for ( $attempt = 0; $attempt < 5; $attempt++ ) {
$short_id = mantek_generate_short_id();
$inserted = $wpdb->query( $wpdb->prepare(
"INSERT INTO {$table} (short_id, post_id, full_url, provider, status, created_at)
VALUES (%s, %d, %s, %s, 'active', NOW())",
$short_id, $post_id, get_permalink( $post_id ), mantek_active_provider()
) );
if ( false !== $inserted ) {
return $short_id; // clean insert: the ID was free
}
// Duplicate key (MySQL 1062): the ID was taken. Loop and re-mint.
}
throw new RuntimeException( "Could not mint a unique short_id for post {$post_id}" );
}
A fair question: with random generation, will codes collide? Yes, and you must
plan for it rather than hope. The maths is the birthday problem, not the raw
keyspace: at five characters you have a better-than-even chance of some
collision once you have minted roughly 38,000 links, which a busy newsroom passes
within a year. That is exactly why the UNIQUE constraint and the retry loop are
not optional.
What that maths does not mean is that the retry fires often. The chance that any
single new code clashes is the number of live links divided by the keyspace, so
even at a million live links roughly one insert in a thousand needs a second try,
and a third try is a one-in-a-million event. Five characters with the retry is
comfortable essentially forever; six characters (about 68.7 billion) makes the
retry all but theoretical, at the cost of one extra character. Pick five unless
you want the longest possible runway.
Where the mapping lives
The short code needs a home in WordPress. The tempting shortcut is the existing
guid column on wp_posts, since it already holds something URL-shaped. Resist
it (more on why in a moment). We use a dedicated table instead:
| Column | Role |
|---|---|
short_id |
the code itself, with a UNIQUE index for the retry loop and the reverse lookup |
post_id |
the local post this code is attached to, used to refresh full_url; instance-local, never used for resolution |
full_url |
the article's current canonical URL, a pointer we refresh when it changes |
provider |
which backend was active when the code was minted (bitly, inhouse); a historical marker, since the DNS is the real router |
status |
lifecycle: active, pending, or gone, so the resolver knows what to do when a post is unpublished |
created_at / updated_at
|
timestamps for reconciliation |
A dedicated table owns its own schema, is properly indexed for the reverse lookup,
and has zero blast radius on the rest of WordPress.
Why not just reuse the
guidcolumn? Because the GUID is not a usable URL,
despite looking like one. Its real job is to be a permanent, never-changed
identifier that feed readers use to decide whether an item is new. Overwrite it
and you risk re-notifying every subscriber, breaking the WXR importer's
de-duplication (and newsrooms migrate often), and corrupting media references,
since for attachments the GUID is the real file URL. Indexing it also means
altering a core table. A dedicated table avoids all of that for almost nothing.
Resolving at the edge: redirect, not proxy
Here is the most important architectural decision, and the one most people get
subtly wrong. A short link should redirect. It resolves the code to the
article's URL and returns a 301, and the reader's browser then loads the real
article from the normal stack. The short-link layer never fetches or serves the
article's HTML itself.
The alternative, serving the article's HTML under the short URL, is a trap. It
splits every article across two URLs, which confuses search engines and forces
canonical-tag gymnastics, it leaves the reader looking at a sho.rt address
instead of your trusted domain, and it doubles your cache, since CloudFront now
stores the full article twice. The redirect keeps the short-link layer trivially
small: the cached object is a few hundred bytes, and it points everyone at the one
canonical article.
That also dissolves a performance worry worth naming. Resolving a short link is
one key lookup followed by a redirect. It never disassembles the full URL into
date, category, and slug, and it never runs a WP_Query. WordPress already
resolves its permalinks once, for all traffic, and CloudFront caches the result.
The short link does not add that cost; it just prepends a tiny redirect hop in
front of it.
The in-house path works like this. The short domain's DNS points at CloudFront. On
a cache hit, CloudFront returns the cached 301 immediately, with no compute at
all. On a miss, it runs a small Lambda@Edge function that does a single DynamoDB
lookup on the short code, builds the redirect, and caches it for next time:
// Lambda@Edge, viewer-request on the short domain's CloudFront distribution.
// Resolves /<short_id> to a 301 toward the article's current URL.
const { DynamoDBClient, GetItemCommand } = require('@aws-sdk/client-dynamodb');
const ddb = new DynamoDBClient({ region: 'us-east-1' });
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
const shortId = request.uri.replace(/^\/+/, '').split('/')[0];
if (!shortId) return notFound();
const { Item } = await ddb.send(new GetItemCommand({
TableName: 'short_links',
Key: { short_id: { S: shortId } },
ProjectionExpression: 'full_url, #s',
ExpressionAttributeNames: { '#s': 'status' },
}));
if (!Item || Item.status?.S === 'gone') return goneOrNotFound(Item);
const target = Item.full_url.S;
if (!isOwnDomain(target)) return notFound(); // open-redirect guard
return {
status: '301',
statusDescription: 'Moved Permanently',
headers: {
location: [{ key: 'Location', value: appendUtm(target) }],
'cache-control': [{ key: 'Cache-Control', value: 'public, max-age=86400' }],
},
};
};
A DynamoDB lookup on the partition key is single-digit milliseconds, and most hits
never reach it anyway, because CloudFront is serving the cached redirect. One
detail to know: a Lambda@Edge function is authored in us-east-1 and replicated to
every edge, and its DynamoDB call reaches the table's home region, so for global
low latency you either replicate the table with DynamoDB Global Tables or front it
with DAX. In practice it rarely bites, precisely because the cache absorbs the vast
majority of requests.
A few decisions baked into that function:
-
301, not 302. We want search engines to consolidate the article's ranking
signals on the canonical URL, and they treat the
301status itself as that signal. The explicitCache-Controlkeeps the redirect from being pinned forever in browser caches, so a later URL change is still picked up. - Open-redirect guard. The resolver only ever sends readers to our own domains. An unknown or tampered target returns a plain 404, so the shortener can never be abused to bounce users to a phishing page. Random codes make scanning for valid links low-yield too.
- API Gateway, honestly. You will see URL shorteners put API Gateway in this path. Its only real job there is to expose a regional Lambda to CloudFront as an origin. For a pure redirect you usually do not need it: Lambda@Edge resolves directly, and where a regional function is preferred, a Lambda Function URL is a lighter front door. (This architecture already runs API Gateway, but for headless apps, not for the shortener.)
Resolving the short link is one cached redirect at the edge, with a DynamoDB lookup only on a cache miss. The browser then follows the 301 to the article through the normal stack, exactly like any other reader.
Migration-proof by design
Notice what we actually resolve by. The durable handle is the short_id itself,
the value we mint, control, and put out into the world. The full_url is just a
pointer we keep current beside it. That pairing is what lets a short link outlast
not only a vendor but your own URL scheme.
It is worth being precise about why, because the obvious candidate for a permanent
identity, the WordPress post_id, is not actually stable. Post IDs are
auto-increment integers local to one database; migrate to a fresh WordPress (or to
anything else) and they get reassigned. So we never resolve by post_id. We
resolve by the short_id, and we carry the short_id → full_url mapping across
every migration, refreshing full_url to wherever the article now lives. Newsrooms
re-platform, restructure categories, and change permalink patterns over the years,
and each time the old URLs change. A short link keyed on the ID we own does not
care: share sho.rt/9Kx2a once, and it keeps landing on the right article through
every future migration, because resolution ends at a code we control, never at a
database's internal ID or a frozen URL string.
The one rule that keeps this true: when an article's URL changes, update the
full_url in DynamoDB and invalidate the cached redirect. Active invalidation is
the primary mechanism, so the change is picked up at once, and the bounded
Cache-Control is a safety net that heals anything an invalidation missed within a
day. For an everyday slug edit, invalidate the single path. For a mass migration of
thousands of URLs at once, let a short TTL expire instead, since firing thousands
of individual invalidations is slower and costlier. This is also why the serving
mapping lives in DynamoDB rather than only inside WordPress: it sits outside the
CMS, so it survives any migration by definition. After a re-platform you refresh the
full_url values to the new URLs, and every link already in the wild keeps
resolving.
Keeping the stores in sync at publish
When a post goes live, three things happen in order: WordPress mints the ID into
its own table, upserts the mapping into DynamoDB, and calls the active shortener.
Each store has a clear, non-overlapping role:
| Store | Role |
|---|---|
WordPress (wp_short_links) |
Authoring source of truth: mints the ID, knows the canonical URL |
| DynamoDB | Serving source of truth at the edge, and the vendor-neutral warm standby |
| Bitly | Downstream replica: the active analytics front today, and swappable |
The honest complication with writing to three places is partial failure: the
DynamoDB write succeeds but the Bitly call times out, say. So the external call
goes through a small queue (SQS) with retries, so a transient blip never silently
loses a mapping, and a periodic reconciliation job diffs WordPress against
DynamoDB and Bitly to catch any drift. WordPress is always the tie-breaker.
On publish, WordPress mints the id and writes its own table, then upserts DynamoDB and enqueues the Bitly call through SQS, so a transient failure never loses a mapping. WordPress stays the source of truth.
Why Bitly, and how we keep it swappable
The call to the shortener does not name Bitly. It goes through an interface, so
Bitly is one implementation behind a contract the rest of the code never has to
think about:
interface ShortLinkProvider {
public function create( string $shortId, string $fullUrl ): ShortLinkResult;
public function updateTarget( string $shortId, string $newUrl ): ShortLinkResult;
public function stats( string $shortId ): ?ShortLinkStats; // if the vendor exposes it
}
final class BitlyProvider implements ShortLinkProvider {
public function create( string $shortId, string $fullUrl ): ShortLinkResult {
$res = $this->api( 'POST', '/v4/bitlinks', [
'long_url' => $fullUrl,
'domain' => $this->brandedDomain, // our custom short domain
'custom_bitlink' => "{$this->brandedDomain}/{$shortId}", // our ID as the back-half
] );
// Bitly may SILENTLY fall back to a random back-half if ours is somehow
// taken, instead of failing. Never accept that: it would break the
// "same ID everywhere" invariant. Assert it returned exactly what we asked.
if ( $res->backHalf() !== $shortId ) {
throw new ShortLinkCollision( $shortId ); // caller re-mints and retries
}
return ShortLinkResult::ok( $res->link() );
}
// updateTarget(), stats() ...
}
Two things we confirmed against Bitly's own documentation before relying on them:
-
Custom back-halves are unique per branded domain, not globally. On our own
short domain, any code that is unique in our database is automatically unique on
Bitly, so our
UNIQUEconstraint is the single source of truth. No second uniqueness problem to manage. -
Bitly links are case-sensitive, which is why our mixed-case alphabet is safe
and why
9Kx2aand9kX2Aare different links.
The one trap, captured in the code above: if you request a custom back-half that is
already in use, Bitly may return a link with a random back-half rather than an
error. Accept that blindly and your database says 9Kx2a while Bitly serves
something else. The integration must assert the returned back-half matches the
requested one, and treat a mismatch as a collision.
While Bitly is the active front, our DynamoDB table is effectively write-only,
since the short domain points at Bitly and our resolver is never triggered. That is
not waste, it is a warm standby. Storing a few hundred bytes per link and doing the
occasional write costs almost nothing, and it is exactly what makes failover a DNS
change rather than a frantic rebuild.
The payoff: failover is a DNS change
Put it together and the failover story is almost boring, which is the point. The
short domain points at Bitly today. To take it back in-house, you repoint its DNS
to our CloudFront distribution. Every link already in the wild keeps working,
because the codes are identical on both fronts and our DynamoDB table already holds
every mapping.
The one operational honesty here: that cutover is only as fast as DNS lets it be,
and the TTL is a knob you manage, not a constant. In steady state, pointing at
Bitly with no change in sight, a normal TTL (say an hour) is fine; the record is
not changing, so there is nothing to propagate faster. The TTL only matters around
a switch. For a planned move off Bitly, lower it to 60 to 300 seconds a day or two
ahead (long enough for the old, higher TTL to age out everywhere), make the change,
then raise it back. If instead you want to be ready for an unplanned failover, say
Bitly itself goes down, keep the TTL permanently low as cheap insurance, since the
cost on modern anycast DNS is negligible. One caveat: some resolvers clamp very low
values, so do not go below about 60 seconds or treat the TTL as an absolute
guarantee.
With DNS control on your side, the short-link layer survives a vendor shutdown, a
price hike, a removed feature, or a full CMS re-platform. The links keep resolving
regardless.
The honest part: analytics
If the in-house resolver can do everything, why pay Bitly at all? Analytics. Bitly
gives a newsroom per-link click counts, geographic and referrer breakdowns, an
editor-friendly UI for creating and tracking links, and the bot-filtering that
makes those numbers trustworthy. That is real product work, and it is the reason to
buy rather than build.
Could you build it? Yes, and the edge resolver is the perfect place to tap, because
every redirect is already an event. There are two complementary ways to capture it:
- Client-side, by having the redirect append a marker (UTM parameters) to the destination, so the analytics already on the article page (GA4, or newsroom tools like Chartbeat and Parse.ly) attribute the visit. This counts human readers and naturally excludes bots.
- Server-side, by having the resolver send a GA4 Measurement Protocol event on each resolution, which counts every hit including the ones that never render a page, closer to how Bitly counts.
One correction worth making, since it is a common assumption: GA cannot read the
request headers of a redirect, and the redirect runs no JavaScript. The signal that
reaches your analytics is the marker parameter or the server-side event, not a
header.
So the honest line is not "GA cannot track short links." It can. The gap is that
matching Bitly's per-link dashboards, editor UI, and bot filtering is a product you
would own and maintain, and for many newsrooms paying for that polish is the right
call. We built the durable core so we are never locked in, and we let Bitly handle
the analytics, with our own edge waiting as the hot standby. Buy the convenience,
own the lifeline.
That is the whole shape of it: build the part that must never disappear, the
resolution and the mapping, buy the convenience layer on top, and keep them
interchangeable so a vendor decision is never an existential one. A short link your
newsroom shared years ago should still land tomorrow, no matter who is answering
the domain.
This is the kind of WordPress and AWS engineering our practice is
built on. If you run a publication that lives on social and cannot afford to lose
the links it has shared, let's talk.



Top comments (0)