Skip to main content
TEEI in Practice
Engineering note · Research & Engineering · 15 October 2026 We Pruned 12,115 Astro Redirect Pages from Our Build

TEEI in Practice

Engineering noteResearch & Engineering

Part of: Astro on Workers

We Pruned 12,115 Astro Redirect Pages from Our Build

Pruning Astro fallback pages reduced the files we shipped. Redirecting their old URLs needed a separate fix before the Worker.

On this page
  1. Three changes, three different effects
  2. How two fallback locales multiply the page count
  3. Why our middleware rule missed the city paths
  4. The file ceiling we chose to enforce
  5. Prune only the fallback-page format
  6. Compare projected capacity with the output on disk
  7. Give old URLs a redirect before the Worker
  8. Choose where to maintain the redirect
  9. Verify file count and HTTP behaviour separately
  10. Appendix: configuration
  11. Continue in the Astro series
  12. Frequently asked questions

On 25 July 2026 we cut our Astro build output from 22,618 files to 10,503 by pruning 12,115 fallback pages for Ukrainian and Norwegian city addresses. Those pages contained English fallback redirects, not translations. Removing their files did not tell a reader following an old link where to go. Our middleware rule returned 301 on the dev server and 404 in the production build, so we put the city redirects in a Cloudflare rule before the Worker.

This case has two independent outcomes to verify: the files left after a build and the HTTP answer at each old URL. Astro still generates the fallback pages; we exclude them from compression, prune them afterwards and check the resulting file count. The zone rule supplies the redirect. Neither change proves that the other works.

This article follows our Astro website architecture. Worker first and adapter routing explains which code sees a request; here the question is how to reduce generated output while keeping old addresses useful. The wider platform is in How TEEI runs its platforms on Cloudflare Workers.

Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.

Three changes, three different effects

Table 1. Three changes, three different effects
ChangeWhat it changesWhat still happens
Exclude locale-prefixed city trees from compressionFewer files go through the compressorAstro generates those pages; the exclusion does not remove them
Prune recognised fallback pages after Astro buildsFewer static asset files remain to shipTheir old URLs still need an HTTP answer
Redirect at the Cloudflare zoneA matching request gets a 301 before the WorkerThe rule does not change the build output

We check file counts after pruning. We check redirects separately for status, destination, query preservation and hop count. This separation also matters for the limits: these HTML pages are static asset files, counted per Worker version. We did not measure a reduction in the Worker script bundle.

How two fallback locales multiply the page count

Our site is in English at the root, with Ukrainian and Norwegian under /uk/ and /no/. Most pages exist only in English, so the Ukrainian and Norwegian paths fall back to the English page:

Adapted for publication.

js
i18n: {
  defaultLocale: 'en',
  locales: ['en', 'uk', 'no', 'de', 'fr', 'es', 'pl', 'cs', 'it', 'nl', 'pt', 'da', 'lv'],
  routing: {
    prefixDefaultLocale: false, // English pages at root (/programs/), not /en/programs/
    fallbackType: 'redirect', // Explicit (was implicit default); pins redirect behaviour for the uk/no fallback.
  },
  fallback: {
    uk: 'en',
    no: 'en',
  },
},

On 15 October 2026 we declared 13 locales, with fallback to English configured only for Ukrainian and Norwegian. Those two relationships set the multiplier, not the length of the locale list. Astro’s documentation describes the fallback: for every route a fallback locale is missing, we get a page in that locale. In our build each one is a small HTML file that answers 200, with a <meta http-equiv="refresh"> that moves the reader to the English page after two seconds, a noindex robots tag and a canonical link to the English page. That is the format Astro’s documentation describes for redirects in a static build.

Volunteers find us through a page for each city, a page for each country, a hub that lists them, and a page for each programme that recruits in a city. On 15 October 2026 our city manifest listed 2,000 cities, 58 countries and two programmes with city pages, TEEI Language and TEEI Mentorship, at 2,000 cities each.

Diagram of the arithmetic: 2,000 city pages, 58 country pages and one hub make 2,059, and 2,000 TEEI Language and 2,000 TEEI Mentorship city pages make 4,000, so 6,059 pages without a prefix. Each gets a Ukrainian and a Norwegian fallback page, 12,118 in all. Our pruning step removed 12,115 on 25 July 2026.
Figure 1. How we generate two fallback redirect pages for each recruitment page. The page counts are from our city manifest as read on 15 October 2026; the difference of three against the 12,115 we removed in July is not explained. Source: TEEI, from our city manifest, read on 15 October 2026 at 12:47 UTC, and our pruning step’s record of 25 July 2026.

That makes 6,059 pages without a prefix, and each gets a Ukrainian and a Norwegian fallback page: 12,118 extra files by the manifest’s arithmetic. Our pruning step removed 12,115 on 25 July 2026. We have not explained why the counts differ by three.

Why our middleware rule missed the city paths

In the July investigation, no page of ours linked to these locale-prefixed city addresses and no sitemap listed them. We still wanted a redirect for anyone arriving at one. Three candidate locations in the Astro project failed to give us the production answer we needed:

A _redirects file. We get one in the build from the Astro Cloudflare adapter. But Cloudflare’s documentation is explicit: “Redirects defined in the _redirects file are not applied to requests served by your Worker code, even if the request URL matches a rule defined in _redirects.” Our build configuration uses run_worker_first = true, and our own _redirects file has carried a header saying it is not processed since 10 May 2026. That header describes our setup; _redirects remains a supported feature of the static asset layer.

Astro’s redirects. Our configuration has an empty redirects: {} with a comment from May 2026: a redirect whose source uses a [...rest] spread parameter made the adapter’s redirect helper emit a rule that pointed the route back at its own pattern, which Cloudflare rejected as a loop. Stripping a prefix from every city path is the kind of redirect that needs one.

Our own middleware. We wrote a middleware rule that strips the /uk or /no prefix from a city path and answers 301. On the dev server it does. In the production build it did not, which we traced on 25 July 2026 in the built Worker and on staging: /uk/cookie-policy, which a different rule earlier in the same middleware redirects, answered 301, while /uk/volunteer/cities/zzz/, a path that never had a redirect page, answered 404. Our reading is that Astro’s locale routing answered those paths with the 404 page before our middleware ran. We saw the behaviour; we have not traced that order through Astro’s source. The rule is still in the middleware, and it answers on the dev server.

The observed 301/404 difference was enough to rule out middleware as the only place for these city redirects. We placed their HTTP answer before the Worker. The pruning step remained a separate part of the build.

The file ceiling we chose to enforce

As we read Cloudflare’s limits page on 15 October 2026, it gives 20,000 static asset files per Worker version on the Free plan and 100,000 on Paid with Wrangler 4.34.0 or later, a change Cloudflare announced on 4 September 2025. We keep a ceiling of 20,000 files; we have no evidence that Cloudflare rejected this build. The reason in our own notes applies on any plan: generating and processing pages we never ship adds build work.

Prune only the fallback-page format

We run the HTML through @playform/compress after Astro has written it, and compressing pages we were about to delete was work on files that never ship. We exclude the two locale-prefixed city trees by path, without inspecting their HTML:

Verbatim excerpt.

js
      Exclude: [(file) => /(?:^|[\\/])(?:uk|no)[\\/]volunteer[\\/]cities[\\/]/.test(file)],

In the comment beside it, which we read on 15 October 2026, we report that this cut the compression pass from about 19,000 files to 5,040. That is a file count; we did not measure how much build time the exclusion saved.

Right after Astro’s build, before anything measures the output, we walk the Ukrainian and Norwegian copies of the city tree in a small Node script and delete a file only when it matches the redirect-page format:

Verbatim excerpt.

js
function isRedirectStub(html) {
  return (
    /<meta http-equiv="refresh"/i.test(html) &&
    /name="robots"[^>]*noindex/i.test(html) &&
    /rel="canonical"/i.test(html) &&
    html.length < 2000
  );
}

All four conditions must hold. A failed condition keeps the file; we count those kept files and print up to ten paths. This recognises a particular HTML format, not translation status: html.length is JavaScript string length, and the canonical check is a case-insensitive text match, not a parsed link or a comparison with the expected target. The compression exclusion is broader because it checks only the path, so a page pruning keeps in that tree remains uncompressed.

The deletion runs in two passes because of a bug we had. An earlier version deleted each city’s directory as it went, but a city directory holds its own page and the directories of the programme pages under it, so the first deletion removed files that were still on the list, and every later read threw ENOENT. Now we collect only files named index.html and read every candidate before deleting anything. Then we delete the matching files one by one and remove a directory only once it is empty, deepest first. A file we keep therefore keeps its directory, and a read error stops the build before the first deletion.

The pruning covers one surface on purpose: the Ukrainian and Norwegian copies of the city pages. Every other page Astro’s fallback writes is still built.

Compare projected capacity with the output on disk

We can project the file count before a build and check the output after pruning. For the projection we count the build output that exists, subtract its unprefixed city tree and add the pages the city manifest projects in its place; with no output on disk, we use a dated baseline. Locale-prefixed files still on disk stay in the baseline, so the result before a build is an estimate, not a dry run of Astro. A programme that is a draft, paused or retired, or that has no city pages, adds nothing, which lets us set up a new programme’s cities before it opens without spending the budget. Since 25 July 2026 we warn above 16,000 files and fail the build above 18,000, with 20,000 as the hard ceiling.

The projection and the measured total can disagree, so after pruning, before our remaining clean-up steps, we stop the build when either exceeds the release limit:

Adapted for publication.

js
const measuredVerdict =
  measured && measured.files > RELEASE_LIMIT ? 'fail' : 'ok';

if (report.verdict === 'fail' || measuredVerdict === 'fail') {
  process.exit(1);
}

We test the projection with hypothetical fleets: five full-scale programmes warn, a sixth fails while still below our 20,000 ceiling, and inactive programmes add no pages.

On 15 October 2026 at 12:47 UTC a build on our build machine held 12,613 files, and the gate’s verdict was ok. Our projection replaces 6,059 counted city files with 6,059 projected pages, leaving the total at 12,613; equal counts do not prove that the routes match or validate the fallback arithmetic. With the redirect pages, the same build would have held a calculated 24,731 files, above our release limit, and the gate would have failed it.

Bar chart of files in our website’s build output. On 25 July 2026 the build held 22,618 files with the redirect pages and 10,503 after pruning. On 15 October 2026 it held 12,613 after pruning, and would have held 24,731 with them, a calculated figure. Lines mark 16,000, above which we warn, 18,000, above which the build fails, and 20,000, our ceiling.
Figure 2. Files in the website’s build output, with and without Astro’s fallback redirect pages for the city pages. The 24,731 is calculated, not built. As we read Cloudflare’s documentation on 15 October 2026, it gives 20,000 static asset files per Worker version on the Free plan and 100,000 on Paid. The October count is from one build, measured at 12:47 UTC. Source: TEEI, from our pruning step’s record of 25 July 2026 and our capacity gate’s count of one build on 15 October 2026 at 12:47 UTC, read on 15 October 2026.

Give old URLs a redirect before the Worker

We rely on Cloudflare’s fixed order: Single Redirects first, Bulk Redirects later, and Bulk Redirects, in the words of the documentation, “will run in front of your Worker”. When a Single Redirect matches, we answer at that layer, before our Worker, Astro and the assets. To follow its Location, the reader makes a new request; the original request never reaches Bulk Redirects or our Worker.

Diagram of a request passing four layers in order: Single Redirects on the zone, Bulk Redirects on the account, the Worker’s entry point and then the static files and Astro. Locale-prefixed city and blog addresses are answered with a 301 at the first layer, an old WordPress address at the second, exact old addresses kept in our code at the third, and pages at the fourth.
Figure 3. Where we answer an old address on our website: Single Redirects before Bulk Redirects, both before the Worker. Example answers measured on 15 October 2026. Source: TEEI, drawn from Cloudflare’s documentation and our website’s configuration, with example answers measured on 15 October 2026.

The following expressions and settings are copied from our infrastructure notes; they describe Cloudflare rules, not a Wrangler configuration file. On 25 July 2026 we added this city rule to the zone:

Verbatim excerpt.

code
(starts_with(http.request.uri.path, "/uk/volunteer/cities") or
 starts_with(http.request.uri.path, "/no/volunteer/cities"))
->
concat("https://theeducationalequalityinstitute.org",
       regex_replace(http.request.uri.path, "^/(uk|no)", ""))
preserve_query_string: true

The match is a plain prefix test. The target strips the locale prefix and keeps everything after it, and the query string goes with it.

The same pattern came back on 12 October 2026. External sites linked to Ukrainian and Norwegian addresses of English blog posts, such as /uk/blog/<slug>-en/, and through Astro’s fallback we served those as 200 pages with a meta refresh, not as a 301. Ahrefs listed 82 such linked addresses that day. Those fallback pages are still built, so this time the build did not change at all. A second rule answers first:

Verbatim excerpt.

code
(http.request.uri.path matches "^/(uk|no)/blog/[^/]+-en/?$")
->
concat("https://theeducationalequalityinstitute.org",
       regex_replace(http.request.uri.path, "^/(?:uk|no)/blog/([^/]+-en)/?$", "/blog/${1}/"))
preserve_query_string: true

Unlike the city rule’s prefix test, this rule anchors the whole path: one slug segment ending in -en, with an optional trailing slash, which we rewrite to the English post with a trailing slash. A post we have translated carries its own suffix, and the Ukrainian and Norwegian blog hubs are left alone. On 12 October all 82 linked addresses answered 301 to the English post in one hop. When we checked again on 15 October 2026, the city and blog addresses answered 301 with the query string intact, and other addresses under /uk/, such as /uk/volunteer/teei-language/, still answered 200.

Choose where to maintain the redirect

We maintain our zone rules outside the repository. We record their expressions and dated response checks in our infrastructure notes; our test suite does not exercise the live rules. If you need a redirect reviewed with the code, put it in the Worker entry. Most of ours are exact old addresses, and for those we wrap the adapter’s Worker entry: at build time we copy the redirect maps from our middleware into it, and we look up each path there before Astro sees the request. Because run_worker_first is on, that lookup runs before any file is served. A redirect kept in two layers can drift: our old WordPress list lives both in the account and as a map in our middleware, and our notes say to change both or neither.

Single Redirects are few. On 15 October 2026 we read in Cloudflare’s URL forwarding documentation an allowance of 10 to 300 Single Redirect rules per zone, depending on the plan, and regular expressions are not available on every plan. They suit a handful of pattern rules. A long list of fixed addresses belongs in Bulk Redirects, which have no regular expressions or string replacement, or in your Worker.

Prune when reducing shipped output solves a measured problem. A file count comfortably inside your plan’s allowance may not justify another build step. Count files before and after pruning and time the build separately: deleting output does not avoid generating it. Check HTTP behaviour independently; a 200 page with meta refresh and a 301 response remain different answers even when file capacity is ample. Disabling fallback changes the generation step and needs its own comparison.

fallbackType: 'rewrite' does not reduce the count. It serves the English content on the localised address in place of a redirect page. By Astro’s documentation, we would still get a page for every missing route in every fallback locale. We have not built our site that way, and we have not measured it.

Verify file count and HTTP behaviour separately

At the build steps immediately before and after pruning, count the same output directory. For an Astro build with client assets in ./dist/client:

bash
find ./dist/client -type f | wc -l

Keep those counts with the build’s timestamp. Compare routes separately if you need to prove which pages remain; equal totals alone do not do that. For this case, the recorded 25 July 2026 counts were 22,618 before pruning and 10,503 afterwards.

For HTTP behaviour, these public requests were measured on 15 October 2026. Repeating them checks the site’s answer at the time you run them; it does not reproduce the historical build count.

In that capture, the Ukrainian volunteer page answered 301 to the page without the prefix:

bash
curl -sI https://theeducationalequalityinstitute.org/uk/volunteer/cities/norway/oslo/

With a query string we saw one hop, a final 200 and a final address without the prefix that still ends in ?a=b:

bash
curl -sIL -o /dev/null -w '%{num_redirects} %{http_code} %{url_effective}\n' \
  "https://theeducationalequalityinstitute.org/uk/volunteer/cities/norway/oslo/language/?a=b"

A Norwegian address for an English post showed one hop, a final 200 and the English post as the final address:

bash
curl -sIL -o /dev/null -w '%{num_redirects} %{http_code} %{url_effective}\n' \
  https://theeducationalequalityinstitute.org/no/blog/bain-for-ukraine-en/

Appendix: configuration

Versions on 15 October 2026: Astro 7.2.10, @astrojs/cloudflare 14.2.4, Wrangler 4.107.0.

The website’s assets block:

Adapted for publication.

toml
[assets]
binding = "ASSETS"
run_worker_first = true
not_found_handling = "404-page"

Count the output of the build you intend to ship, then probe the old URLs against the environment you intend to release. For the redirect, inspect the first response’s status and Location as well as the final destination: following a link successfully does not by itself establish a one-hop 301 or query preservation.

Continue in the Astro series

The homepage case follows what happens after a reader reaches the page. Continue with deploying the exact Worker you tested for how generated output, configuration and release evidence fit together.

Frequently asked questions

Why does Astro i18n fallback create so many files?

With fallbackType 'redirect', we get a page under each fallback locale for every route that locale is missing, and in a static build that page is a small HTML file with a meta refresh. By our manifest's arithmetic, read on 15 October 2026, two fallback locales over 6,059 pages make 12,118 extra files; we removed 12,115 on 25 July 2026.

Do redirect pages count against the Cloudflare Workers static asset limit?

Yes. They are files in the build output, so they count as static assets for the Worker version, not as Worker code. As we read Cloudflare's documentation on 15 October 2026, it gives 20,000 static asset files per Worker version on the Free plan and 100,000 on Paid with Wrangler 4.34.0 or later.

Why do we not use a _redirects file for these Astro paths on Workers?

Cloudflare's documentation says redirects in _redirects are not applied to requests served by your Worker code. Our configuration sends every request to the Worker first, and our own _redirects file has been marked as not processed since May 2026. We keep these redirects in a zone rule before the Worker or in Worker code.

Why did our Astro middleware redirect not run in production?

On 25 July 2026 we observed a 301 on the dev server and a 404 in the production build for the locale-prefixed city paths. We inferred that Astro answered those paths before our middleware but did not trace that order through Astro's source. We moved the redirect to a Cloudflare Single Redirect rule on the zone, which runs before the Worker.

Does a zone redirect keep the query string?

It does when the rule sets preserve_query_string. On 15 October 2026 our locale-prefixed city and blog addresses answered 301 with the query string intact. We also counted the hops for two of them: a city address with a query string and a blog address without one each reached a 200 page in one hop.

More in Research & Engineering