Skip to main content
Adrean Jan Quidor (Kofeejan)
All blogs

Fresh Content Without Runtime CMS Reads

AstroSanityCloudflareGitHub ActionsStatic Generation

Problem

A portfolio must show published Content soon enough for editing to feel reliable. It does not require a content management system request on every page request. Runtime reads make each public response depend on Sanity latency and availability. They also change a statically generated Astro site into a system with another live request path.

I wanted to preserve static site generation. Static site generation means that the build creates the public HTML before a visitor asks for it. I also wanted a Content Publish in Sanity to reach the site within minutes. The design required a clear input for every production build and a useful local development path.

Decision

I chose a Snapshot-based build. A Content Snapshot is a validated JSON capture of published Content. The file contains an envelope with contractVersion, capturedAt, and content. GitHub Actions creates this file, and Astro uses it during prerender. The deployed Cloudflare Worker does not read Sanity for public pages.

Local development keeps a separate convenience. If CONTENT_SNAPSHOT_PATH exists, the Snapshot wins. Without that path, local development can use SANITY_PROJECT_ID and fetch live Content through the shared Snapshot Query. A non-local build without a Snapshot fails. Local development without either source also fails.

This choice makes production deterministic. A build consumes one validated file. It does not depend on Content that changes during prerender.

Implementation

Sanity starts the publish path. A GROQ-powered webhook sends a small repository_dispatch trigger to GitHub when published Content changes. GROQ is the Sanity query language. The webhook body is only a trigger, not Content.

GitHub runs .github/workflows/content-redeploy.yml on its default branch, main. The protected prod environment accepts only the prod branch. The trigger job therefore starts the same workflow with workflow_dispatch --ref prod. The prod job runs Snapshot Query against the published Dataset and writes ./.content/ci-snapshot.json.

Snapshot Query is the GROQ projector in portfolio-publish-gate/snapshot-query. It converts Sanity documents into the shared Content Contract shape. The generator validates the result as a Snapshot envelope before Astro receives it.

The build sets this value:

CONTENT_SNAPSHOT_PATH=./.content/ci-snapshot.json

resolveContentLoadKind() selects the Snapshot adapter for that path. contentSnapshotVitePlugin() reads and validates the JSON during the build. The plugin exposes the parsed value through virtual:content-snapshot. Vite then places the value in the built module graph.

This inlining step matters for Cloudflare. The runner disk exists during GitHub Actions, but the deployed Worker cannot read that disk with node:fs. Inlining turns the Snapshot into build input instead of a runtime file dependency. loadDisplayContentFromInlinedSnapshot() parses the envelope again and sends its content to createDisplayContentFromContent().

Astro prerenders public pages to dist/. The contact API is the request-time exception. Public page requests use the generated output and do not call Sanity.

The workflow adds two recovery rules. A weekly schedule runs a full Site Redeploy, so a missed webhook does not leave the site stale forever. The production redeploy job also uses one concurrency group with cancel-in-progress: true. A newer Content Publish cancels an older in-flight redeploy. The last start wins, which prevents an older build from replacing a newer one.

Local development takes a shorter path. src/lib/content/sanity-source.ts calls the same Snapshot Query library when no Snapshot path exists. It then sends the validated Content into the same Display Content factory. The local source changes, but the page-facing shape does not.

Rejected Options

I rejected runtime CMS reads for public pages. They offered fresher Content by seconds, but this portfolio accepts minutes of publish lag. Runtime reads also tied request latency and availability to Sanity. That cost did not match the freshness requirement.

I rejected live GROQ inside production Astro prerender. It looked simpler because it removed the file. It also made a build observe mutable Content throughout its work. A validated Snapshot gives the build one named input and one capture time.

I rejected a static fallback when neither Sanity nor a Snapshot exists. A fallback can make a build pass with old or incomplete material. A clear failure protects the delivery contract.

The project also rejected Dataset polling from the public site. The weekly job is not a runtime poller. It is a full Site Redeploy that follows the same Snapshot path as a webhook-triggered run.

Accepted Tradeoffs

Content does not appear at the instant of publish. GitHub Actions must start, query Sanity, build Astro, and deploy the Worker. The repository accepts that delay.

Each Content Publish starts a workflow. Rare publishes make that cost small, but frequent bursts can spend more CI time. Cancellation limits overlapping production work, but it does not restore event coalescing.

The weekly recovery job also spends build time when Content did not change. In return, recovery has no special state or polling service. It uses the tested production path.

Local development and production use different source adapters. That difference can hide an adapter-specific bug. Shared Contract validation and the shared Display Content factory reduce that risk, and focused tests cover both selection paths.

Lesson

Static generation and fresh editorial Content are not opposites. A publish event can trigger a new static build. The useful design question is where the live dependency belongs. In this system, GitHub Actions owns the live Sanity read. The deployed public site owns no CMS connection.

A Snapshot also improves reasoning. It gives each build a concrete, validated input. The same file format supports generation, testing, and local production-like builds.

Next-Project Rule

For my next mostly static site, I will start with the required freshness window. If minutes are sufficient, I will use publish-triggered builds and a validated Snapshot. I will keep live queries outside production rendering. I will add one recovery trigger and define concurrency so older work cannot replace newer work.