The homepage worked. The blog index worked. Every actual blog post blew up with a 500.

That is the kind of failure that wastes time because it looks half-healthy. The deploy is up. Nginx is serving traffic. Most of the site renders. So the first instinct is usually wrong. You start suspecting content, caching, or some random production-only edge case.

In this case, the real problem was much simpler: we had moved the site to Astro’s server runtime, but one dynamic blog route was still written like a static build-time route.

The fix was small. The lesson is worth keeping.


The symptom pattern mattered

The production behavior was very specific:

  • / returned 200
  • /blog returned 200
  • /blog/<post-slug> returned 500

That immediately narrowed the problem.

This was not a full app outage. It was not a broken container. It was not a bad domain or SSL config.

It was a route-level failure on individual post pages.

That distinction matters because it changes where to look first.


The log line that mattered

CapRover service logs showed the real exception:

TypeError: Cannot read properties of undefined (reading 'render')
    at file:///app/dist/server/pages/blog/_---slug_.astro.mjs:209:34

That pointed directly at the dynamic blog route.

The failing file was:

src/pages/blog/[...slug].astro

And the route was doing this:

export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ data }) => !data.draft);
  return posts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }));
}

const { post } = Astro.props;
const { Content } = await post.render();

That pattern was fine when the site was operating as a static build.

It stopped being safe once the site moved to server mode.


The practical root cause

Astro’s routing model is different in static mode and SSR mode.

Per the Astro docs, dynamic routes in SSR are served on request, and getStaticPaths() should not be used there. In SSR mode, routes should look up data from Astro.params, not rely on build-time props being passed through the route.

That was the bug.

We still had a route that depended on getStaticPaths() and Astro.props carrying a full astro:content entry into runtime. After the server-runtime switch, that object was no longer something we could safely call render() on.

So the route was effectively doing this at runtime:

  • receive a post object that was no longer the real content entry we expected
  • call post.render()
  • crash

That is why the error was specifically reading 'render'.


The rule to keep in your head

If you move an Astro site from static output to SSR, audit every dynamic route that depends on getStaticPaths() props.

Do not assume those routes will keep behaving the same way just because the build still passes.

The build can be healthy while runtime behavior is wrong.

That is exactly what happened here.


The fix was to go slug-first

Instead of passing the content entry through route props, we changed the route to resolve the post by slug at request time.

The fixed shape was:

const slug = Astro.params.slug;
const posts = await getCollection('blog', ({ data }) => !data.draft);
const post = posts.find((entry) => entry.slug === slug);

if (!post) {
  return Astro.redirect('/404');
}

const { Content } = await post.render();

That is much more aligned with how SSR routes are supposed to work:

  • get the route param from the request
  • look up the matching record at request time
  • handle missing content explicitly
  • render the real entry

No build-time prop handoff required.


Why this fix is better than trying to patch around the symptom

There were a few weaker options:

  1. try to massage the serialized object coming through props
  2. special-case production only
  3. roll the whole site back to static mode

None of those addressed the actual mismatch.

The clean fix was to make the route follow the runtime model the site was now using.

That is usually the right move in migrations like this.

When runtime assumptions change, patching around old assumptions creates more drift. Rewriting the route to match the new contract is safer.


A migration checklist that would have caught this earlier

If you are moving an Astro site into SSR or hybrid server rendering, check these before you deploy:

1) List your dynamic routes

Look for files like:

  • [slug].astro
  • [...slug].astro
  • [id].astro

Those are the first places where static assumptions tend to hide.

2) Search for getStaticPaths()

Then ask a simple question for each match:

  • is this route still supposed to be prerendered, or
  • is it now part of the server-rendered path?

If it is SSR, treat getStaticPaths() as suspicious by default.

3) Search for Astro.props on dynamic pages

If a dynamic page is reading runtime content from Astro.props, verify that it still makes sense in server mode.

4) Smoke-test the deepest real URLs

Do not stop at:

  • homepage
  • list pages
  • top-level nav

Hit actual leaf routes:

  • real blog post URLs
  • real docs pages
  • real nested content pages

That is where these failures show up.

5) Read the live service logs before guessing

The logs in this case gave the answer quickly.

Without them, it would have been easy to waste time on CDN, cache, or content debugging.


Why the symptom looked more confusing than it was

This class of bug creates a misleading sense that the deploy is “mostly fine.”

And “mostly fine” is dangerous.

It lowers urgency, broadens the search space, and encourages hand-wavy explanations.

The better mindset is:

  • partial success does not mean low severity
  • route-specific failures are usually structural
  • production logs beat speculation

Once we treated it that way, the problem got narrow fast.


The practical takeaway

The bug was not really about Astro being fragile.

It was about carrying a static-route mental model into an SSR runtime.

If you switch an Astro site into server mode, the safest rule is simple:

dynamic routes should resolve data from params at request time unless you are intentionally prerendering them.

That one rule would have prevented this whole incident.

And if your homepage looks healthy while leaf routes 500, go to the route code and the service logs first. That is usually where the truth is.