<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mathias Ahlgren</title>
    <description>The latest articles on DEV Community by Mathias Ahlgren (@mathiasahlgren).</description>
    <link>https://dev.to/mathiasahlgren</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1690379%2F65c596a4-6bd1-4338-8dfc-68db3bdd1682.jpg</url>
      <title>DEV Community: Mathias Ahlgren</title>
      <link>https://dev.to/mathiasahlgren</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mathiasahlgren"/>
    <language>en</language>
    <item>
      <title>I Built a Local Directory Site with Astro, Airtable, and Cloudflare - Here is the Architecture</title>
      <dc:creator>Mathias Ahlgren</dc:creator>
      <pubDate>Thu, 18 Jun 2026 23:51:26 +0000</pubDate>
      <link>https://dev.to/mathiasahlgren/i-built-a-local-directory-site-with-astro-airtable-and-cloudflare-here-is-the-architecture-3ojh</link>
      <guid>https://dev.to/mathiasahlgren/i-built-a-local-directory-site-with-astro-airtable-and-cloudflare-here-is-the-architecture-3ojh</guid>
      <description>&lt;p&gt;I've built a few local directory sites lately - the "best X in town" kind of thing - and I kept reaching for the same stack: Astro for the frontend, Airtable as the CMS, Cloudflare to host it. After the third one I realised the &lt;em&gt;architecture&lt;/em&gt; was the interesting part, not any individual site. So this is a writeup of how the pieces fit together, the decisions that actually mattered, and the two or three gotchas that cost me an afternoon each.&lt;/p&gt;

&lt;p&gt;If you're building anything that's mostly structured, read-heavy content - a directory, a catalogue, a "links" site, a small marketplace - this pattern is worth stealing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this stack for a directory
&lt;/h2&gt;

&lt;p&gt;A directory is a particular shape of website. It's a pile of structured records (each listing has a name, a category, some photos, hours, a location) that changes a few times a week, not a few times a second. Visitors read far more than they write.&lt;/p&gt;

&lt;p&gt;That shape matters, because it tells you what you &lt;em&gt;don't&lt;/em&gt; need. You don't need a PHP runtime and a database doing a query on every single page view to show a list of restaurants that barely changes. That's the WordPress model, and for this job it's mostly overhead - plus a plugin ecosystem you have to keep patched.&lt;/p&gt;

&lt;p&gt;What you actually need is three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A friendly way to &lt;strong&gt;manage&lt;/strong&gt; the data (ideally one a non-technical client can use).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;build step&lt;/strong&gt; that turns that data into fast, static HTML.&lt;/li&gt;
&lt;li&gt;Somewhere &lt;strong&gt;cheap and fast&lt;/strong&gt; to serve it from.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Airtable, Astro, and Cloudflare map onto those three jobs almost one-to-one. Let's go through each.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data layer: Airtable as a build-time CMS
&lt;/h2&gt;

&lt;p&gt;The instinct when you hear "CMS" is to reach for something with an admin panel - WordPress, Strapi, a headless SaaS. But for a directory, Airtable is hard to beat, and the reason is purely about the editing experience.&lt;/p&gt;

&lt;p&gt;Your content is a table. Listings are rows. Categories are linked records. "Feature this on the homepage" is a checkbox. "Hide this" is unchecking another. That's an interface a client or a virtual assistant can use on day one without you building anything. You get a real relational-ish data model (linked records between listings and categories) &lt;em&gt;and&lt;/em&gt; a spreadsheet UI, for free.&lt;/p&gt;

&lt;p&gt;The key architectural decision: &lt;strong&gt;read Airtable at build time, not at request time.&lt;/strong&gt; A loader script runs during the build, pulls every table over the Airtable API, and hands the data to Astro to render into static pages. There's no live API call when a visitor hits the site - the content is already baked into HTML.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Airtable base                 build step                 static output
┌───────────────┐        ┌────────────────────┐        ┌──────────────────┐
│ Items         │        │ loaders/airtable   │        │ HTML in dist/     │
│ Categories    │  ───►  │ • fetch all tables │  ───►  │ • /listings       │
│ Areas         │        │ • links → slugs    │        │ • /place/&amp;lt;slug&amp;gt;   │
│ + images      │        │ • download images  │        │ • /category/&amp;lt;slug&amp;gt;│
└───────────────┘        └────────────────────┘        └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consequence you have to design around: &lt;strong&gt;a content change only appears after a rebuild.&lt;/strong&gt; In dev you restart the server; in production you trigger a deploy. That sounds like a limitation, and for some apps it would be - but for a directory that updates a few times a week, it's a perfectly fine trade for never running a database. (More on automating that rebuild later.)&lt;/p&gt;

&lt;h3&gt;
  
  
  The image gotcha that bites everyone
&lt;/h3&gt;

&lt;p&gt;Here's the one that cost me time, and it's worth the price of the whole article: &lt;strong&gt;Airtable's attachment URLs expire.&lt;/strong&gt; If you fetch a record and hot-link the image URL it gives you, your photos will silently 404 a while later.&lt;/p&gt;

&lt;p&gt;So the loader can't reference Airtable's URLs directly. Instead, at build time it &lt;strong&gt;downloads&lt;/strong&gt; every image and self-hosts the local copy. The build pulls the bytes down, drops them in a public folder, and the rendered HTML points at your own domain. Photos become stable and fast, with the side effect that adding a photo (like any content change) needs a rebuild to show up.&lt;/p&gt;

&lt;p&gt;If you take one thing from this section: any "use Airtable as a CMS" tutorial that hot-links attachment URLs is quietly broken. Download them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The frontend: Astro, and designing for reuse
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://astro.build/" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; is the obvious fit here because directories are content-first and Astro ships zero JavaScript by default - you only opt into client-side JS where you actually need interactivity (a filter drawer, a map). For a page that's fundamentally "a styled list of records," that means very fast pages out of the box.&lt;/p&gt;

&lt;p&gt;But the more interesting decision was structural. After the first build I didn't want to &lt;em&gt;rewrite&lt;/em&gt; components every time I started a new directory in a different niche. So I wrote the whole thing in &lt;strong&gt;generic primitives&lt;/strong&gt; and pushed every niche-specific word into one config file.&lt;/p&gt;

&lt;p&gt;Nothing in the components or routes says "restaurant." The code talks about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an &lt;strong&gt;Item&lt;/strong&gt; (the thing you list)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Taxonomies&lt;/strong&gt; (the ways you categorise it)&lt;/li&gt;
&lt;li&gt;an optional &lt;strong&gt;Tier&lt;/strong&gt; (a price level)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What those &lt;em&gt;mean&lt;/em&gt; lives entirely in config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;singular&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Place to Eat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Places to Eat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;slugBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;place&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// detail pages live at /place/&amp;lt;slug&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;taxonomies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cuisine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cuisine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cuisines&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;slugBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cuisine&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;area&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Area&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Areas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="na"&gt;slugBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;area&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="na"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tags&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="na"&gt;slugBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tags&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The trick that makes re-skinning safe: keys vs labels
&lt;/h3&gt;

&lt;p&gt;This is the part I'm most pleased with, and it generalises to any config-driven system. Each taxonomy has both a &lt;strong&gt;&lt;code&gt;key&lt;/code&gt;&lt;/strong&gt; and a &lt;strong&gt;&lt;code&gt;label&lt;/code&gt;&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;key&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;cuisine&lt;/code&gt;, &lt;code&gt;area&lt;/code&gt;, &lt;code&gt;tag&lt;/code&gt;) is a stable internal identifier. The loader and components are wired to it. You &lt;strong&gt;never&lt;/strong&gt; change it once you have content and URLs depending on it.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;&lt;code&gt;label&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;plural&lt;/code&gt;&lt;/strong&gt;, and &lt;strong&gt;&lt;code&gt;slugBase&lt;/code&gt;&lt;/strong&gt; are human-facing. They're what shows in the UI and the URLs. You rename these &lt;strong&gt;freely&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the taxonomy the code calls &lt;code&gt;cuisine&lt;/code&gt; can &lt;em&gt;display&lt;/em&gt; as "Specialty" and live at &lt;code&gt;/specialty/...&lt;/code&gt;, while every component still references the stable &lt;code&gt;cuisine&lt;/code&gt; key under the hood. To turn a restaurant directory into a hairdresser one, you change labels - &lt;code&gt;Cuisine&lt;/code&gt; → &lt;code&gt;Specialty&lt;/code&gt;, &lt;code&gt;Tags&lt;/code&gt; → &lt;code&gt;Services&lt;/code&gt;, &lt;code&gt;Place&lt;/code&gt; → &lt;code&gt;Salon&lt;/code&gt; - and rename the matching Airtable tables. No component touched, no route rewritten.&lt;/p&gt;

&lt;p&gt;That's the difference between a codebase you reuse and one you fork-and-gut every time. The vertical becomes &lt;em&gt;data, not code&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic routes from the data
&lt;/h3&gt;

&lt;p&gt;Astro's file-based routing does the heavy lifting. Two dynamic route files cover most of the site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/pages/
  [itemBase]/[slug].astro   → a listing's detail page  (/place/the-old-spence)
  [taxonomy]/[slug].astro   → a category browse page    (/cuisine/italian)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each uses &lt;code&gt;getStaticPaths()&lt;/code&gt; to enumerate every listing and every category from the build-time data and emit a static page per record. Combined with the config above, the &lt;code&gt;slugBase&lt;/code&gt; values decide the URL shapes - so when you relabel &lt;code&gt;place&lt;/code&gt; to &lt;code&gt;salon&lt;/code&gt;, the routes follow automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hosting: Cloudflare, and the "mostly static" hybrid
&lt;/h2&gt;

&lt;p&gt;Here's a nuance people miss: a directory is &lt;em&gt;almost&lt;/em&gt; fully static, but not quite. The listing pages, category pages, map, and blog are static HTML. But you usually want a few forms - "submit a business," contact, newsletter - and a form needs &lt;em&gt;something&lt;/em&gt; server-side to receive the POST.&lt;/p&gt;

&lt;p&gt;The clean answer on &lt;a href="https://www.cloudflare.com/products/workers/" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt; is a hybrid. The vast majority of the site is static assets served from the edge. The handful of form endpoints run as server functions (in Astro you mark just those routes with &lt;code&gt;export const prerender = false&lt;/code&gt;). So you get static performance everywhere that matters, and a tiny bit of compute exactly where you need it - no more.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Static (edge):  /  /listings  /place/*  /cuisine/*  /map  /blog/*
Server (Worker): /api/submit  /api/contact  /api/subscribe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps hosting costs at rounding-error levels - most of the traffic never touches compute - while still letting the public interact with the site.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spam protection without CAPTCHA
&lt;/h3&gt;

&lt;p&gt;Those public form endpoints are the one attack surface, so they're worth hardening. I really didn't want to slap a CAPTCHA on real users, so I went with a layered guard that's invisible to humans, cheapest checks first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Honeypot&lt;/strong&gt; - a hidden field real users never see. Bots fill every field; if it's populated, the endpoint &lt;em&gt;fakes success and saves nothing&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing&lt;/strong&gt; - a hidden timestamp stamped on page load. Submissions that arrive implausibly fast (bots) or suspiciously stale (replayed pages) are dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin check&lt;/strong&gt; - posts must come from your own domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User-Agent check&lt;/strong&gt; - blocks lazy scripted clients that don't look like a browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-IP rate limit&lt;/strong&gt; - using Cloudflare's rate-limiting binding, each IP gets a cap (e.g. 3 per minute per form).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The design detail I like: the bot-shaped rejections (1–4) return a &lt;strong&gt;fake success&lt;/strong&gt;, so a bot gets no signal about what tripped it. Only the rate limit and genuine validation errors show a real message. For most directories this is plenty; you can always add Cloudflare Turnstile on top if you're a bigger target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping a static site fresh
&lt;/h2&gt;

&lt;p&gt;The obvious objection to "rebuild to publish" is: isn't that annoying? It doesn't have to be. Two patterns solve it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build hook + Airtable automation.&lt;/strong&gt; Create a deploy hook, then add an Airtable automation: "when a record is created or updated → call this webhook." Now editing a listing triggers a rebuild on its own. Edit in the spreadsheet, the site updates a minute later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled rebuild.&lt;/strong&gt; A Cloudflare Cron Trigger (or any CI) that rebuilds hourly/daily.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a low-edit directory, honestly, deploying by hand when you change something is fine too. But the automation path means "static" never means "manual."&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-offs, honestly
&lt;/h2&gt;

&lt;p&gt;No stack is free of them, and pretending otherwise is how you end up with angry comments.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a developer's setup.&lt;/strong&gt; Initial setup and deploy are command-line work - clone, configure, deploy. Day-to-day &lt;em&gt;content&lt;/em&gt; editing is all in Airtable and needs no code, but you have to be comfortable getting it running. If you'll never open a terminal, a hosted directory builder is a better fit even at the cost of monthly fees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content is build-time.&lt;/strong&gt; Covered above - great for directories, wrong for anything needing per-request freshness (live inventory, user accounts, real-time anything).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Airtable's free tier has limits.&lt;/strong&gt; Fine for hundreds of listings; if you're modelling tens of thousands of rows with heavy API traffic, reassess.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the directory shape specifically, those trade-offs land on the right side of the line for me every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The pattern in one breath: &lt;strong&gt;Airtable as a build-time CMS, Astro turning that data into static pages via generic Item/Taxonomy primitives, Cloudflare serving it from the edge with a few server-side form endpoints for the interactive bits.&lt;/strong&gt; Fast, cheap, owned outright, and - if you do the key/label separation - re-skinnable to any niche without rewriting components.&lt;/p&gt;

&lt;p&gt;If you want to build this from scratch, everything above is the blueprint; none of it is secret sauce. If you'd rather not wire up the Airtable loader, the image-download step, the spam guard, and the dynamic routing yourself, I packaged this exact stack as a commercial &lt;a href="https://stackrater.io/tools/localfinds-astro-directory-template/" rel="noopener noreferrer"&gt;Astro directory template called LocalFinds&lt;/a&gt; - Astro 6, Tailwind v4, Airtable, Cloudflare, with the one-config re-skinning baked in. There's a live demo linked there if you just want to poke at the end result and see whether the architecture feels right before building your own.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Either way - build it or buy it - the stack itself is the takeaway. For read-heavy structured-content sites, this combination is genuinely hard to beat.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Happy building!&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>cloudflare</category>
      <category>tailwindcss</category>
      <category>directory</category>
    </item>
    <item>
      <title>How to Set Up a Headless WordPress Site with Astro</title>
      <dc:creator>Mathias Ahlgren</dc:creator>
      <pubDate>Thu, 27 Jun 2024 07:26:14 +0000</pubDate>
      <link>https://dev.to/mathiasahlgren/how-to-set-up-a-headless-wordpress-site-with-astro-3a2h</link>
      <guid>https://dev.to/mathiasahlgren/how-to-set-up-a-headless-wordpress-site-with-astro-3a2h</guid>
      <description>&lt;p&gt;Here, I'm diving into the exciting world of &lt;strong&gt;headless WordPress and Astro&lt;/strong&gt;. If you're looking to combine the content management power of WordPress with the blazing-fast performance of a static site generator, you're in for a treat. Let's get started!&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;So, what's all this fuss about headless WordPress and Astro? Well, imagine taking WordPress's fantastic content management capabilities and pairing them with a modern, lightning-fast front end. That's exactly what we're doing here!&lt;/p&gt;

&lt;p&gt;Headless WordPress means we're using WordPress solely as a backend, handling all our content creation and management. Meanwhile, Astro steps in as our front-end superhero, delivering that content to users with incredible speed and flexibility.&lt;/p&gt;

&lt;p&gt;Why bother with this setup? Simple: you get the best of both worlds. Content editors can stick with the familiar WordPress interface, while developers can build a blazing-fast, SEO-friendly frontend using modern tools and frameworks. It's a win-win!&lt;/p&gt;

&lt;p&gt;Before we dive in, I've got a hot tip for you: check out &lt;a href="https://astrowp.com" rel="noopener noreferrer"&gt;AstroWP - headless WordPress starter kit&lt;/a&gt;. It's an awesome resource that can jumpstart your headless WordPress project with Astro. While we'll be building our site from scratch in this tutorial, AstroWP is definitely worth exploring if you want to hit the ground running on future projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Before we jump in, let's make sure you've got everything you need:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A WordPress installation (don't worry, we'll cover this)&lt;/li&gt;
&lt;li&gt;Basic knowledge of JavaScript and React (we'll be using some React components)&lt;/li&gt;
&lt;li&gt;Node.js and npm installed on your machine&lt;/li&gt;
&lt;li&gt;Familiarity with the command line (nothing too scary, I promise!)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Got all that? Great! Let's dive in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up WordPress
&lt;/h2&gt;

&lt;p&gt;First things first, let's get WordPress up and running:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;If you haven't already, install WordPress on your favorite web host. There are tons of great guides out there if you need help with this step.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Once WordPress is installed, log into your admin panel and head to the Plugins section. You need to install a crucial plugin called WPGraphQL. This nifty tool exposes your WordPress data through a GraphQL API, which we'll use to fetch content for our Astro site.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Search for "WPGraphQL" in the plugin directory, install it, and activate it. Easy peasy!&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Now, let's create some sample content. Add a few blog posts and pages so we have something to work with. Don't stress about making it perfect – we're just testing things out.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Alright, our WordPress setup is good to go. Time to switch gears and set up Astro!&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Astro
&lt;/h2&gt;

&lt;p&gt;Now for the fun part – let's get Astro up and running:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Open up your terminal and navigate to where you want your project to live.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Run the following command to create a new Astro project:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npm create astro@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Follow the prompts to set up your project. When asked which template to use, choose the &lt;strong&gt;"Empty" (minimal)&lt;/strong&gt; option from the list.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Once the installation is complete, cd into your new project directory and run:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will install all of the needed dependencies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This starts the development server and gives you a local preview of your site.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open up your browser and navigate to &lt;code&gt;http://localhost:4321&lt;/code&gt;. You should see a blank Astro site. Not very exciting yet, but we're about to change that!&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads up (Astro 6+):&lt;/strong&gt; If you picked the minimal template and &lt;code&gt;npm run dev&lt;/code&gt; throws a &lt;code&gt;! integrations: Required&lt;/code&gt; config error, just add an empty &lt;code&gt;integrations: []&lt;/code&gt; array to your &lt;code&gt;astro.config.mjs&lt;/code&gt;. We'll be adding the React integration there in a moment anyway, so this resolves itself in the next section.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Take a moment to explore the project structure. You'll see a &lt;code&gt;src&lt;/code&gt; folder with &lt;code&gt;pages&lt;/code&gt; and &lt;code&gt;components&lt;/code&gt; subdirectories. This is where we'll be spending most of our time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Astro to WordPress
&lt;/h2&gt;

&lt;p&gt;Now that we have both WordPress and Astro set up, it's time to introduce them to each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First, let's add the React integration. Astro ships with an &lt;code&gt;astro add&lt;/code&gt; command that installs the integration, pulls in its peer dependencies, and wires up your config in one step. Run the following and accept the prompts:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npx astro add react
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This automatically updates your &lt;code&gt;astro.config.mjs&lt;/code&gt; so it looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;   &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;astro/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;react&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@astrojs/react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

   &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
     &lt;span class="na"&gt;integrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
   &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;If you ever see a &lt;code&gt;Cannot find package 'react'&lt;/code&gt; warning when starting Astro, install the peer dependencies manually with &lt;code&gt;npm install react react-dom @types/react @types/react-dom&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
&lt;li&gt;Now, we need to set up our environment variables. Create a new file in your project root called &lt;code&gt;.env&lt;/code&gt; and add the following:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;   &lt;span class="py"&gt;WP_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https://your-wordpress-site.com/graphql&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;https://your-wordpress-site.com&lt;/code&gt; with your actual WordPress site URL.&lt;/p&gt;

&lt;p&gt;Great job! We've now got the groundwork laid for our headless WordPress + Astro site. In the next section, we'll start fetching data from WordPress and displaying it in our Astro site. Exciting times ahead!&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetching Data from WordPress
&lt;/h2&gt;

&lt;p&gt;Alright, now we're getting to the good stuff. Let's fetch some data from WordPress and display it in our Astro site:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;First, let's create a new file in the &lt;code&gt;src/pages&lt;/code&gt; directory called &lt;code&gt;index.astro&lt;/code&gt;. This will be our homepage.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Open up &lt;code&gt;index.astro&lt;/code&gt; and add the following code:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ---
   const response = await fetch(import.meta.env.WP_URL, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({
       query: `
         query HomePagePosts {
           posts(first: 5) {
             nodes {
               title
               excerpt
               slug
             }
           }
         }
       `
     })
   });

   const json = await response.json();
   const posts = json.data.posts.nodes;
   ---

   &amp;lt;html lang="en"&amp;gt;
     &amp;lt;head&amp;gt;
       &amp;lt;meta charset="utf-8" /&amp;gt;
       &amp;lt;meta name="viewport" content="width=device-width" /&amp;gt;
       &amp;lt;title&amp;gt;My Headless WordPress Site&amp;lt;/title&amp;gt;
     &amp;lt;/head&amp;gt;
     &amp;lt;body&amp;gt;
       &amp;lt;h1&amp;gt;Welcome to My Blog&amp;lt;/h1&amp;gt;
       &amp;lt;ul&amp;gt;
         {posts.map((post) =&amp;gt; (
           &amp;lt;li&amp;gt;
             &amp;lt;h2&amp;gt;{post.title}&amp;lt;/h2&amp;gt;
             &amp;lt;p set:html={post.excerpt}&amp;gt;&amp;lt;/p&amp;gt;
             &amp;lt;a href={`/posts/${post.slug}`}&amp;gt;Read more&amp;lt;/a&amp;gt;
           &amp;lt;/li&amp;gt;
         ))}
       &amp;lt;/ul&amp;gt;
     &amp;lt;/body&amp;gt;
   &amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code does a few things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It sends a GraphQL query to our WordPress site to fetch the latest 5 posts.&lt;/li&gt;
&lt;li&gt;It then takes that data and renders it in a simple HTML structure.&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;Save the file and check out your Astro dev server. You should now see your WordPress posts displayed on the page!&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pretty cool, right? We're now pulling data from WordPress and displaying it in our Astro site. But we can do even better. In the next section, we'll set up dynamic routing to create individual pages for each of our blog posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating Dynamic Routes
&lt;/h2&gt;

&lt;p&gt;Now that we've got our posts showing up on the homepage, let's create individual pages for each post:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Create a new file in &lt;code&gt;src/pages&lt;/code&gt; called &lt;code&gt;posts/[slug].astro&lt;/code&gt;. The square brackets in the filename tell Astro that this is a dynamic route.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Open &lt;code&gt;[slug].astro&lt;/code&gt; and add the following code:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ---
   export async function getStaticPaths() {
     const response = await fetch(import.meta.env.WP_URL, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
         query: `
           query AllPosts {
             posts {
               nodes {
                 slug
               }
             }
           }
         `
       })
     });

     const json = await response.json();
     const posts = json.data.posts.nodes;

     return posts.map((post) =&amp;gt; {
       return {
         params: { slug: post.slug },
         props: { slug: post.slug },
       };
     });
   }

   const { slug } = Astro.props;

   const response = await fetch(import.meta.env.WP_URL, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({
       query: `
         query SinglePost($slug: ID!) {
           post(id: $slug, idType: SLUG) {
             title
             content
           }
         }
       `,
       variables: {
         slug: slug,
       }
     })
   });

   const json = await response.json();
   const post = json.data.post;
   ---

   &amp;lt;html lang="en"&amp;gt;
     &amp;lt;head&amp;gt;
       &amp;lt;meta charset="utf-8" /&amp;gt;
       &amp;lt;meta name="viewport" content="width=device-width" /&amp;gt;
       &amp;lt;title&amp;gt;{post.title}&amp;lt;/title&amp;gt;
     &amp;lt;/head&amp;gt;
     &amp;lt;body&amp;gt;
       &amp;lt;h1&amp;gt;{post.title}&amp;lt;/h1&amp;gt;
       &amp;lt;div set:html={post.content}&amp;gt;&amp;lt;/div&amp;gt;
       &amp;lt;a href="/"&amp;gt;Back to Home&amp;lt;/a&amp;gt;
     &amp;lt;/body&amp;gt;
   &amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code does a few important things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;getStaticPaths&lt;/code&gt; function fetches all post slugs from WordPress and tells Astro to create a page for each one.&lt;/li&gt;
&lt;li&gt;We then fetch the specific post data for each page and render it.&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;Now, if you click on the "Read more" links on your homepage, you should be taken to individual post pages!&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Awesome work! We've now got a fully functional headless WordPress site built with Astro. Of course, there's always room for improvement. In the next sections, we'll look at styling our site and optimizing its performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Styling Your Astro Site
&lt;/h2&gt;

&lt;p&gt;Now that we've got our content displaying correctly, let's make it look a bit nicer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Astro supports several styling options out of the box. For this tutorial, we'll use Astro's built-in CSS support.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Create a new file in &lt;code&gt;src/styles&lt;/code&gt; called &lt;code&gt;global.css&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add some basic styles to &lt;code&gt;global.css&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;   &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Arial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;800px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;

   &lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;h2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#2c3e50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;

   &lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#3498db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
     &lt;span class="nl"&gt;text-decoration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;

   &lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
     &lt;span class="nl"&gt;text-decoration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;underline&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
   &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Now, let's import this CSS file in our pages. In both &lt;code&gt;index.astro&lt;/code&gt; and &lt;code&gt;posts/[slug].astro&lt;/code&gt;, add this line in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; section:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   &amp;lt;link rel="stylesheet" href="/styles/global.css" /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Refresh your browser, and you should see a much nicer-looking site!&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Remember, this is just a starting point. Feel free to expand on these styles and make the site your own!&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimizing Performance
&lt;/h2&gt;

&lt;p&gt;One of Astro's big selling points is its focus on performance. Let's take advantage of some of Astro's features to make our site even faster:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Astro uses partial hydration, which means it only sends JavaScript to the browser when it's needed. This is great for performance, but we haven't actually used any client-side JavaScript yet. If you need interactivity, you can use Astro's client directives like &lt;code&gt;client:load&lt;/code&gt; or &lt;code&gt;client:idle&lt;/code&gt; on your components.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;For image optimization, Astro has a built-in Image component. Let's use it for our post thumbnails. First, install the sharp package:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npm &lt;span class="nb"&gt;install &lt;/span&gt;sharp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Before we can render a thumbnail, we need to actually fetch the featured image data. Update the GraphQL query in your &lt;code&gt;index.astro&lt;/code&gt; to include the &lt;code&gt;featuredImage&lt;/code&gt; field:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HomePagePosts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
       &lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="n"&gt;excerpt&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="n"&gt;featuredImage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
           &lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
             &lt;/span&gt;&lt;span class="n"&gt;sourceUrl&lt;/span&gt;&lt;span class="w"&gt;
           &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
         &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
       &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
   &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Then, in your &lt;code&gt;index.astro&lt;/code&gt; file, import the Image component and use it for your post thumbnails:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ---
   import { Image } from 'astro:assets';
   // ... rest of your frontmatter code
   ---

   &amp;lt;!-- In your HTML, inside the posts.map() loop --&amp;gt;
   {post.featuredImage &amp;amp;&amp;amp; (
     &amp;lt;Image
       src={post.featuredImage.node.sourceUrl}
       width={300}
       height={200}
       alt={post.title}
       inferSize
     /&amp;gt;
   )}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: Remote images from WordPress need their domain authorized in &lt;code&gt;astro.config.mjs&lt;/code&gt; under the &lt;code&gt;image.domains&lt;/code&gt; (or &lt;code&gt;image.remotePatterns&lt;/code&gt;) setting before Astro will optimize them.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Astro also automatically optimizes your CSS and HTML. It removes unused CSS and minifies your HTML in production builds.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Deployment
&lt;/h2&gt;

&lt;p&gt;We're in the home stretch! Let's get your site deployed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First, build your site with:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;This will create a &lt;code&gt;dist&lt;/code&gt; folder with your production-ready site.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You can deploy this folder to any static hosting service. Netlify, Vercel, and Cloudflare Pages are popular options that work great with Astro.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If you're using Netlify, you can simply drag and drop your &lt;code&gt;dist&lt;/code&gt; folder onto their site to deploy.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;For automated deployments, you can set up a GitHub repository for your project and connect it to your hosting service. Then, every time you push to your main branch, your site will automatically rebuild and deploy.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;And there you have it! We've successfully set up a &lt;a href="https://stackrater.io/tools/best-wordpress-blog-alternatives/" rel="noopener noreferrer"&gt;headless WordPress blog with Astro&lt;/a&gt;. We've covered everything from initial setup to deployment, touching on data fetching, routing, styling, and performance optimization along the way.&lt;/p&gt;

&lt;p&gt;Remember, this is just the beginning. There's so much more you can do with this setup. You could add custom post types, implement search functionality, or even turn your site into a full-fledged e-commerce platform.&lt;/p&gt;

&lt;p&gt;I hope this tutorial has been helpful and has sparked some ideas for your own projects. Happy coding!&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting Common Issues
&lt;/h2&gt;

&lt;p&gt;Before we wrap up, let's quickly address some common issues you might run into:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;CORS errors: If you're getting CORS errors when trying to fetch data from WordPress, you may need to install a CORS plugin in WordPress or configure your server to allow cross-origin requests.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;GraphQL errors: Double-check your query syntax if you're getting GraphQL errors. The WPGraphQL plugin provides a GraphiQL interface in the WordPress admin panel where you can test your queries.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Astro build problems: If you're having issues building your Astro site, make sure all your dependencies are up to date. You can also try clearing your &lt;code&gt;.astro&lt;/code&gt; cache folder.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Remember, the Astro and WordPress communities are very helpful. If you run into any issues you can't solve, don't hesitate to reach out for help!&lt;/p&gt;

&lt;p&gt;P.S. if you haven't already, you definitely need to &lt;a href="https://github.com/emdash-cms/emdash" rel="noopener noreferrer"&gt;check out EmDash&lt;/a&gt;, a CMS built on Astro and Cloudflare.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article has been updated from its original version. Changes include: clarifying the "Empty" (minimal) Astro template name, switching React setup to the recommended &lt;code&gt;npx astro add react&lt;/code&gt; command, adding a fix for the Astro 6 &lt;code&gt;integrations: Required&lt;/code&gt; error, and correcting the featured-image example to fetch the &lt;code&gt;featuredImage&lt;/code&gt; field it relied on.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>headless</category>
      <category>wordpress</category>
      <category>astro</category>
      <category>cms</category>
    </item>
  </channel>
</rss>
