How to Build a Link Shortener in Astro (Static Redirect Pages)
Build a first-party link shortener in Astro with no server: a JSON product map, generated redirect pages, meta refresh + JS fallback, and Plausible click tracking.
15 min read

I run affiliate links on this site, and raw amzn.to URLs scattered across articles were becoming a maintenance problem. When a product gets discontinued or I want to swap a link, I shouldn’t have to grep the content folder. And I had no idea which links anyone actually clicked.
So I built a first-party shortener: bitdoze.com/go/<product>/ URLs that redirect to the affiliate destination, fire a Plausible click event on the way out, and live in one JSON file. This site is fully static and hosted on Cloudflare Pages, which supports real HTTP redirects through _redirects. I use generated HTML pages here so the browser can send a Plausible event before leaving.
What we’re building
- One JSON file mapping slugs to destination URLs — the only file you edit to add or swap a link
- A
[product].astroroute that emits a redirect page per slug at build time (/go/dock-name/→ product page) - A Plausible
affiliate_clickcustom event fired before the redirect, so outbound clicks are measurable per product noindexpages that stay out of search engines and the site search- Fallbacks for no-JS and analytics-blocked visitors, so nobody gets stranded on the redirect page
If you want a hosted dashboard, link editing without deploys, or per-click logs with timestamps and referrers, that’s a real link-shortener service — I run Sink on Cloudflare Workers for that elsewhere. The static version below covers the affiliate use case with zero infrastructure.
Three ways to redirect on a static Astro site
Astro gives you three mechanisms that look similar but behave very differently. Picking the wrong one silently costs you either analytics or reliability:
| Mechanism | Redirect type | Page loads? | Analytics? | Use for |
|---|---|---|---|---|
redirects: in astro.config.mjs |
Static HTML stub (meta refresh) | Briefly | Possible but ugly | Internal URL moves, renamed pages |
public/_redirects |
Real HTTP 301/302 at the Cloudflare edge | Never | No — no page ever loads | Legacy URL migrations, domain moves |
Generated pages (getStaticPaths) |
Full HTML page → JS redirect | Yes | Yes — best-effort Plausible event | Affiliate links |
The trade-off is the middle column. An edge _redirects rule is fastest but invisible to analytics — the browser never renders anything, so Plausible can’t fire an event. A generated page adds a page load and up to roughly 400ms of JavaScript waiting, allowing a best-effort click event and a visible destination (which affiliate programs want anyway). Since click data is the entire point, /go/ uses the third option.
Already Using Cloudflare?
The public/_redirects file is the right tool when you don’t need tracking — it produces real HTTP 301s at the edge. I use it for retired category URLs. It sits next to this system without conflict. For the deployment side of things, see deploying Astro on Cloudflare.
Step 1: The product map
Everything lives in src/data/affiliate-links.json. One entry per linkable product:
{
"anker-prime-tb5": {
"name": "Anker Prime TB5 Docking Station",
"url": "https://amzn.to/42cJK4L"
},
"gmktec-evo-x2": {
"name": "GMKtec EVO-X2",
"url": "https://amzn.to/3UrrKiE"
}
}The name is what visitors see on the redirect page (“Redirecting to Anker Prime TB5 Docking Station on Amazon…”), which keeps the destination unmasked — affiliate ToS expects readers to know where they’re going. The slug becomes both the URL (/go/anker-prime-tb5/) and the analytics property, so keep it stable once published.
Step 2: The redirect page
src/pages/go/[product].astro — one file generates every redirect page:
---
import links from "../../data/affiliate-links.json";
export function getStaticPaths() {
return Object.entries(links).map(([slug, l]) => ({
params: { product: slug },
props: { slug, name: l.name, url: l.url },
}));
}
const { slug, name, url } = Astro.props;
// Show the real retailer name even when the link is Amazon's amzn.to shortener.
const hostMap: Record<string, string> = {
"amzn.to": "Amazon",
"amazon.com": "Amazon",
};
const rawHost = new URL(url).hostname.replace(/^www\./, "");
const host = hostMap[rawHost] ?? rawHost;
---
<!doctype html>
<html lang="en" data-pagefind-ignore>
<head>
<meta charset="utf-8" />
<title>Redirecting to {name}…</title>
<meta name="robots" content="noindex,nofollow" />
<meta http-equiv="refresh" content={`3;url=${url}`} />
<script is:inline>
window.plausible =
window.plausible ||
function () {
(plausible.q = plausible.q || []).push(arguments);
};
plausible.init =
plausible.init ||
function (i) {
plausible.o = i || {};
};
plausible.init({ autoCapturePageviews: false });
</script>
<script is:inline async src="https://an3.bitdoze.com/js/pa-kQauNXzAlLDQqncIWm6Op.js"></script>
</head>
<body data-pagefind-ignore>
<p>Redirecting to {name} on {host}…</p>
<p><a href={url} rel="nofollow sponsored">Continue to {host}</a></p>
<script is:inline define:vars={{ slug, url }}>
(function () {
var done = false;
function go() {
if (done) return;
done = true;
window.location.replace(url);
}
// Only record a same-origin pathname, never query strings or fragments.
var props = { product: slug };
try {
var source = new URLSearchParams(window.location.search).get("article");
var article = new URL(source || document.referrer, window.location.origin);
if ((source || document.referrer) && article.origin === window.location.origin && !article.pathname.startsWith("/go/")) {
props.article = article.pathname;
}
} catch (e) { /* Direct visits may have no article attribution. */ }
// Start the bound before analytics: tracking must never block navigation.
setTimeout(go, 400);
try {
plausible("affiliate_click", {
props: props,
callback: go,
});
} catch (e) {
/* analytics blocked or script missing — still redirect */
}
})();
</script>
</body>
</html>Six details in there are load-bearing:
getStaticPaths+Astro.props— the standard static-site-generation pair: each JSON entry becomes adist/go/<slug>/index.htmlfile at build time. Slugs not in the JSON produce no page, so typos 404 naturally.- The Plausible queue stub — same pattern as the site’s
Layout.astro: thewindow.plausiblefunction queues events until the real script loads. Mine is the same custom-domain script (an3.bitdoze.com); use the installation snippet and script URL from your own Plausible site settings. The stub is initialized before the async script loads.autoCapturePageviews: falsekeeps these intermediate pages out of ordinary pageview counts while retaining the custom event. Setup details are in the Plausible on Astro with Cloudflare Workers article. callback: go+setTimeout(go, 400)— this is the reliability trick. Plausible calls the callback once the event is sent, so the redirect waits for tracking. The 400ms timeout starts before the event call and bounds the wait. If the tracker is blocked or takes longer than that to load and send, navigation still proceeds and the click may not be recorded.doneguards against double-firing.location.replaceinstead oflocation.href— the redirect page never enters browser history, so Back doesn’t bounce users through/go/again.noindex,nofollow+data-pagefind-ignore— keeps redirect stubs out of Google and out of the site’s own search index (Pagefind only indexes pages carryingdata-pagefind-body, and the attribute blocks them regardless).- Meta refresh at 3s — the no-JS fallback. Slower than the JS path but guarantees everyone ends up at the destination.
Why .astro and not a .ts endpoint
My first version was [product].ts returning a new Response(html). It works, but a .ts endpoint emits an extensionless file (dist/go/anker-prime-tb5), which Cloudflare Pages serves as application/octet-stream — the browser downloads it instead of rendering. An .astro page under trailingSlash: "always" produces dist/go/<slug>/index.html, which serves correctly at /go/<slug>/. Use a page, not an endpoint.
Step 3 (optional): An index of all links
Useful while testing, and it makes auditing the JSON easy. src/pages/go/index.astro:
---
import links from "../../data/affiliate-links.json";
const entries = Object.entries(links).sort(([a], [b]) => a.localeCompare(b));
---
<!doctype html>
<html lang="en" data-pagefind-ignore>
<head>
<meta name="robots" content="noindex,nofollow" />
<title>Affiliate redirect links</title>
</head>
<body data-pagefind-ignore>
<h1>/go/ redirects ({entries.length})</h1>
<ul>
{
entries.map(([slug, l]) => (
<li>
<a href={`/go/${slug}/`}>/go/{slug}/</a> — {l.name}
</li>
))
}
</ul>
</body>
</html>Step 4: Link from articles
Anywhere a product link goes, point at the /go/ URL instead of the raw affiliate link:
<Button text="Check Anker Prime TB5 Price" link="/go/anker-prime-tb5/" />
<!-- or a plain markdown link -->
[Check current price](/go/anker-prime-tb5/)Updating a destination URL for the same product is a one-line JSON edit, applied everywhere on the next build. Give a different product its own slug so existing article recommendations stay accurate.
Compliance Reminder
Affiliate programs (Amazon Associates especially) require a disclosure near the links and don’t want the destination disguised. The redirect page handles the second part by showing the retailer name; the first is on you — keep an Affiliate Disclosure notice above the first affiliate link in every article that has one.
Article attribution and build checks
The event always includes product. On this site, the shared layout also adds an article query parameter to same-origin /go/<slug>/ links before interaction, using only the current pathname. This works for ordinary clicks, new tabs, and widgets using rel="noreferrer". The redirect page accepts a same-origin source, strips any query string or fragment, and sends only the pathname as the optional article property. Without the parameter, it tries document.referrer; direct visits can have no article property. The parameter is never forwarded to the retailer.
For the same behavior, add this script to your shared Astro layout:
<script>
function annotateAffiliateLinks() {
document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((link) => {
const target = new URL(link.href, window.location.href);
if (target.origin !== window.location.origin || !/^\/go\/[^/]+\/$/.test(target.pathname)) return;
target.searchParams.set("article", window.location.pathname);
link.href = target.pathname + target.search + target.hash;
});
}
annotateAffiliateLinks();
document.addEventListener("astro:page-load", annotateAffiliateLinks);
</script>Our build runs scripts/validate-affiliate-links.mjs before Astro to check kebab-case slugs, nonempty product names, and HTTPS destinations without embedded credentials. After Astro emits the pages, the same script with --dist checks rendered anchors against the map. Checking the output covers both Markdown links and MDX widgets without treating tutorial code samples as real links. Unknown slugs fail the build before deployment. These checks validate configuration and references; they do not check retailer availability.
Verifying it works
Run bun run build:ci for the build and affiliate checks. Then verify the redirects:
# Pages were emitted
ls dist/go/ | head
# Redirect target baked into the HTML
grep -o 'url=https[^"]*' dist/go/anker-prime-tb5/index.html
# Unknown slugs 404
curl -o /dev/null -w '%{http_code}' http://localhost:4321/go/does-not-exist/Then click one in a browser with Plausible’s dashboard open — you should see an affiliate_click event with the product prop. If the event doesn’t appear, the usual cause is the custom event not being registered as a goal in the Plausible site settings; add affiliate_click under Goals → Custom Events.
Costs and limits
Each product costs one static page — trivial at this scale, and the build-time hit is negligible even for a site this size (the approach plays fine with the tricks in Astro build speed optimization).
What you give up versus a real shortener:
- No per-click log with timestamps/referrers — you get aggregate counts per product, not individual click records
- Links only change on deploy — there’s no dashboard to edit live
- No query-string passthrough, no A/B destinations, no geo-routing out of the box
- A page load plus up to roughly 400ms of JavaScript waiting, versus an edge redirect
If any of those become real requirements — especially geographic routing, live editing, or server-side analytics — move the same URL scheme to a Cloudflare Worker or Sink and keep /go/ as the path so no article ever changes.
Conclusion
This implementation uses generated pages rather than the HTTP redirects also available on Cloudflare Pages. Once you accept that, the implementation is small: one JSON file, one [product].astro route, and an event call with a timeout-guarded callback. What you get back is a single place to manage every affiliate URL on the site and click data that tells you which products your readers actually care about.
Frequently Asked Questions
Why not just use astro.config.mjs redirects?
Astro’s redirects config generates the same kind of meta-refresh stub pages, but it’s a flat config map with no per-entry props — you can’t hang a product name or a Plausible event on it cleanly. It’s meant for moved pages, not a managed link catalog. Keep it for internal URL changes.
Can I use public/_redirects on Cloudflare Pages instead?
You can, and it’s faster (a real HTTP 301 at the edge, no page load at all). The trade-off is analytics: since no page ever renders, no JavaScript runs and no Plausible event can fire. Use _redirects for legacy URL migrations where you don’t care about click tracking; use generated pages where you do.
Does the Plausible event fire reliably before the redirect?
Two mechanisms cover it: Plausible’s custom-event API accepts a callback that runs after the event is sent, and a 400ms setTimeout redirects anyway if analytics is blocked by an ad blocker. The done flag prevents double-redirecting when both fire. Tracking is best-effort: blocked analytics and a slow tracker load or request can both cause missed events. Test with network throttling and a blocked tracker; navigation should still proceed without waiting for analytics.
How do I keep these pages out of Google and site search?
The page emits meta name="robots" content="noindex,nofollow" for search engines. For site search, Pagefind only indexes pages marked data-pagefind-body, and these pages carry data-pagefind-ignore on top. The custom sitemap is generated from content collections, so /go/ pages never enter the sitemap either.
What about visitors without JavaScript?
The <meta http-equiv="refresh"> tag redirects after 3 seconds with no JavaScript required, and there’s a visible “Continue to Amazon” link with rel="nofollow sponsored" if even that fails or the user wants to inspect the destination first.
When should I move to a real shortener service?
When you need per-click logs, live link editing without a deploy, query passthrough, geographic routing, or server-side analytics. A few hundred small static pages alone is not a reason to migrate. At that point run something like Sink on Cloudflare Workers — you can keep the same /go/ paths so existing article links keep working.


