Skip to content
All insights

Short links that outlive the vendor: a newsroom URL shortener on WordPress and AWS

Jaafar Abazid

WordPress AWS Architecture Newsrooms
A long Arabic URL collapsing into a short branded link, the newsroom URL shortener on WordPress and AWS, ManTek Insights

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.

sho.rt/9Kx2athe shared linkDNSthe switchBitlyactive front todayanalytics · editor UIOur AWS edgeCloudFront · DynamoDBLambda@EdgeHOT STANDBYCanonicalarticle
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:

ColumnRole
short_idthe code itself, with a UNIQUE index for the retry loop and the reverse lookup
post_idthe local post this code is attached to, used to refresh full_url; instance-local, never used for resolution
full_urlthe article’s current canonical URL, a pointer we refresh when it changes
providerwhich backend was active when the code was minted (bitly, inhouse); a historical marker, since the DNS is the real router
statuslifecycle: active, pending, or gone, so the resolver knows what to do when a post is unpublished
created_at / updated_attimestamps 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 guid column? 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 301 status itself as that signal. The explicit Cache-Control keeps 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.)
1 · RESOLVE THE SHORT LINKcache hitmissbuilds the 301browser follows the 301Readersho.rt/9Kx2aCloudFrontshort-domain edge301 redirectLocation: /articleLambda@Edgeruns only on a missDynamoDBGetItem(short_id)2 · FOLLOW THE 301 TO THE ARTICLEBrowserrequests /articleCloudFrontarticle cacheALBload balancerEC2 · WordPressoriginAuroraMySQL database
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:

StoreRole
WordPress (wp_short_links)Authoring source of truth: mints the ID, knows the canonical URL
DynamoDBServing source of truth at the edge, and the vendor-neutral warm standby
BitlyDownstream 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.

WORDPRESS · SOURCE OF TRUTHupsertenqueuecall APIEditor hits Publishthe publish hook firesMint short_id5 chars, CSPRNGINSERT; on UNIQUE collision, re-mintwp_short_linksauthoring source of truthDynamoDBserving source of truththe warm standbySQSretry queueBitlydownstream replica
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 UNIQUE constraint 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 9Kx2a and 9kX2A are 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.

Further reading

Have something in mind?

Whether it's a quick fix or a system built to last, tell us what you're working on. We'd like to help.