The Prerendered Child That Broke Its Server-Rendered Parent
/blog returned this:
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Access Denied</Message></Error>
Not a 404. Not a stack trace. An XML document, with 403 and server: AmazonS3 in the headers.
Individual posts worked. /blog/some-post rendered perfectly. Only the index — the simplest page on the site, a list of three links — was unreachable. Nothing in the Lambda logs, because the request never reached the Lambda.
That last part is the whole story.
Two origins pretending to be one site
This site runs Astro in output: 'server' mode on AWS. “Server mode” is slightly misleading: it sets the default, not the rule. Any route can opt out with export const prerender = true, and a real build usually ends up mixed.
That mix is invisible in development, where one dev server answers everything. In production it becomes physical. The build emits two directories:
dist/client/ → uploaded to S3, served by CloudFront
dist/server/ → bundled into a Lambda function
One domain, two completely different origins. CloudFront decides, per request, which one answers — and that decision is made from the URL path alone, before any of your code runs.
So the interesting question stops being “is this route SSR or static?” and becomes “which origin will CloudFront send this path to?” Those are not the same question, and I had only been thinking about the first one.
What a prerendered route actually leaves behind
/blog/[slug] is prerendered. There is no reason to invoke a Lambda to render Markdown that was fixed at build time.
Prerendering that route emits real files:
dist/client/blog/index.html ← the listing
dist/client/blog/oidc-deploys-to-aws-from-github-actions/index.html
dist/client/blog/dark-mode-done-right-css/index.html
dist/client/blog/getting-started-serverless-aws/index.html
Look at what the children created on their way in: a blog/ directory in the static bucket. Not requested, not configured — a side effect of the child routes existing.
CloudFront now has a static path prefix called blog/. Requests under it resolve against S3.
The index page, meanwhile, was server-rendered. It existed only inside the Lambda bundle. So /blog was routed to S3, S3 looked for blog/index.html, found nothing, and returned the response it returns for an object you cannot read: AccessDenied.
Which explains the XML. The 403 was not an authorization problem — the bucket is configured correctly. AccessDenied is simply what S3 says when a private bucket is asked for an object that isn’t there. It refuses to distinguish “missing” from “forbidden”, because confirming which one applies would leak whether the key exists.
The children made the parent unreachable. Not by conflicting with it, not by overwriting it — just by existing at a path underneath it.
The fix is one line, and it was free
// Must be prerendered: /blog/[slug] emits a `blog/` directory into the client
// build, so CloudFront resolves /blog against S3. Rendering this on the server
// would leave that lookup with no blog/index.html to serve.
export const prerender = true;
The listing reads its content with getCollection at build time regardless. Nothing about it was dynamic — no request headers, no per-user state, no database. It was server-rendered by inheritance, because the config said output: 'server' and nobody had asked whether that route needed it.
So prerendering it costs nothing and gains everything: it emits the blog/index.html that the S3 lookup was already expecting, and the page now comes off the CDN rather than a cold Lambda.
The comment is the actual deliverable. export const prerender = true on a route that could obviously be static looks like a micro-optimisation someone could clean up. Without the comment, deleting it is a reasonable-looking edit that returns the site to serving XML.
The rule worth internalising
A parent route cannot stay server-rendered once its children prerender.
If any route under a path prefix is prerendered, that prefix becomes a static directory, and every path under it resolves against the bucket. The parent must be prerendered too, or it is unreachable in production.
What makes this expensive is when you find out. It cannot reproduce locally — astro dev has no S3 and no CloudFront, so it serves both routes happily. It survives astro build, which reports success. It survives a deploy, which also reports success. The first signal is a 403 in a browser, and the error you get names a service you were not thinking about and a problem — access — that you do not have.
The practical version, when adding a route: decide explicitly whether it needs the Lambda, and check whether anything else under the same prefix prerenders. If it does, prerender the index too.
The part I have not fixed
You can still watch this happen. Today, in production:
$ curl -sI https://bbobadilla.dev/blog/does-not-exist | head -3
HTTP/2 403
content-type: application/xml
server: AmazonS3
$ curl -sI https://bbobadilla.dev/does-not-exist | head -2
HTTP/2 404
content-type: text/html
Same site, same domain, two different failure modes — and the header tells you which origin answered. An unknown path at the root reaches the Lambda, which renders the site’s own 404 page. An unknown path under /blog never gets that far: it hits S3 and comes back as XML.
The obvious fix is to map that 403 onto the 404 page at the CDN. I tried it twice. Both attempts failed, for unrelated reasons, and the reasons are more interesting than the fix would have been.
Attempt 1 — a CloudFront custom error response
This is the documented answer, and it is one config block: catch 403 from the origin, respond with /404.html and a status of 404.
Deployed to a staging stage, it turned the 403 into a 502.
$ curl -sI https://dev.bbobadilla.dev/blog/does-not-exist | head -2
HTTP/2 502
content-type: text/html
The cause is specific to how SST wires the distribution. It attaches a placeholder origin, and rewrites it to the real S3 bucket or Lambda per request, inside a CloudFront Function on viewer-request. Custom error responses do not go through that function — CloudFront fetches the error page itself. So the request went to the placeholder, found nothing there, and reported an origin failure. Which it correctly was.
A generic fix collided with a framework-specific routing mechanism. Nothing in the CloudFront docs would have warned me; the warning was in SST’s own source, in the component that builds the distribution.
Attempt 2 — rewriting the status in a viewer-response function
If the error page can’t be fetched, don’t fetch one. Let S3 answer, and rewrite the status on the way out — a CloudFront Function on viewer-response, ten lines, no origin involved.
The function deployed, published, and associated cleanly. Tested in isolation against a synthetic 403 event, it did exactly what it should:
$ aws cloudfront test-function --name s3-403-to-404 ... \
--event-object fileb://event.json
"statusCode": 404
In production it changed nothing at all. Same 403, same XML.
CloudFront does not execute viewer-response functions when the origin returns a status of 400 or above. That is documented behaviour, and there is no flag for it. The function was correct, correctly associated, and unreachable — the hardest kind of failure to debug, because every piece of it looks healthy.
Attempt 3 — the one that would work, and which I did not ship
origin-response is invoked for 4xx and 5xx. But CloudFront Functions can’t run there; that trigger belongs to Lambda@Edge, which is a heavier thing entirely — a real Lambda, replicated to every edge location, and the only option here that can replace the response body as well as the status. It would not merely fix the code, it would serve the actual page.
I stopped there. Lambda@Edge deploys only from us-east-1, is associated by published version ARN rather than an alias, takes minutes to replicate, and — the part that decided it — takes hours to fully delete, because the replicas have to be torn down before the function can go. For a personal site, that is a lot of standing operational surface to buy a nicer error body on a path nobody requests.
So the seam stays. I would rather leave it visible than pretend the architecture is free.
What the two failures have in common
Both fixes were correct. Both were also invisible from where I was standing: the first assumed CloudFront fetches error pages the same way it fetches everything else, the second assumed a response function runs on every response. Neither assumption is written down anywhere near the config that depends on it.
That is the same shape as the original bug. A prerendered child route does not announce that it has claimed a path prefix, and a viewer-response function does not announce that it will be skipped. In both cases the system reports success and the behaviour is decided somewhere you weren’t looking.
Two origins behind one domain is a real trade-off, not a free abstraction: you get CDN latency for static routes and a Lambda only where you need one, and in exchange the boundary between them occasionally surfaces in a way your users can see.
Worth knowing where your seams are.