Blog system

The blog lives under /blog/ (and /es/blog/) and is rendered by the Website, but its content comes from WordPress. The Website never calls WordPress directly: content flows Website → Platform Worker → WordPress REST API.

sequenceDiagram participant Browser participant Web as Website (swapps-client) participant Plat as Platform (swapps-worker) participant WP as WordPress REST API Browser->>Web: GET /blog/{slug}/ Note over Web: edge-cache hit? serve cached HTML Web->>Plat: API_WORKER.fetch(/api/posts...) Plat->>WP: GET /wp-json/wp/v2/posts WP-->>Plat: posts (+ translations, Yoast) Plat-->>Web: normalized JSON Note over Web: Vike SSR renders the page Web-->>Browser: HTML (then cached at edge)

Routes

Route Page
/blog/ Listing with pagination and search
/blog/<slug>/ Individual post (blog/@slug/)
/blog/tag/<slug>/ Posts by tag
/blog/category/<slug>/ Posts by category
/blog/author/<slug>/ Posts by author

Spanish equivalents live under /es/blog/… with translated slugs (e.g. /es/blog/etiqueta/<slug>/), resolved by the i18n routing layer (see SSR with Vike).

Data fetching

pages/blog/+data.ts runs on the server during SSR and calls the Platform Worker through the API_WORKER Service Binding via fetchData():

  • PostsGET /api/posts?per_page=<n>&page=<n>&orderby=date&order=desc&lang=<locale> (plus &search=<q> when searching). per_page is 4 on mobile user agents and 12 otherwise.
  • TagsGET /api/posts/tags?per_page=15&orderby=count&order=desc&lang=<locale> for the filter chips.

The loader returns the posts, pagination metadata, categories, and SEO metadata (title/description/keywords/image) to the page. On API failure it returns a graceful error shape (empty posts + error: true) so the page still renders.

The Worker also builds a category sitemap by fetching GET /api/posts/categories?...&lang=<en|es> for both locales (handleSitemapXml in src/worker.ts).

SSR rendering and SEO

Blog pages are server-rendered through Vike. For posts, +onRenderHtml.tsx prefers the Yoast yoast_head_json carried in the post data, emitting the full Yoast meta set (title, description, canonical, Open Graph, Twitter, and JSON-LD schema). A Spanish-canonical fix injects the /es/ prefix into Yoast canonicals when needed, and an LCP <link rel="preload"> is emitted for the post's featured image.

Multilingual

Posts are bilingual via WordPress (WPML). The WordPress theme exposes a custom REST field that maps each post's translations, which the Platform Worker uses to serve the correct language and to build hreflang alternates. The Website passes per-post alternateUrls into +onRenderHtml.tsx, which renders the hreflang link tags. The lang query param on /api/posts* selects the locale.

Edge caching and purge

Anonymous blog-post pages are cached at the edge in caches.default:

  • Cacheable: GET, a final post URL (/blog/<slug>/ or /es/blog/<slug>/, not a listing/taxonomy root), and no wordpress_logged_in cookie (so editors always see fresh renders).
  • Cache key drops the query string, so utm_*, fbclid, gclid, etc. all resolve to a single shared entry.
  • TTL: 7 days (s-maxage) as a freshness safety net.

When a post is published, edited, or deleted, WordPress calls the Worker's purge webhook (POST /api/blog-cache/purge, authenticated with X-Cache-Api-Key == CACHE_API_SECRET). The Worker purges in two layers:

  1. caches.default.delete() — instant, but only in the colo that handled the request.
  2. Cloudflare Cache Purge API (single-file by URL, using CF_ZONE_ID / CF_PURGE_TOKEN) — global eviction across every colo.

This is targeted single-file purge (never a full flush); URLs are normalized to the cache key (origin + path, query dropped) so the purge hits the exact stored entry.

Note

The WAF allows the Platform Worker's sub-requests to /wp-json/wp/v2/ while keeping that path closed to external traffic — see the platform CLOUDFLARE.md for the server-to-server WAF exceptions.

Next steps