<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Astro - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Astro - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 17 Jul 2026 22:27:48 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/astro/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Headless WordPress Frontend with Astro SSR on Cloudflare Pages ]]>
                </title>
                <description>
                    <![CDATA[ This tutorial shows you how to run WordPress as a headless CMS with an Astro frontend deployed to Cloudflare Pages. For a project I was recently working on, the requirement was to use WordPress as the ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-headless-wordpress-frontend-with-astro-ssr-on-cloudflare-pages/</link>
                <guid isPermaLink="false">69e65d6ec9501dd0100e2105</guid>
                
                    <category>
                        <![CDATA[ WordPress ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Astro ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ headless cms ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tech With RJ ]]>
                </dc:creator>
                <pubDate>Mon, 20 Apr 2026 17:07:58 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/87087639-b2d1-4641-a2c9-f8f369b49406.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>This tutorial shows you how to run WordPress as a headless CMS with an Astro frontend deployed to Cloudflare Pages.</p>
<p>For a project I was recently working on, the requirement was to use WordPress as the site's backend. Content management, blog posts, and media were all handled through the WordPress admin. The frontend was open: it could be a theme, a template, or something customized through Elementor.</p>
<p>I could've built the same result in Elementor, but the process would've been slower and harder to maintain. Drag-and-drop works until the design gets specific, and then every small tweak costs more time than it should.</p>
<p>As a full stack developer, writing code turned out to be faster for me and produced cleaner output. Tools like Claude Code make the iteration cycle even tighter. So I kept the requirement –&nbsp;WordPress as the backend – and decided to build the frontend separately in code.</p>
<p>I wanted to share how I did this so that, if you're facing similar requirements, you'll know the way forward.</p>
<p>By the end of this tutorial, you'll have:</p>
<ul>
<li><p>A WordPress install serving content through its REST API on a subdomain</p>
</li>
<li><p>An Astro SSR frontend rendering the content on the root domain</p>
</li>
<li><p>A Cloudflare Pages deployment triggered on every git push</p>
</li>
<li><p>Security hardening for a headless WordPress setup</p>
</li>
<li><p>Draft post preview working across both systems</p>
</li>
</ul>
<p><strong>Prerequisites:</strong> You should be comfortable with the command line, have basic familiarity with WordPress admin, and know enough JavaScript to read and write simple functions.</p>
<p>To follow along, you'll need a WordPress installation, a GitHub account, and a Cloudflare account.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-headless-wordpress">Why Headless WordPress?</a></p>
</li>
<li><p><a href="#heading-the-architecture">The Architecture</a></p>
</li>
<li><p><a href="#heading-why-astro">Why Astro?</a></p>
</li>
<li><p><a href="#heading-infrastructure-setup">Infrastructure Setup</a></p>
<ul>
<li><p><a href="#heading-step-1-move-dns-to-cloudflare">Step 1: Move DNS to Cloudflare</a></p>
</li>
<li><p><a href="#heading-step-2-create-the-cms-subdomain">Step 2: Create the CMS Subdomain</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-wordpress-configuration">WordPress Configuration</a></p>
<ul>
<li><p><a href="#heading-tell-wordpress-it-lives-on-the-subdomain">Tell WordPress it Lives on the Subdomain</a></p>
</li>
<li><p><a href="#heading-must-use-plugin-redirect-and-preview">Must-Use Plugin: Redirect and Preview</a></p>
</li>
<li><p><a href="#heading-clean-up-plugins">Clean Up Plugins</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-astro-frontend">The Astro Frontend</a></p>
<ul>
<li><p><a href="#heading-astroconfigmjs">astro.config.mjs</a></p>
</li>
<li><p><a href="#heading-env">.env</a></p>
</li>
<li><p><a href="#heading-srclibwordpressjs">src/lib/wordpress.js</a></p>
</li>
<li><p><a href="#heading-srcmiddlewarejs">src/middleware.js</a></p>
</li>
<li><p><a href="#heading-srclayoutslayoutastro">src/layouts/Layout.astro</a></p>
</li>
<li><p><a href="#heading-srcpagesblogindexastro">src/pages/blog/index.astro</a></p>
</li>
<li><p><a href="#heading-srcpagesblogslugastro">src/pages/blog/[slug].astro</a></p>
</li>
<li><p><a href="#heading-srcpagessitemapxmlts">src/pages/sitemap.xml.ts</a></p>
</li>
<li><p><a href="#heading-srcstylesglobalcss">src/styles/global.css</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-cicd-with-cloudflare-pages">CI/CD with Cloudflare Pages</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
<li><p><a href="#heading-good-to-know">Good to Know</a></p>
</li>
</ul>
<h2 id="heading-why-headless-wordpress">Why Headless WordPress?</h2>
<p>Headless WordPress separates content management from content delivery. WordPress keeps doing what it handles well: storing content and giving editors a familiar admin interface. A separate frontend handles rendering, routing, and performance.</p>
<p>A few situations where this split pays off:</p>
<ul>
<li><p>Your content team is trained on WordPress and moving them elsewhere would slow everyone down. Headless preserves their workflow and gives you a modern frontend.</p>
</li>
<li><p>Your site needs a design or interaction pattern that a WordPress theme or page builder struggles to deliver. Custom dashboards, interactive tools, data-driven layouts, or integrations with non-WordPress APIs all fit here.</p>
</li>
<li><p>You want edge delivery and modern tooling without rebuilding content management from scratch. WordPress handles content and media well. A JavaScript frontend on a CDN handles delivery well. Headless lets each side do its job.</p>
</li>
<li><p>You need the same content across multiple surfaces. One WordPress install feeds a marketing site, a mobile app, and an internal dashboard through the same REST API.</p>
</li>
</ul>
<p>Headless is not a fit for every site. Skip it if your site is a simple brochure, if one person does everything in the admin, or if you have no developer time to maintain a second codebase. A regular WordPress theme is the better answer there.</p>
<h2 id="heading-the-architecture">The Architecture</h2>
<p>The term "headless" means you strip WordPress of its frontend responsibility. Instead of WordPress generating and serving HTML pages to visitors, it only stores and serves content through its REST API. A separate frontend framework, in this case Astro, handles what the visitor actually sees.</p>
<img src="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/0508174b-0740-47cf-a780-943a0c254ae1.png" alt="Diagram of a headless WordPress setup with Cloudflare Pages and Astro SSR fetching content via the WordPress REST API, with GitHub auto-deploy and a CMS subdomain for editors." style="display:block;margin:0 auto" width="7093" height="1017" loading="lazy">

<p>When a visitor loads a page, the request hits Cloudflare Pages, which runs the Astro server. Astro fetches the relevant content from WordPress via the REST API, builds the HTML, and returns it to the visitor. WordPress never touches the visitor's browser.</p>
<p>Content editors log into the WordPress admin at the CMS subdomain. They write, publish, and manage content as they normally would. The moment they publish, the content is live. There's no rebuild step because Astro fetches fresh data on every request.</p>
<p>The REST API has been built into WordPress since version 4.7. You don't need a GraphQL plugin, a paid headless CMS service, or any extra infrastructure.</p>
<h2 id="heading-why-astro">Why Astro?</h2>
<p>You could use Next.js, Nuxt, or SvelteKit here as well. But I chose Astro because its defaults fit this use case.</p>
<p>Astro compiles components to plain HTML and ships zero JavaScript to the browser by default. You only add client-side JavaScript where you explicitly need it.</p>
<p>For a CMS-driven site, most pages need none. SSR mode means every request fetches fresh data from WordPress at runtime, so content changes go live immediately without a rebuild. Cloudflare has an official adapter that handles the build output. Tailwind v4 integrates through a Vite plugin with no config file needed.</p>
<p>If WordPress wasn't a requirement, I would have used Next.js with Payload CMS. Payload gives you a fully typed CMS built in TypeScript that sits inside the same Next.js project, with more control over your content schema from day one. But the requirement was WordPress, and for a WordPress REST API frontend, Astro is the faster and cleaner choice.</p>
<h2 id="heading-infrastructure-setup">Infrastructure Setup</h2>
<p>Here's my setup: domain at Namecheap, WordPress on Hostinger shared hosting, and a Google Workspace email. The steps below apply to any host, whether shared hosting with cPanel or hPanel, a VPS with Apache or Nginx, or a self-managed server.</p>
<h3 id="heading-step-1-move-dns-to-cloudflare">Step 1: Move DNS to Cloudflare</h3>
<p>First, you'll need to move your domain's nameservers to Cloudflare. This gives you free DDoS protection, SSL, and the ability to attach a custom domain to Cloudflare Pages.</p>
<p>Before switching, verify that all DNS records transferred correctly, including your website A or CNAME records. For email, get your MX, SPF, DKIM, and DMARC values from your email provider's admin panel and add them to Cloudflare DNS first, otherwise email breaks during propagation.</p>
<h3 id="heading-step-2-create-the-cms-subdomain">Step 2: Create the CMS Subdomain</h3>
<p>Move WordPress to <code>cms.yourdomain.com</code> so the root domain is free for Astro. In Cloudflare DNS, add an A record pointing <code>cms</code> at your server IP, or a CNAME if your host uses a CDN hostname. Then create the subdomain in your hosting panel pointing to the same WordPress directory.</p>
<p>One thing people miss: your server needs its own SSL certificate for the connection between Cloudflare and your origin to work. Cloudflare handles SSL at its edge, but if the origin has no certificate, you get a 525 error.</p>
<p>On Hostinger, this isn't automatic for new subdomains. Install it manually through hPanel. On cPanel, use Let's Encrypt. On a VPS, use Certbot.</p>
<p>Moving WordPress off the root domain also means <code>/wp-admin</code> no longer exists at your main domain, which reduces exposure. But the default login path is still <code>/wp-admin</code> on the subdomain. That is the first thing you should change — more on this in the Good to Know section at the end.</p>
<h2 id="heading-wordpress-configuration">WordPress Configuration</h2>
<h3 id="heading-tell-wordpress-it-lives-on-the-subdomain">Tell WordPress it Lives on the Subdomain</h3>
<p>In <code>wp-config.php</code>, before the "That's all, stop editing!" comment:</p>
<pre><code class="language-php">define('WP_HOME',    'https://cms.yourdomain.com');
define('WP_SITEURL', 'https://cms.yourdomain.com');
</code></pre>
<p>WordPress admin is now at <code>cms.yourdomain.com/wp-admin</code>. The old path at the root domain stops working. That's intentional.</p>
<h3 id="heading-must-use-plugin-redirect-and-preview">Must-Use Plugin: Redirect and Preview</h3>
<p>WordPress has a folder called <code>mu-plugins</code> inside <code>wp-content</code>. Files placed there are treated as must-use plugins. They load automatically on every request, before regular plugins, and there is no way to activate or deactivate them through the admin UI. This makes them the right place for behaviour you never want accidentally turned off.</p>
<p>Create <code>wp-content/mu-plugins/headless-redirect.php</code>:</p>
<pre><code class="language-php">&lt;?php
/*
Plugin Name: Headless Redirect
Description: Redirects frontend visitors to the Astro site and rewires the WordPress preview link.
*/

add_action('template_redirect', function() {
    if (is_user_logged_in()) return;
    if ($_SERVER['HTTP_HOST'] === 'cms.yourdomain.com') {
        wp_redirect('https://yourdomain.com', 302);
        exit;
    }
});

add_filter('preview_post_link', function(\(link, \)post) {
    $token = HEADLESS_PREVIEW_SECRET;
    \(type  = \)post-&gt;post_type;
    return 'https://yourdomain.com/preview?type=' . \(type . '&amp;id=' . \)post-&gt;ID . '&amp;token=' . $token;
}, 10, 2);
</code></pre>
<p>The <code>template_redirect</code> action fires when WordPress is about to render a page. If the visitor isn't logged in and the request is on the CMS subdomain, it redirects them to the main frontend. Logged-in editors pass through to the admin normally. REST API requests to <code>/wp-json/...</code> don't go through <code>template_redirect</code> at all, so they are unaffected.</p>
<p>The <code>preview_post_link</code> filter changes what happens when an editor clicks Preview on a draft post. By default, WordPress previews using its own theme, which in a headless setup renders blank.</p>
<p>This filter replaces that URL with a request to your Astro <code>/preview</code> page, passing the post ID, post type, and a secret token. Your Astro preview page uses those values to fetch the draft via the REST API and renders it exactly as it would appear live.</p>
<h3 id="heading-clean-up-plugins">Clean Up Plugins</h3>
<p>Now it's time to remove everything that renders the frontend: page builders, caching plugins, and hosting onboarding plugins.</p>
<p>But you'll want to keep Akismet, Wordfence, and Yoast SEO. Yoast adds SEO meta and Open Graph data directly to the REST API response, which your Astro pages read through <code>post.yoast_head_json</code>.</p>
<p>Then switch the active theme to a lightweight default. WordPress requires one active, but nobody sees it.</p>
<h2 id="heading-the-astro-frontend">The Astro Frontend</h2>
<p>Start with <code>pnpm create astro@latest</code>, then install the Cloudflare adapter and Tailwind:</p>
<pre><code class="language-bash">pnpm add @astrojs/cloudflare
pnpm add -D @tailwindcss/vite tailwindcss
</code></pre>
<h3 id="heading-astroconfigmjs">astro.config.mjs</h3>
<pre><code class="language-js">import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  output: 'server',
  adapter: cloudflare({ imageService: 'passthrough' }),
  vite: { plugins: [tailwindcss()] },
})
</code></pre>
<p><code>output: 'server'</code> puts Astro into full SSR mode. Without it, Astro pre-renders pages at build time, which breaks dynamic routes like <code>/blog/[slug]</code> that depend on WordPress content that didn't exist at build time.</p>
<p><code>imageService: 'passthrough'</code> is required specifically for Cloudflare Workers. Astro's default image service uses Sharp, which depends on <code>child_process</code> and <code>fs</code>. Those Node.js built-ins don't exist in the Cloudflare Workers runtime. The deployment fails with a module resolution error. Setting passthrough skips image processing entirely and renders standard <code>&lt;img&gt;</code> tags instead.</p>
<h3 id="heading-env">.env</h3>
<pre><code class="language-bash">WORDPRESS_API_URL=https://cms.yourdomain.com
</code></pre>
<p>Add this same variable in Cloudflare Pages project settings under Environment Variables before deploying.</p>
<h3 id="heading-srclibwordpressjs">src/lib/wordpress.js</h3>
<p>This file is the single place all WordPress API calls go through. Centralising them means if the API URL or authentication changes, you update one file.</p>
<p>The <code>_embed</code> parameter is important. By default, a post response only includes the post data. Featured images, author details, and categories are separate entities with their own IDs. Without <code>_embed</code>, you would need additional API requests to fetch each one. Adding it inlines all that related data into the same response.</p>
<p><code>cache: 'no-store'</code> on every fetch call is not optional. Cloudflare Workers runs a fetch cache internally that's separate from HTTP <code>Cache-Control</code> headers. Without disabling it, Cloudflare caches your WordPress API responses at the edge. An editor publishes a post and sees the old version on the frontend because the cached response is being served.</p>
<pre><code class="language-js">const WP_URL = import.meta.env.WORDPRESS_API_URL

const fetchWP = (path) =&gt;
  fetch(`\({WP_URL}\){path}`, { cache: 'no-store' }).then((r) =&gt; r.json())

export const getPosts = (page = 1, perPage = 10) =&gt;
  fetchWP(`/wp-json/wp/v2/posts?_embed&amp;per_page=\({perPage}&amp;page=\){page}`)

export const getPostBySlug = async (slug) =&gt; {
  const posts = await fetchWP(`/wp-json/wp/v2/posts?_embed&amp;slug=${slug}`)
  return posts[0]
}

export const getCategories = () =&gt;
  fetchWP(`/wp-json/wp/v2/categories`)

export const getPostsByCategory = (categoryId, page = 1) =&gt;
  fetchWP(`/wp-json/wp/v2/posts?_embed&amp;categories=\({categoryId}&amp;page=\){page}`)

export const getAllPostsForSitemap = () =&gt;
  fetchWP(`/wp-json/wp/v2/posts?_fields=slug,modified&amp;per_page=100`)
</code></pre>
<p>The sitemap function uses <code>_fields</code> instead of <code>_embed</code> to fetch only the fields it needs, keeping that request lightweight.</p>
<h3 id="heading-srcmiddlewarejs">src/middleware.js</h3>
<p>Middleware runs on every request before the page handler. This one adds <code>Cache-Control: no-store</code> to every SSR response so Cloudflare doesn't cache the rendered HTML pages.</p>
<pre><code class="language-js">export function onRequest(_context, next) {
  return next().then(response =&gt; {
    const newResponse = new Response(response.body, response)
    newResponse.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate')
    newResponse.headers.set('CDN-Cache-Control', 'no-store')
    return newResponse
  })
}
</code></pre>
<p>The original Response from Astro has immutable headers, so you can't call <code>.headers.set()</code> on it directly. The fix is to construct a new Response using the original body and response as the init argument. The new Response has mutable headers, so <code>.set()</code> works. <code>CDN-Cache-Control</code> is a Cloudflare-specific header that controls caching at the edge independently from the standard <code>Cache-Control</code> header.</p>
<h3 id="heading-srclayoutslayoutastro">src/layouts/Layout.astro</h3>
<p>Every page goes through this layout. HTML structure, meta tags, and global imports live here so you don't repeat them on every page.</p>
<pre><code class="language-astro">---
interface Props {
  title: string
  description?: string
}
const { title, description = '' } = Astro.props
---
&lt;!doctype html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0" /&gt;
    &lt;title&gt;{title}&lt;/title&gt;
    &lt;meta name="description" content={description} /&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;slot name="nav" /&gt;
    &lt;main id="main-content"&gt;&lt;slot /&gt;&lt;/main&gt;
    &lt;slot name="footer" /&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Named slots let the navbar and footer sit outside <code>&lt;main&gt;</code>, keeping the HTML landmark structure correct for accessibility.</p>
<h3 id="heading-srcpagesblogindexastro">src/pages/blog/index.astro</h3>
<pre><code class="language-astro">---
import Layout from '../../layouts/Layout.astro'
import { getPosts, getCategories, getPostsByCategory } from '../../lib/wordpress'

const page = Number(Astro.url.searchParams.get('page') ?? 1)
const categoryId = Astro.url.searchParams.get('category')

const [posts, categories] = await Promise.all([
  categoryId ? getPostsByCategory(categoryId, page) : getPosts(page, 10),
  getCategories(),
])
---
&lt;Layout title="Blog"&gt;
  &lt;nav&gt;
    &lt;a href="/blog"&gt;All&lt;/a&gt;
    {categories.map((cat) =&gt; (
      &lt;a href={`/blog?category=${cat.id}`}&gt;{cat.name}&lt;/a&gt;
    ))}
  &lt;/nav&gt;

  &lt;ul&gt;
    {posts.map((post) =&gt; {
      const image   = post._embedded?.['wp:featuredmedia']?.[0]?.source_url
      const imageAlt = post._embedded?.['wp:featuredmedia']?.[0]?.alt_text ?? ''
      return (
        &lt;li&gt;
          {image &amp;&amp; &lt;img src={image} alt={imageAlt} /&gt;}
          &lt;a href={`/blog/${post.slug}`} set:html={post.title.rendered} /&gt;
          &lt;div set:html={post.excerpt.rendered} /&gt;
        &lt;/li&gt;
      )
    })}
  &lt;/ul&gt;

  {page &gt; 1 &amp;&amp; &lt;a href={`/blog?page=${page - 1}`}&gt;Previous&lt;/a&gt;}
  &lt;a href={`/blog?page=${page + 1}`}&gt;Next&lt;/a&gt;
&lt;/Layout&gt;
</code></pre>
<p><code>Promise.all</code> fetches posts and categories in parallel. The category filter reads from the URL query string so the same page handles both <code>/blog</code> and <code>/blog?category=5</code> without separate routes.</p>
<p>Featured images live inside <code>post._embedded['wp:featuredmedia'][0]</code> because <code>_embed</code> inlines the media object into the post response.</p>
<h3 id="heading-srcpagesblogslugastro">src/pages/blog/[slug].astro</h3>
<pre><code class="language-astro">---
import Layout from '../../layouts/Layout.astro'
import { getPostBySlug } from '../../lib/wordpress'

const { slug } = Astro.params
const post = await getPostBySlug(slug)
if (!post) return Astro.redirect('/404')

const image    = post._embedded?.['wp:featuredmedia']?.[0]?.source_url
const imageAlt = post._embedded?.['wp:featuredmedia']?.[0]?.alt_text ?? ''
const author   = post._embedded?.author?.[0]?.name
const seoTitle = post.yoast_head_json?.title ?? post.title.rendered
const seoDesc  = post.yoast_head_json?.og_description ?? ''
---
&lt;Layout title={seoTitle} description={seoDesc}&gt;
  &lt;article&gt;
    &lt;h1 set:html={post.title.rendered} /&gt;
    &lt;p&gt;{author} · {new Date(post.date).toLocaleDateString()}&lt;/p&gt;
    {image &amp;&amp; &lt;img src={image} alt={imageAlt} /&gt;}
    &lt;div set:html={post.content.rendered} /&gt;
  &lt;/article&gt;
&lt;/Layout&gt;
</code></pre>
<p>Use <code>set:html</code> for WordPress content, not <code>{post.content.rendered}</code>. Astro treats curly brace expressions as text and escapes the HTML, so you see raw tags printed on the page instead of rendered content.</p>
<p>Always guard with <code>if (!post) return Astro.redirect('/404')</code>. If someone visits a slug that doesn't exist, the API returns an empty array. Without the guard, accessing properties on <code>undefined</code> throws an error that crashes the Cloudflare Worker and returns a 500.</p>
<p><code>post.yoast_head_json</code> is available when Yoast SEO is active. It contains the computed SEO title and description that Yoast generates. Using it means the SEO work done in WordPress carries over to the Astro frontend automatically.</p>
<h3 id="heading-srcpagessitemapxmlts">src/pages/sitemap.xml.ts</h3>
<pre><code class="language-ts">import type { APIRoute } from 'astro'
import { getAllPostsForSitemap } from '../lib/wordpress'

export const GET: APIRoute = async () =&gt; {
  const posts = await getAllPostsForSitemap()

  const urls = [
    { loc: 'https://yourdomain.com/', lastmod: new Date().toISOString() },
    { loc: 'https://yourdomain.com/blog/', lastmod: new Date().toISOString() },
    ...posts.map((p) =&gt; ({
      loc: `https://yourdomain.com/blog/${p.slug}/`,
      lastmod: p.modified,
    })),
  ]

  const xml = `&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&gt;
\({urls.map((u) =&gt; `  &lt;url&gt;\n    &lt;loc&gt;\){u.loc}&lt;/loc&gt;\n    &lt;lastmod&gt;${u.lastmod}&lt;/lastmod&gt;\n  &lt;/url&gt;`).join('\n')}
&lt;/urlset&gt;`

  return new Response(xml, { headers: { 'Content-Type': 'application/xml' } })
}
</code></pre>
<p>This generates fresh XML on every request, so the sitemap always reflects currently published posts without a rebuild.</p>
<h3 id="heading-srcstylesglobalcss">src/styles/global.css</h3>
<pre><code class="language-css">@import "tailwindcss";

@theme {
  --color-brand: #your-color;
  --font-sans: 'Your Font', sans-serif;
}
</code></pre>
<p>Tailwind v4 uses CSS-first configuration through the <code>@theme</code> block. CSS variables defined here become Tailwind utilities automatically. <code>--color-brand</code> becomes <code>bg-brand</code>, <code>text-brand</code>, and so on. No <code>tailwind.config.js</code> needed.</p>
<h2 id="heading-cicd-with-cloudflare-pages">CI/CD with Cloudflare Pages</h2>
<p>With the Astro code in place, the last piece is getting it deployed. Cloudflare Pages connects directly to GitHub, so you don't have to maintain a separate pipeline.</p>
<p>Here are the steps:</p>
<ol>
<li><p>Push your repo to GitHub.</p>
</li>
<li><p>Go to Cloudflare Pages, create a project, connect it to your GitHub repository.</p>
</li>
<li><p>Set the build command to <code>pnpm build</code> and the output directory to <code>dist</code>.</p>
</li>
<li><p>Under Environment Variables, add <code>WORDPRESS_API_URL</code> pointing to <code>https://cms.yourdomain.com</code>.</p>
</li>
<li><p>Deploy.</p>
</li>
</ol>
<p>After the first deploy, every push to <code>main</code> triggers a new deployment automatically. Cloudflare runs the build, and within minutes the new version is live globally. Content updates in WordPress go live immediately, since Astro fetches from WordPress on every request. A developer pushing code and an editor publishing a post are completely independent operations.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>This setup exists because of the specific requirement that the content team was already on WordPress and changing that was not on the table.</p>
<p>If you're starting fresh with no CMS in place, this is probably not the stack you want. Go with something like Next.js and Payload CMS where the backend and frontend are designed to work together from the start.</p>
<p>But if your situation matches where content editors are already familiar with WordPress, and you need a custom frontend that a page builder can't deliver cleanly, then this separation makes sense.</p>
<p>Pros:</p>
<ul>
<li><p>Content editors keep using WordPress. No retraining, no migration.</p>
</li>
<li><p>The frontend has full control over design and behaviour. No theme or plugin constraints.</p>
</li>
<li><p>Deployments are automatic on every push. Content changes go live immediately without a rebuild.</p>
</li>
<li><p>No added cost for most sites. WordPress stays on its existing host. Cloudflare Pages is free within generous limits, and scales to $5 per month on the Workers Paid plan if you outgrow them.</p>
</li>
</ul>
<p>Cons:</p>
<ul>
<li><p>Two systems to maintain instead of one. You operate the WordPress install (updates, plugins, backups) and maintain the Astro codebase separately.</p>
</li>
<li><p>The WordPress REST API has limitations. Complex content structures or real-time features need more work to handle compared to a purpose-built headless CMS.</p>
</li>
<li><p>Adapter and deployment target are tied together. @astrojs/cloudflare v13 drops Pages support in favor of Workers, so staying on Pages means staying on v12. Details in the Good to Know section.</p>
</li>
<li><p>Frontend changes require a developer. With Elementor, anyone with admin access could adjust layouts directly in the browser. Here, any visual change outside of content goes through code, which means it goes through you.</p>
</li>
</ul>
<p>The stack is WordPress on existing hosting, Astro on Cloudflare Pages, with GitHub as the bridge between development and production. It solves a specific problem cleanly. Outside of that problem, there are better options.</p>
<h2 id="heading-good-to-know">Good to Know</h2>
<p><strong>Change the default login URL immediately.</strong> Every bot targets <code>/wp-login.php</code> and <code>/wp-admin</code>. Install WPS Hide Login and move it to something custom. Anyone hitting the default paths gets a 404.</p>
<p><strong>Remove the</strong> <code>/wp-json/wp/v2/users</code> <strong>endpoint.</strong> It returns a public list of usernames. In headless mode you get author data through <code>_embed</code> and have no use for this endpoint. Add to the mu-plugin:</p>
<pre><code class="language-php">add_filter('rest_endpoints', function($endpoints) {
    unset($endpoints['/wp/v2/users']);
    unset($endpoints['/wp/v2/users/(?P&lt;id&gt;[\d]+)']);
    return $endpoints;
});
</code></pre>
<p><strong>Disable XML-RPC and enable 2FA.</strong> Add <code>add_filter('xmlrpc_enabled', '__return_false')</code> to the mu-plugin — you aren't using it in headless mode and it's a common brute force target. Enable Wordfence's Brute Force Protection and add two-factor authentication through WP 2FA for all admin accounts.</p>
<p><strong>Don't upgrade</strong> <code>@astrojs/cloudflare</code> <strong>to v13 if you deploy via Cloudflare Pages git-push CI.</strong> v12 outputs <code>dist/_worker.js</code> which Pages CI expects. v13 outputs a different format for <code>wrangler deploy</code> — Pages CI falls back to serving the <code>dist</code> folder as a static site and every SSR route returns 404 with no helpful error message.</p>
<p><strong>The v12 adapter throws a deprecation warning on</strong> <code>entrypointResolution</code><strong>.</strong> Silence it by adding <code>entrypointResolution: 'auto'</code> to the adapter options. Test before committing — it changes how the build locates the Worker entry file.</p>
<p><strong>Custom Post Types follow the same pattern.</strong> Register the CPT with <code>show_in_rest: true</code> and a <code>rest_base</code>, and it shows up at <code>/wp-json/wp/v2/your-base</code>. The same fetch helpers, <code>_embed</code>, and slug routing work exactly the same way.</p>
<p><strong>The REST API returns pagination headers.</strong> The raw response includes <code>X-WP-Total</code> and <code>X-WP-TotalPages</code> headers before you call <code>.json()</code>. If you want proper previous/next pagination, read those instead of guessing whether a next page exists.</p>
<p><strong>Wrap API calls in try/catch.</strong> If WordPress is unreachable, an unhandled fetch throws and returns a 500. A try/catch returns an empty page instead, which is a much better failure mode.</p>
<p><strong>Preview auth uses Application Passwords.</strong> WordPress 5.6 added Application Passwords under Users → Profile. That's what <code>WP_APP_USER</code> and <code>WP_APP_PASSWORD</code> in your <code>.env</code> should point to — not your regular admin password. Generate one per environment. Define the preview token as a constant in <code>wp-config.php</code> (<code>define('HEADLESS_PREVIEW_SECRET', '...')</code>) and reference that constant in the mu-plugin — never hardcode secrets in version-controlled files.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Secure SSR Authentication with Supabase, Astro, and Cloudflare Turnstile ]]>
                </title>
                <description>
                    <![CDATA[ In this guide, you'll build a full server-side rendered (SSR) authentication system using Astro, Supabase, and Cloudflare Turnstile to protect against bots. By the end, you'll have a fully functional authentication system with Astro actions, magic li... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-secure-ssr-authentication-with-supabase-astro-and-cloudflare-turnstile/</link>
                <guid isPermaLink="false">685594145aea0dba325c37e1</guid>
                
                    <category>
                        <![CDATA[ supabase ss ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Astro ]]>
                    </category>
                
                    <category>
                        <![CDATA[ authentication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ supabase ]]>
                    </category>
                
                    <category>
                        <![CDATA[ supabase auth ]]>
                    </category>
                
                    <category>
                        <![CDATA[ magic links ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloudflare Turnstile ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SSR ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Fatuma Abdullahi ]]>
                </dc:creator>
                <pubDate>Fri, 20 Jun 2025 17:02:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1750438909287/d36c0c01-e779-4eea-aa41-b797fcbb05f6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this guide, you'll build a full server-side rendered (SSR) authentication system using Astro, Supabase, and Cloudflare Turnstile to protect against bots.</p>
<p>By the end, you'll have a fully functional authentication system with Astro actions, magic link authentication using Supabase, bot protection via Cloudflare Turnstile, protected routes and middleware, and secure session management.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-understanding-the-technologies">Understanding the Technologies</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-is-astro">What is Astro?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-are-astro-actions">What are Astro Actions?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-supabase">What is Supabase?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-cloudflare-turnstile">What is Cloudflare Turnstile?</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-understanding-ssr-authentication">Understanding SSR Authentication</a></p>
<ul>
<li><a class="post-section-overview" href="#heading-ssr-vs-spa-authentication">SSR vs. SPA Authentication</a></li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-why-protect-auth-forms">Why Protect Auth Forms?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-part-1-how-to-set-up-the-backend">Part 1: How to Set Up the Backend</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-set-up-supabase-backend">Set Up Supabase Backend</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-set-up-cloudflare-turnstile">Set Up Cloudflare Turnstile</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-part-2-how-to-set-up-the-frontend">Part 2: How to Set Up the Frontend</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-create-the-astro-project">Create the Astro Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-configure-astro-for-ssr">Configure Astro for SSR</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-install-supabase-dependencies">Install Supabase Dependencies</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-configure-environment-variables">Configure Environment Variables</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-part-3-how-to-set-up-supabase-ssr">Part 3: How to Set Up Supabase SSR</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-create-the-supabase-client">Create the Supabase Client</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-middleware-for-route-protection">Create Middleware for Route Protection</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-part-4-how-to-build-the-user-interface">Part 4: How to Build the User Interface</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-update-the-layout">Update the Layout</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-the-sign-in-page">Create the Sign-In Page</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-the-protected-page">Create the Protected Page</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-part-5-how-to-set-up-astro-actions">Part 5: How to Set Up Astro Actions</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-create-the-authentication-actions">Create the Authentication Actions</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-the-code-exchange-api-route">Create the Code Exchange API Route</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-part-6-how-to-test-your-application">Part 6: How to Test Your Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-notes-and-additional-resources">Notes and Additional Resources</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-useful-documentation">Useful Documentation</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-complete-code-repository">Complete Code Repository</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This tutorial assumes you are familiar with:</p>
<ul>
<li><p>Web development frameworks</p>
</li>
<li><p><a target="_blank" href="https://www.freecodecamp.org/news/set-up-authentication-in-apps-with-supabase/">Basic authentication flows</a></p>
</li>
<li><p>Basic Backend-as-a-Service (BaaS) concepts</p>
</li>
</ul>
<h2 id="heading-understanding-the-technologies">Understanding the Technologies</h2>
<h3 id="heading-what-is-astro">What is Astro?</h3>
<p><a target="_blank" href="https://docs.astro.build/en/getting-started/">Astro</a> is a UI-agnostic web framework that renders <a target="_blank" href="https://docs.astro.build/en/concepts/why-astro/#server-first">server-first</a> by default. It <a target="_blank" href="https://docs.astro.build/en/guides/integrations-guide/#official-integrations">can be used with any UI framework</a>, including <a target="_blank" href="https://docs.astro.build/en/guides/client-side-scripts/">Astro client components</a>.</p>
<h3 id="heading-what-are-astro-actions">What are Astro Actions?</h3>
<p><a target="_blank" href="https://docs.astro.build/en/guides/actions/">Astro actions</a> allow you to write server-side functions that can be called without explicitly setting up API routes. They provide many useful utilities that simplify the process of running server logic and can be called from both client and server environments.</p>
<h3 id="heading-what-is-supabase">What is Supabase?</h3>
<p><a target="_blank" href="https://supabase.com/docs">Supabase</a> is an open-source Backend-as-a-Service that builds upon <a target="_blank" href="https://www.postgresql.org/docs/">Postgres</a>. It provides key features such as authentication, real-time capabilities, edge functions, storage, and more. Supabase offers both a hosted version for easy scaling and a self-hostable version for full control.</p>
<h3 id="heading-what-is-cloudflare-turnstile">What is Cloudflare Turnstile?</h3>
<p>Turnstile is <a target="_blank" href="https://www.cloudflare.com/en-gb/application-services/products/turnstile/">Cloudflare's replacement for CAPTCHAs</a>, which are visual puzzles used to differentiate between genuine users and bots. Unlike traditional CAPTCHAs, which are visually clunky, annoying, and <a target="_blank" href="https://blog.cloudflare.com/turnstile-ga/">sometimes difficult to solve</a>, Turnstile detects malicious activity without requiring users to solve puzzles, while providing a better user experience.</p>
<h2 id="heading-understanding-ssr-authentication">Understanding SSR Authentication</h2>
<p>Server-side rendered (SSR) auth refers to handling authentication on the server using a <a target="_blank" href="https://www.freecodecamp.org/news/set-up-authentication-in-apps-with-supabase/#how-does-authentication-work">cookie-based authentication method</a>.</p>
<p>The flow works as follows:</p>
<ol>
<li><p>The server creates a session and stores a session ID in a cookie sent to the client</p>
</li>
<li><p>The browser receives the cookie and automatically includes it in future requests</p>
</li>
<li><p>The server uses the cookie to determine if the user is authenticated</p>
</li>
</ol>
<p>Since browsers cannot modify HTTP-only cookies and servers cannot access local storage, SSR authentication requires careful management to prevent security risks such as session hijacking and stale sessions.</p>
<h3 id="heading-ssr-vs-spa-authentication">SSR vs. SPA Authentication</h3>
<p>Single-Page Applications (SPAs), like traditional React apps, handle authentication on the client side because they don't have direct access to a server. SPAs typically use JWTs stored in local storage, cookies, or session storage, sending these tokens in HTTP headers when communicating with servers.</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/HdE3dk8VkRU" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
<p> </p>
<h2 id="heading-why-protect-auth-forms">Why Protect Auth Forms?</h2>
<p>Authentication protects sensitive resources from unauthorized access, making auth forms primary targets for bots and malicious actors. Taking extra precautions is important for maintaining security.</p>
<h2 id="heading-part-1-how-to-set-up-the-backend">Part 1: How to Set Up the Backend</h2>
<h3 id="heading-set-up-supabase-backend">Set Up Supabase Backend</h3>
<p>First, you'll need <a target="_blank" href="https://supabase.com/dashboard/">a Supabase account</a>. Create a project, then:</p>
<ol>
<li><p>Go to the Authentication tab in the sidebar</p>
</li>
<li><p>Click the Sign In / Up tab under Configuration</p>
</li>
<li><p>Enable user sign-ups</p>
</li>
<li><p>Scroll down to Auth Providers and enable email (disable email confirmation for this tutorial)</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742054137964/a379192b-4eaf-491f-bcf4-a0e1e0deef94.png" alt="Supabase authentication configuration interface showing user signup options and email provider enabled" width="2480" height="1448" loading="lazy"></p>
<h3 id="heading-set-up-cloudflare-turnstile">Set Up Cloudflare Turnstile</h3>
<ol>
<li><p><a target="_blank" href="https://dash.cloudflare.com/login">Log in or register for a Cloudflare account</a></p>
</li>
<li><p>Click the Turnstile tab in the sidebar</p>
</li>
<li><p>Click the "Add widget" button</p>
</li>
<li><p>Name your widget and add "localhost" as the hostname</p>
</li>
<li><p>Leave all other settings as default, and create the widget</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750260766060/95ec02e5-8ee7-4438-a66c-76866ec068c1.png" alt="Cloudflare Turnstile widget creation interface" width="2200" height="1796" loading="lazy"></p>
<p>After creating the widget, copy the secret key and add it to your Supabase dashboard:</p>
<ol>
<li><p>Go back to Supabase Authentication settings</p>
</li>
<li><p>Navigate to the Auth Protection tab under Configuration</p>
</li>
<li><p>Turn on Captcha protection</p>
</li>
<li><p>Choose Cloudflare as the provider</p>
</li>
<li><p>Paste your secret key</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750260776990/56ef5fc1-3321-45f0-ab9a-878679a08e88.png" alt="Supabase Attack Protection settings with Turnstile configuration" width="2302" height="986" loading="lazy"></p>
<h2 id="heading-part-2-how-to-set-up-the-frontend">Part 2: How to Set Up the Frontend</h2>
<h3 id="heading-create-the-astro-project">Create the Astro Project</h3>
<p>Next, you will need to create an Astro project. Open your preferred IDE or Text editor’s integrated terminal and run the following command to scaffold an Astro project in a folder named “ssr-auth.” Feel free to use any name you like.</p>
<pre><code class="lang-bash">npm create astro@latest ssr-auth
</code></pre>
<p>Follow the provided prompts and choose a basic template to start with. When it’s done, change into the folder, then run <code>npm install</code> to install dependencies, followed by <code>npm run dev</code> to start the server, and your site will be available at <a target="_blank" href="http://localhost:4321"><code>localhost:4321</code></a>.</p>
<h3 id="heading-configure-astro-for-ssr">Configure Astro for SSR</h3>
<p>Set Astro to run in SSR mode by adding <code>output: "server",</code> to the <code>defineConfig</code> function found in the <code>astro.config.mjs</code> file at the root of the folder.</p>
<p>Next, <a target="_blank" href="https://docs.astro.build/en/guides/integrations-guide/node/">add an adapter</a> to create a server runtime. For this, use the Node.js adapter by running this command in a terminal: <code>npx astro add node</code>. This will add it and automatically make all relevant changes.</p>
<p>Finally, add Tailwind for styling. Run this command in a terminal window: <code>npx astro add tailwind</code>. Follow the prompts, and it will make any changes necessary.</p>
<p>At this stage, your <code>astro.config.mjs</code> should look like this:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// @ts-check</span>
<span class="hljs-keyword">import</span> { defineConfig } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro/config"</span>;
<span class="hljs-keyword">import</span> node <span class="hljs-keyword">from</span> <span class="hljs-string">"@astrojs/node"</span>;
<span class="hljs-keyword">import</span> tailwindcss <span class="hljs-keyword">from</span> <span class="hljs-string">"@tailwindcss/vite"</span>;

<span class="hljs-comment">// https://astro.build/config</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> defineConfig({
  output: <span class="hljs-string">"server"</span>,
  adapter: node({
    mode: <span class="hljs-string">"standalone"</span>,
  }),
  vite: {
    plugins: [tailwindcss()],
  },
});
</code></pre>
<h3 id="heading-install-supabase-dependencies">Install Supabase Dependencies</h3>
<p>You can do this by running the following command:</p>
<pre><code class="lang-bash">npm install @supabase/supabase-js @supabase/ssr
</code></pre>
<h3 id="heading-configure-environment-variables">Configure Environment Variables</h3>
<p>Create a <code>.env</code> file in the project root and add the following. Remember to replace with your actual credentials:</p>
<pre><code class="lang-bash">SUPABASE_URL=&lt;YOUR_URL&gt;
SUPABASE_ANON_KEY=&lt;YOUR_ANON_KEY&gt;
TURNSTILE_SITE_KEY=&lt;YOUR_TURNSTILE_SITE_KEY&gt;
</code></pre>
<p>You can get the Supabase values from the dashboard:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742054292788/8aeec326-259c-49bd-a6f8-b885e9a9e6ea.png" alt="Supabase project connection interface showing environment variables" width="2132" height="802" loading="lazy"></p>
<p><strong>💡Note:</strong> In Astro, environment variables accessed on the client side must be prefixed with 'PUBLIC'. But since we're using Astro actions that run on the server, the prefix is not required.</p>
<h2 id="heading-part-3-how-to-set-up-supabase-ssr">Part 3: How to Set Up Supabase SSR</h2>
<h3 id="heading-create-the-supabase-client">Create the Supabase Client</h3>
<p>Create <code>src/lib/supabase.ts</code>:</p>
<pre><code class="lang-typescript">
<span class="hljs-keyword">import</span> { createServerClient, parseCookieHeader } <span class="hljs-keyword">from</span> <span class="hljs-string">"@supabase/ssr"</span>;
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { AstroCookies } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">createClient</span>(<span class="hljs-params">{
    request,
    cookies,
}: {
    request: Request;
    cookies: AstroCookies;
}</span>) </span>{
    <span class="hljs-keyword">const</span> cookieHeader = request.headers.get(<span class="hljs-string">"Cookie"</span>) || <span class="hljs-string">""</span>;

    <span class="hljs-keyword">return</span> createServerClient(
        <span class="hljs-keyword">import</span>.meta.env.SUPABASE_URL,
        <span class="hljs-keyword">import</span>.meta.env.SUPABASE_ANON_KEY,
        {
            cookies: {
                getAll() {
                    <span class="hljs-keyword">const</span> cookies = parseCookieHeader(cookieHeader);
                    <span class="hljs-keyword">return</span> cookies.map(<span class="hljs-function">(<span class="hljs-params">{ name, value }</span>) =&gt;</span> ({
                        name,
                        value: value ?? <span class="hljs-string">""</span>,
                    }));
                },
                setAll(cookiesToSet) {
                    cookiesToSet.forEach(<span class="hljs-function">(<span class="hljs-params">{ name, value, options }</span>) =&gt;</span>
                        cookies.set(name, value, options)
                    );
                },
            },
        }
    );
}
</code></pre>
<p>This sets up Supabase to handle <a target="_blank" href="https://supabase.com/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&amp;framework=astro&amp;queryGroups=environment&amp;environment=astro-browser">cookies in a server-rendered application</a> and exports a function that takes the request and cookies object as input. The function is set up like this because Astro has three ways to access request and cookie information:</p>
<ul>
<li><p>Through Astro’s global object, which is only available on Astro pages.</p>
</li>
<li><p>Through <code>AstroAPIContext</code> object, which is only available in Astro actions.</p>
</li>
<li><p>Through <code>APIContext</code> which is a subset of the global object and is available through API routes and middleware.</p>
</li>
</ul>
<p>So the <code>createClient</code> function accepts the <code>request</code> and <code>cookies</code> objects separately to make it flexible and applicable in the various contexts in which it may be used.</p>
<h3 id="heading-create-middleware-for-route-protection">Create Middleware for Route Protection</h3>
<p>Next, create a <code>middleware.ts</code> file in the <code>src</code> folder and paste this into it:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { defineMiddleware } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro:middleware"</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"./lib/supabase"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> onRequest = defineMiddleware(<span class="hljs-keyword">async</span> (context, next) =&gt; {
    <span class="hljs-keyword">const</span> { pathname } = context.url;

    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Middleware executing for path:"</span>, pathname);

    <span class="hljs-keyword">const</span> supabase = createClient({
        request: context.request,
        cookies: context.cookies,
    });

    <span class="hljs-keyword">if</span> (pathname === <span class="hljs-string">"/protected"</span>) {
        <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Checking auth for protected route"</span>);

        <span class="hljs-keyword">const</span> { data } = <span class="hljs-keyword">await</span> supabase.auth.getUser();

        <span class="hljs-comment">// If no user, redirect to index</span>
        <span class="hljs-keyword">if</span> (!data.user) {
            <span class="hljs-keyword">return</span> context.redirect(<span class="hljs-string">"/"</span>);
        }
    }

    <span class="hljs-keyword">return</span> next();
});
</code></pre>
<p>This middleware checks for an active user when accessing the protected route and redirects unauthenticated users to the index page.</p>
<h2 id="heading-part-4-how-to-build-the-user-interface">Part 4: How to Build the User Interface</h2>
<h3 id="heading-update-the-layout">Update the Layout</h3>
<p>First, update <code>src/layouts/Layout.astro</code> to include the Turnstile script. Add this just above the closing <code>&lt;/head&gt;</code> tag:</p>
<pre><code class="lang-typescript">&lt;script
    src=<span class="hljs-string">"https://challenges.cloudflare.com/turnstile/v0/api.js"</span>
    <span class="hljs-keyword">async</span>
    defer&gt;
&lt;/script&gt;
</code></pre>
<h3 id="heading-create-the-sign-in-page">Create the Sign-In Page</h3>
<p>Replace the contents of <code>src/pages/index.astro</code>:</p>
<pre><code class="lang-typescript">---
<span class="hljs-keyword">import</span> Layout <span class="hljs-keyword">from</span> <span class="hljs-string">"../layouts/Layout.astro"</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"../lib/supabase"</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">"../styles/global.css"</span>;

<span class="hljs-keyword">const</span> supabase = createClient({
    request: Astro.request,
    cookies: Astro.cookies,
});

<span class="hljs-keyword">const</span> { data } = <span class="hljs-keyword">await</span> supabase.auth.getUser();

<span class="hljs-keyword">if</span> (data.user) {
    <span class="hljs-keyword">return</span> Astro.redirect(<span class="hljs-string">"/protected"</span>);
}

<span class="hljs-keyword">const</span> apiKey = <span class="hljs-keyword">import</span>.meta.env.TURNSTILE_SITE_KEY;
---

&lt;Layout&gt;
    &lt;section <span class="hljs-keyword">class</span>=<span class="hljs-string">"flex flex-col items-center justify-center m-30"</span>&gt;
        &lt;h1 <span class="hljs-keyword">class</span>=<span class="hljs-string">"text-4xl text-left font-bold mb-12"</span>&gt;Sign In to Your Account&lt;/h1&gt;
        &lt;form id=<span class="hljs-string">"signin-form"</span> <span class="hljs-keyword">class</span>=<span class="hljs-string">"flex flex-col gap-2 w-1/2"</span>&gt;
            &lt;label <span class="hljs-keyword">for</span>=<span class="hljs-string">"email"</span> <span class="hljs-keyword">class</span>=<span class="hljs-string">""</span>&gt;Enter your email&lt;/label&gt;
            &lt;input
                <span class="hljs-keyword">type</span>=<span class="hljs-string">"email"</span>
                name=<span class="hljs-string">"email"</span>
                id=<span class="hljs-string">"email"</span>
                placeholder=<span class="hljs-string">"youremail@example.com"</span>
                <span class="hljs-keyword">class</span>=<span class="hljs-string">"border border-gray-500 rounded-md p-2"</span>
                required
            /&gt;
            &lt;div <span class="hljs-keyword">class</span>=<span class="hljs-string">"cf-turnstile"</span> data-sitekey={apiKey}&gt;&lt;/div&gt;
            &lt;button
                <span class="hljs-keyword">type</span>=<span class="hljs-string">"submit"</span>
                id=<span class="hljs-string">"sign-in"</span>
                <span class="hljs-keyword">class</span>=<span class="hljs-string">"bg-gray-600 hover:bg-gray-700 p-2 rounded-md text-white font-bold w-full cursor-pointer disabled:bg-gray-500 disabled:hover:bg-gray-500 disabled:cursor-not-allowed"</span>
                &gt;Sign In&lt;/button
            &gt;
        &lt;/form&gt;
    &lt;/section&gt;
&lt;/Layout&gt;
</code></pre>
<p>Here, the frontmatter creates a Supabase server client and then uses it to check if we have an active user. It redirects based on this information. This works because the front matter runs on the server side, and the project is set to server output.</p>
<p>The template displays a simple form with an email input. To complete it, add this below the closing <code>&lt;/Layout&gt;</code> tag:</p>
<pre><code class="lang-typescript">
&lt;script&gt;
    <span class="hljs-keyword">import</span> { actions } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro:actions"</span>;

    <span class="hljs-keyword">declare</span> <span class="hljs-built_in">global</span> {
        <span class="hljs-keyword">interface</span> Window {
            turnstile?: {
                reset: <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">void</span>;
            };
        }
    }

    <span class="hljs-keyword">const</span> signInForm = <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">"#signin-form"</span>) <span class="hljs-keyword">as</span> HTMLFormElement;
    <span class="hljs-keyword">const</span> formSubmitBtn = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"sign-in"</span>) <span class="hljs-keyword">as</span> HTMLButtonElement;

    signInForm?.addEventListener(<span class="hljs-string">"submit"</span>, <span class="hljs-keyword">async</span> (e) =&gt; {
        e.preventDefault();
        formSubmitBtn.disabled = <span class="hljs-literal">true</span>;
        formSubmitBtn.textContent = <span class="hljs-string">"Signing in..."</span>;

        <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">const</span> turnstileToken = (
                <span class="hljs-built_in">document</span>.querySelector(
                    <span class="hljs-string">"[name='cf-turnstile-response']"</span>
                ) <span class="hljs-keyword">as</span> HTMLInputElement
            )?.value;

            <span class="hljs-keyword">if</span> (!turnstileToken) {
                <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">"verification_missing"</span>);
            }

            <span class="hljs-keyword">const</span> formData = <span class="hljs-keyword">new</span> FormData(signInForm);
            formData.append(<span class="hljs-string">"captchaToken"</span>, turnstileToken);

            <span class="hljs-keyword">const</span> results = <span class="hljs-keyword">await</span> actions.signIn(formData);

            <span class="hljs-keyword">if</span> (!results.data?.success) {
                <span class="hljs-keyword">if</span> (results.data?.message?.includes(<span class="hljs-string">"captcha protection"</span>)) {
                    alert(<span class="hljs-string">"Verification failed. Please try again."</span>);
                    <span class="hljs-keyword">if</span> (<span class="hljs-built_in">window</span>.turnstile) {
                        <span class="hljs-built_in">window</span>.turnstile.reset();
                    }
                    formSubmitBtn.disabled = <span class="hljs-literal">false</span>;
                    formSubmitBtn.textContent = <span class="hljs-string">"Sign In"</span>;
                    <span class="hljs-keyword">return</span>;
                } <span class="hljs-keyword">else</span> {
                    alert(<span class="hljs-string">"Oops! Could not sign in. Please try again"</span>);
                    formSubmitBtn.disabled = <span class="hljs-literal">false</span>;
                    formSubmitBtn.textContent = <span class="hljs-string">"Sign In"</span>;
                    <span class="hljs-keyword">return</span>;
                }
            }

            formSubmitBtn.textContent = <span class="hljs-string">"Sign In"</span>;
            alert(<span class="hljs-string">"Please check your email to sign in"</span>);
        } <span class="hljs-keyword">catch</span> (error) {
            <span class="hljs-keyword">if</span> (<span class="hljs-built_in">window</span>.turnstile) {
                <span class="hljs-built_in">window</span>.turnstile.reset();
            }
            formSubmitBtn.disabled = <span class="hljs-literal">false</span>;
            formSubmitBtn.textContent = <span class="hljs-string">"Sign In"</span>;
            <span class="hljs-built_in">console</span>.log(error);
            alert(<span class="hljs-string">"Something went wrong. Please try again"</span>);
        }
    });
&lt;/script&gt;
</code></pre>
<p>This adds some vanilla JavaScript that calls the <code>SignIn</code> Upon form submission. This action provides user feedback through alerts and manages the button’s text and disabled state. This effectively adds client-side interactivity to the page.</p>
<h3 id="heading-create-the-protected-page">Create the Protected Page</h3>
<p>Create <code>src/pages/protected.astro</code>:</p>
<pre><code class="lang-typescript">---
<span class="hljs-keyword">import</span> Layout <span class="hljs-keyword">from</span> <span class="hljs-string">"../layouts/Layout.astro"</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"../lib/supabase"</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">"../styles/global.css"</span>;

<span class="hljs-keyword">const</span> supabase = createClient({
    request: Astro.request,
    cookies: Astro.cookies,
});

<span class="hljs-keyword">const</span> { data } = <span class="hljs-keyword">await</span> supabase.auth.getUser();
---

&lt;Layout&gt;
    &lt;section <span class="hljs-keyword">class</span>=<span class="hljs-string">"flex flex-col items-center justify-center m-30"</span>&gt;
        &lt;h1 <span class="hljs-keyword">class</span>=<span class="hljs-string">"text-4xl text-left font-bold mb-12"</span>&gt;You are logged <span class="hljs-keyword">in</span>!&lt;/h1&gt;
        &lt;p <span class="hljs-keyword">class</span>=<span class="hljs-string">"mb-6"</span>&gt;Your user Id: {data.user?.id}&lt;/p&gt;
        &lt;button
            id=<span class="hljs-string">"sign-out"</span>
            <span class="hljs-keyword">class</span>=<span class="hljs-string">"bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-md text-white font-bold cursor-pointer disabled:bg-gray-500 disabled:hover:bg-gray-500 disabled:cursor-not-allowed"</span>
            &gt;Sign Out&lt;/button
        &gt;
    &lt;/section&gt;
&lt;/Layout&gt;

&lt;script&gt;
    <span class="hljs-keyword">import</span> { actions } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro:actions"</span>;
    <span class="hljs-keyword">const</span> signOutBtn = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"sign-out"</span>) <span class="hljs-keyword">as</span> HTMLButtonElement;

    signOutBtn?.addEventListener(<span class="hljs-string">"click"</span>, <span class="hljs-keyword">async</span> (e) =&gt; {
        e.preventDefault();
        signOutBtn!.disabled = <span class="hljs-literal">true</span>;
        signOutBtn!.textContent = <span class="hljs-string">"Signing out..."</span>;

        <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">const</span> results = <span class="hljs-keyword">await</span> actions.signOut();

            <span class="hljs-keyword">if</span> (!results.data?.success) {
                signOutBtn!.disabled = <span class="hljs-literal">false</span>;
                signOutBtn!.textContent = <span class="hljs-string">"Sign Out"</span>;
                <span class="hljs-keyword">return</span> alert(<span class="hljs-string">"Oops! Could not sign Out. Please try again"</span>);
            }
            <span class="hljs-keyword">return</span> <span class="hljs-built_in">window</span>.location.reload();
        } <span class="hljs-keyword">catch</span> (error) {
            signOutBtn.disabled = <span class="hljs-literal">false</span>;
            signOutBtn.textContent = <span class="hljs-string">"Sign Out"</span>;
            <span class="hljs-built_in">console</span>.log(error);
            <span class="hljs-keyword">return</span> alert(<span class="hljs-string">"Something went wrong. Please try again"</span>);
        }
    });
&lt;/script&gt;
</code></pre>
<p>This page retrieves the user data server-side in the front matter and displays it in the template, along with a sign-out button.</p>
<p>The JavaScript in the <code>script</code> tags handle calling the sign-out action, user feedback, and button state, as in the <code>index.astro</code> page.</p>
<h2 id="heading-part-5-how-to-set-up-astro-actions">Part 5: How to Set Up Astro Actions</h2>
<h3 id="heading-create-the-authentication-actions">Create the Authentication Actions</h3>
<p>Finally, add an <code>actions</code> folder in the <code>src</code> folder and create an <code>index.ts</code> file to hold our logic. Paste the following into it:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { defineAction, <span class="hljs-keyword">type</span> ActionAPIContext } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro:actions"</span>;
<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro:schema"</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"../lib/supabase"</span>;

<span class="hljs-keyword">const</span> emailSignUp = <span class="hljs-keyword">async</span> (
    {
        email,
        captchaToken,
    }: {
        email: <span class="hljs-built_in">string</span>;
        captchaToken: <span class="hljs-built_in">string</span>;
    },
    context: ActionAPIContext
) =&gt; {
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Sign up action"</span>);
    <span class="hljs-keyword">try</span> {
        <span class="hljs-keyword">const</span> supabase = createClient({
            request: context.request,
            cookies: context.cookies,
        });

        <span class="hljs-keyword">const</span> { data, error } = <span class="hljs-keyword">await</span> supabase.auth.signInWithOtp({
            email,
            options: {
                captchaToken,
                emailRedirectTo: <span class="hljs-string">"http://localhost:4321/api/exchange"</span>,
            },
        });

        <span class="hljs-keyword">if</span> (error) {
            <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Sign up error"</span>, error);
            <span class="hljs-keyword">return</span> {
                success: <span class="hljs-literal">false</span>,
                message: error.message,
            };
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Sign up success"</span>, data);
            <span class="hljs-keyword">return</span> {
                success: <span class="hljs-literal">true</span>,
                message: <span class="hljs-string">"Successfully logged in"</span>,
            };
        }
    } <span class="hljs-keyword">catch</span> (err) {
        <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"SignUp action other error"</span>, err);
        <span class="hljs-keyword">return</span> {
            success: <span class="hljs-literal">false</span>,
            message: <span class="hljs-string">"Unexpected error"</span>,
        };
    }
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> server = {
    signIn: defineAction({
        accept: <span class="hljs-string">"form"</span>,
        input: z.object({
            email: z.string().email(),
            captchaToken: z.string(),
        }),
        handler: <span class="hljs-keyword">async</span> (input, context) =&gt; {
            <span class="hljs-keyword">return</span> emailSignUp(input, context);
        },
    }),
    signOut: defineAction({
        handler: <span class="hljs-keyword">async</span> (_, context) =&gt; {
            <span class="hljs-keyword">const</span> supabase = createClient({
                request: context.request,
                cookies: context.cookies,
            });
            <span class="hljs-keyword">const</span> { error } = <span class="hljs-keyword">await</span> supabase.auth.signOut();
            <span class="hljs-keyword">if</span> (error) {
                <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Sign out error"</span>, error);
                <span class="hljs-keyword">return</span> {
                    success: <span class="hljs-literal">false</span>,
                    message: error.message,
                };
            }
            <span class="hljs-keyword">return</span> {
                success: <span class="hljs-literal">true</span>,
                message: <span class="hljs-string">"Successfully signed out"</span>,
            };
        },
    }),
};
</code></pre>
<p>This action handles both sign-in and sign-out methods. A Supabase server instance is created during the sign-in method, and the magic link method is used for sign-in. It passes a redirect URL, which we have yet to create, and handles any errors that may occur.</p>
<p>It also passes the token verification, allowing Supabase to perform verification on our behalf, eliminating the need to call <a target="_blank" href="https://developers.cloudflare.com/turnstile/get-started/server-side-validation/">Cloudflare’s verify APIs</a> directly.</p>
<p>The sign-out method calls Supabase’s sign-out method and handles any potential errors.</p>
<p>The redirect URL refers to an API route that exchanges the code from the email Supabase sends for a session that Supabase handles.</p>
<h3 id="heading-create-the-code-exchange-api-route">Create the Code Exchange API Route</h3>
<p>Create <code>src/pages/api/exchange.ts</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { APIRoute } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro"</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"../../lib/supabase"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> GET: APIRoute = <span class="hljs-keyword">async</span> ({ request, cookies, redirect }) =&gt; {
    <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> URL(request.url);
    <span class="hljs-keyword">const</span> code = url.searchParams.get(<span class="hljs-string">"code"</span>);

    <span class="hljs-keyword">if</span> (!code) {
        <span class="hljs-keyword">return</span> redirect(<span class="hljs-string">"/"</span>);
    }

    <span class="hljs-keyword">const</span> supabase = createClient({ request, cookies });
    <span class="hljs-keyword">const</span> { error } = <span class="hljs-keyword">await</span> supabase.auth.exchangeCodeForSession(code);

    <span class="hljs-keyword">if</span> (error) {
        <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Error exchanging code for session:"</span>, error);
        <span class="hljs-keyword">return</span> redirect(<span class="hljs-string">"/404"</span>);
    }

    <span class="hljs-keyword">return</span> redirect(<span class="hljs-string">"/protected"</span>);
};
</code></pre>
<p>This grabs the code from the URL in the magic link sent, creates a server client, and calls the <code>exchangeCodeForSession</code> method with the code. It handles any error by redirecting to Astro’s built-in not-found page.</p>
<p>Otherwise, it will redirect to the protected page as Supabase handles the session implementation details.</p>
<h2 id="heading-part-6-how-to-test-your-application">Part 6: How to Test Your Application</h2>
<p>Start your development server: <code>npm run dev</code></p>
<p>Visit the provided localhost URL. You should see the sign-in page with the Turnstile widget:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750267075336/66ad5f39-67c6-458a-96ea-4dfe1123b015.png" alt="Sign-in page with Turnstile verification and email input field" width="2356" height="956" loading="lazy"></p>
<p>If you try to access the <code>/protected</code> page, it will redirect you back to this view until you sign in. Now, sign in, and you should get an email with a link that will redirect you to the <code>/protected</code> page. This is what you should see:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750335131827/f85cde2f-f9bb-46b0-a09e-6ae6456cd49f.png" alt="Text reads: &quot;You are logged in!&quot; with a field labeled &quot;Your user Id&quot; and a &quot;Sign Out&quot; button below." width="1200" height="502" loading="lazy"></p>
<p>And with that, you've successfully built a comprehensive auth system that leverages Astro actions, Supabase auth, and Cloudflare Turnstile's bot protection. This setup provides a secure, user-friendly authentication experience while protecting your application from malicious actors.</p>
<h2 id="heading-notes-and-additional-resources">Notes and Additional Resources</h2>
<h3 id="heading-useful-documentation">Useful Documentation</h3>
<ul>
<li><p><a target="_blank" href="https://supabase.com/docs/guides/auth/server-side/advanced-guide">Supabase's advanced guide to SSR</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/supabase/ssr">Supabase SSR package</a></p>
</li>
<li><p><a target="_blank" href="https://docs.astro.build/en/reference/api-reference/#cookies">Astro Cookies documentation</a></p>
</li>
<li><p><a target="_blank" href="https://supabase.com/docs/guides/auth/sessions/pkce-flow">Supabase PKCE flow documentation</a></p>
</li>
<li><p><a target="_blank" href="https://docs.astro.build/en/guides/actions/">Astro Actions documentation</a></p>
</li>
<li><p><a target="_blank" href="https://developers.cloudflare.com/turnstile/get-started/">Get started with Turnstile</a></p>
</li>
</ul>
<h3 id="heading-complete-code-repository">Complete Code Repository</h3>
<p>The complete code for this project is available on GitHub:</p>
<ul>
<li><p><a target="_blank" href="https://github.com/FatumaA/supa-ssr">Base authentication setup</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/FatumaA/supa-ssr/tree/add-cloudflare">With Cloudflare Turnstile</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Callout Component for your Astro Blog ]]>
                </title>
                <description>
                    <![CDATA[ Earlier this year, I became really interested in learning about Astro, so I completely rebuilt my blog using it. The results have been amazing since then: I can easily handle automatic sitemap generation and SEO, and integrating other tools became a ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-callout-component-for-your-astro-blog/</link>
                <guid isPermaLink="false">6717a6755af2bda0ccbde415</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Astro ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Kolade Chris ]]>
                </dc:creator>
                <pubDate>Tue, 22 Oct 2024 13:19:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1728900397885/4c9f906f-e531-4588-9e45-7b56920d676e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Earlier this year, I became really interested in learning about Astro, so I completely rebuilt my blog using it. The results have been amazing since then: I can easily handle automatic sitemap generation and SEO, and integrating other tools became a breeze. Because of these benefits, I was inspired to publish an article every single week.</p>
<p>Astro is incredible for content-driven websites – so much so that since then, I've been cracking a joke: it should be a crime to build a blog and documentation site with any frontend framework other than Astro.</p>
<p>Another notable thing I was able to do on my blog was build a callout component. This is the little tangential boxes you see on documentation sites and some advanced developer blogs.</p>
<p>This is an example of one in the freeCodeCamp docs:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728898968834/da8175de-6380-4094-bf96-1c0d14f51625.png" alt="freeCodeCamp docs callout" class="image--center mx-auto" width="762" height="170" loading="lazy"></p>
<p>And here’s one in the Astro docs:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728898998665/981f943b-d138-4f52-9e3f-627ab83526d8.png" alt="Astro documentation callout box" class="image--center mx-auto" width="760" height="151" loading="lazy"></p>
<p>A bit unique, this is one on Josh Comeau's blog:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728899020675/a1199846-e851-4438-81dd-17a3da759d97.png" alt="Josh Comeau blog callout box" class="image--center mx-auto" width="841" height="342" loading="lazy"></p>
<p>Keep reading, so I can show you how to build a callout component for your Astro blog.</p>
<p>You can also use the same technique to create one for a blog built with any other frontend framework.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this article, you need the following:</p>
<ul>
<li><p>basic knowledge of Astro</p>
</li>
<li><p>a knowledge of Astro slots and fragments</p>
</li>
<li><p>familiarity with JSX and prop-drilling</p>
</li>
<li><p>understanding of component-based architecture</p>
</li>
</ul>
<p>Astro slots and fragments might sound a bit strange. If that's the case, don't worry, I’l do my best to make things as clear as possible for you.</p>
<h2 id="heading-how-to-build-a-callout-component-for-your-blog">How to Build a Callout Component for your Blog</h2>
<p>The approach we are going to use is to have a <strong>parent component</strong> that will handle all the rendering for the callout box: the type, icon, text, and the styling.</p>
<p>What will follow are respective components that indicate a note, tip, caution, error callout boxes, and more.</p>
<p>Here's a diagram that simplifies the approach:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728934365025/93fe73ce-5781-47bd-a9f1-02519c22776a.png" alt="Callout parent and children components diagram" class="image--center mx-auto" width="988" height="606" loading="lazy"></p>
<p>Each of the child components is what you'll bring in any time you want to display a callout related to them.</p>
<p>To get started, create a folder, name it what you want (I call it astro-callout on my end), and install Astro.</p>
<p>What follows is a series of prompts. Here are the choices I made during the prompts:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728899147064/9d3f33ba-5b56-44de-a700-b3652fbefaec.png" alt="Astro installation prompt choices" class="image--center mx-auto" width="1206" height="712" loading="lazy"></p>
<p>Here's what the project folder structure looks like after installing Astro and choosing the blog template:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728899305440/93267555-d761-4759-b666-037c9ff13738.png" alt="Astro blog template folder structure" class="image--center mx-auto" width="292" height="357" loading="lazy"></p>
<h3 id="heading-how-to-build-the-parent-callout-component">How to Build the Parent <code>Callout</code> Component.</h3>
<p>Head over to the <code>src/component</code> directory and create a folder named <code>callout</code>. Then inside the folder, create a <code>Callout.astro</code> file.</p>
<p>The callout component is an <code>aside</code> element that will take in the following props:</p>
<ul>
<li><p><code>type</code>: the callout type (note, caution, error, tip, and others)</p>
</li>
<li><p><code>icon</code>: the icon that will signify the callout type. This could be an emoji or an icon from an icon library.</p>
</li>
<li><p><code>backgroundColor</code>: the different background color of each callout type</p>
</li>
<li><p><code>borderLeftColor</code>: the border-left color of each callout type</p>
</li>
</ul>
<p>Destructure those props from <code>Astro.props</code> in the code fence of the <code>Callout.astro</code> component:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> { icon, type, backgroundColor, borderLeftColor } = Astro.props
</code></pre>
<p>Don't forget the <code>aside</code> element has to have some text content, too. Astro has a way to handle this using the <code>&lt;slot /&gt;</code> element. </p>
<p><code>&lt;slot /&gt;</code> lets you insert child content written between opening and closing tags into any component.</p>
<p>Here's how I filled everything into the <code>aside</code> element:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">aside</span>
 <span class="hljs-attr">class</span>=<span class="hljs-string">"callout-box"</span>
 <span class="hljs-attr">style</span>=<span class="hljs-string">{{backgroundColor,</span> <span class="hljs-attr">borderLeftColor</span>}}
 &gt;</span>
 <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"callout-icon-and-type"</span>&gt;</span>
   {icon}
   {type}
 <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
 <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"callout-content"</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">slot</span> /&gt;</span>
 <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">aside</span>&gt;</span>
</code></pre>
<p>The <code>icon</code> prop fills in for any icon you would like to use, be it an emoji or an icon library like Ionicons or Font Awesome.</p>
<p>The problem is that not all emojis will be rendered in a presentable way, as different operating systems render emojis their own ways. So, you must find a way to accept both emojis and an icon library. </p>
<p>The icon library I chose for this project is ionicons. Go into the layout file (in this case <code>layouts/BlogPost.astro</code>) and insert the following scripts just before the closing body tag:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"module"</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">nomodule</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
</code></pre>
<p>Here's the function that will let you render an icon, whether it’s an emoji or an ionicons icon:</p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">renderIcon</span>(<span class="hljs-params">icon:any</span>) </span>{
 <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> icon === <span class="hljs-string">'string'</span>) {
   <span class="hljs-keyword">return</span> icon;
 } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> icon === <span class="hljs-string">'object'</span> &amp;&amp; icon.name) {
   <span class="hljs-keyword">return</span> <span class="hljs-string">`&lt;ion-icon name="<span class="hljs-subst">${icon.name}</span>"&gt;&lt;/ion-icon&gt;`</span>;
 }
 <span class="hljs-keyword">return</span> <span class="hljs-string">''</span>;
}
</code></pre>
<p>This function checks if the <code>icon</code> you pass in is an emoji or an icon from the Ionicons library.</p>
<p>An emoji corresponds to a string. So if you pass in an emoji, the <code>icon</code> is returned. If you pass in an icon from the Ionicons library, the function will look out for the icon name and display that icon.</p>
<p>You need to modify the content of the <code>aside</code> element a bit so the icon will be rendered the correct way:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">aside</span>
 <span class="hljs-attr">class</span>=<span class="hljs-string">"callout-box"</span>
 <span class="hljs-attr">style</span>=<span class="hljs-string">{{backgroundColor,</span> <span class="hljs-attr">borderLeftColor</span>}}
 &gt;</span>
 <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"callout-icon-and-type"</span>&gt;</span>
   <span class="hljs-comment">&lt;!-- New icon renderer --&gt;</span>
   <span class="hljs-tag">&lt;<span class="hljs-name">Fragment</span> <span class="hljs-attr">set:html</span>=<span class="hljs-string">{renderIcon(icon)}</span> /&gt;</span>
   {type}
 <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
 <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"callout-content"</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">slot</span> /&gt;</span>
 <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">aside</span>&gt;</span>
</code></pre>
<p>If you're wondering what <code>set:html</code> is, it's an Astro template directory similar to the JavaScript <code>element.innerHTML</code>.</p>
<p>The <code>Fragment</code> component itself is a component you can use with the <code>set:*</code> directives to render HTML content.</p>
<p>Here's the CSS for the <code>Callout</code> component:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">style</span>&gt;</span><span class="css">
 <span class="hljs-selector-class">.callout-box</span> {
   <span class="hljs-attribute">color</span>: <span class="hljs-number">#fff</span>;
   <span class="hljs-attribute">flex-direction</span>: column;
   <span class="hljs-attribute">padding</span>: <span class="hljs-number">1rem</span>;
   <span class="hljs-attribute">border-left-width</span>: <span class="hljs-number">4px</span>;
   <span class="hljs-attribute">border-left-style</span>: solid;
   <span class="hljs-attribute">margin</span>: <span class="hljs-number">1.5rem</span> <span class="hljs-number">0</span>;
   <span class="hljs-attribute">border-top-right-radius</span>: <span class="hljs-number">4px</span>;
   <span class="hljs-attribute">border-bottom-right-radius</span>: <span class="hljs-number">4px</span>;
 }

 <span class="hljs-selector-class">.callout-icon-and-type</span> {
   <span class="hljs-attribute">font-size</span>: <span class="hljs-number">1.5rem</span>;
   <span class="hljs-attribute">display</span>: flex;
   <span class="hljs-attribute">gap</span>: <span class="hljs-number">0.4rem</span>;
 }

 <span class="hljs-selector-class">.icon</span> {
   <span class="hljs-attribute">margin-right</span>: <span class="hljs-number">4rem</span>;
 }

 <span class="hljs-selector-class">.callout-content</span> {
   <span class="hljs-attribute">font-size</span>: <span class="hljs-number">1.2rem</span>;
 }
</span><span class="hljs-tag">&lt;/<span class="hljs-name">style</span>&gt;</span>
</code></pre>
<p>This will make more sense when we start using the parent <code>Callout</code> component in the child callout components.</p>
<h3 id="heading-how-to-build-the-child-callout-components">How to Build the Child Callout Components</h3>
<p>Let's demonstrate how to do this with a sample note callout box.</p>
<p>Create a <code>NoteCallout.astro</code> file inside the <code>components/callout</code> directory.</p>
<p>Import the parent <code>Callout</code> component inside the code fence of the <code>NoteCallout.astro</code> file:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> CallOutBox <span class="hljs-keyword">from</span> <span class="hljs-string">"./Callout.astro"</span>;
</code></pre>
<p>To make things less confusing, you can rename CalloutBox to <code>NoteCalloutBox</code>:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> NoteCallOutBox <span class="hljs-keyword">from</span> <span class="hljs-string">"./Callout.astro"</span>;
</code></pre>
<p>After that, use the <code>NoteCalloutBox</code> as a tag and pass ion all the props the parent <code>Callout</code> component accepts:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">NoteCallOutBox</span>
 <span class="hljs-attr">icon</span>=<span class="hljs-string">'&lt;ion-icon style="color: #3347ff;" size="large" name="information-circle"&gt;&lt;/ion-icon&gt;'</span>
 <span class="hljs-attr">type</span>=<span class="hljs-string">"Note"</span>
 <span class="hljs-attr">backgroundColor</span>=<span class="hljs-string">"#171d4f"</span>
 <span class="hljs-attr">borderLeftColor</span>=<span class="hljs-string">"#3347ff"</span>
&gt;</span>

<span class="hljs-tag">&lt;/<span class="hljs-name">NoteCallOutBox</span>&gt;</span>
</code></pre>
<p>To account for the content of the NoteCalloutBox to be passed in when it's finally used, pass in <code>&lt;slot /&gt;</code> as the content of the <code>NoteCallOutBox</code>:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">NoteCallOutBox</span>
 <span class="hljs-attr">icon</span>=<span class="hljs-string">'&lt;ion-icon style="color: #3347ff;" size="large" name="information-circle"&gt;&lt;/ion-icon&gt;'</span>
 <span class="hljs-attr">type</span>=<span class="hljs-string">"Note"</span>
 <span class="hljs-attr">backgroundColor</span>=<span class="hljs-string">"#171d4f"</span>
 <span class="hljs-attr">borderLeftColor</span>=<span class="hljs-string">"#3347ff"</span>
&gt;</span>
 <span class="hljs-tag">&lt;<span class="hljs-name">slot</span> /&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">NoteCallOutBox</span>&gt;</span>
</code></pre>
<p>You can follow this process to create an error, caution, warning, and tip callout child components. All you need to do is to fill in the appropriate prop values.</p>
<h2 id="heading-how-to-use-the-callout-child-components">How to Use the Callout Child Components</h2>
<p>To use the child callout components in an MDX file for example, import the <code>NoteCallout</code>:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> NoteCallout <span class="hljs-keyword">from</span> <span class="hljs-string">'../../components/callout/NoteCallout.astro'</span>;
</code></pre>
<p>Then use it as a tag and pass in the note you want to communicate to the user:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">NoteCallout</span>&gt;</span>
 You can only use this component in an MDX file, not a regular Markddown file
 with a .md extension.
<span class="hljs-tag">&lt;/<span class="hljs-name">NoteCallout</span>&gt;</span>
</code></pre>
<p>Here's the result in the browser:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728899862999/7c6624f9-5d29-44eb-b283-f57e31663ee8.png" alt="NoteCallout component" class="image--center mx-auto" width="1594" height="482" loading="lazy"></p>
<p>Here are other child components I created using the same approach:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1728899912952/5c19e264-fed6-483f-94b9-afd2b761424a.png" alt="Caution, Tip, Error, and Success Callout components" class="image--center mx-auto" width="1682" height="1046" loading="lazy"></p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>I hope this article showed you everything you need to build a callout component for your blog.</p>
<p>Don’t forget that you can use the same technique to build a callout for a blog built with any other frontend framework.</p>
<p>If you find the article helpful, you can read more articles like it on <a target="_blank" href="https://www.koladechris.com/blog">my blog</a>. I also have articles on PHP, JavaScript, React, Python, and more.</p>
<p>Keep coding…</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Add Google Analytics to your Astro Website ]]>
                </title>
                <description>
                    <![CDATA[ You can use data insights derived from your website or app to make important decisions that'll help grow your business. This information can help you improve user experience, create effective content strategies, and so on. Google Analytics is an effe... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-add-google-analytics-to-your-astro-website/</link>
                <guid isPermaLink="false">66b0a2bfb30dd4d00547bb92</guid>
                
                    <category>
                        <![CDATA[ analytics ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Astro ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google Analytics ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ihechikara Abba ]]>
                </dc:creator>
                <pubDate>Wed, 18 Oct 2023 18:07:14 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2023/10/andy-hermawan-bVBvv5xlX3g-unsplash.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You can use data insights derived from your website or app to make important decisions that'll help grow your business. This information can help you improve user experience, create effective content strategies, and so on.</p>
<p>Google Analytics is an effective tool for tracking and analyzing traffic and events on your websites and mobile apps. In this article, you'll learn how to add Google Analytics to your Astro website.</p>
<p>To follow along, you'll need the following:</p>
<ul>
<li>A Google account.</li>
<li>A deployed Astro project.</li>
</ul>
<p>Let's get started!</p>
<h2 id="heading-how-to-add-google-analytics-to-an-astro-website">How to Add Google Analytics to an Astro Website</h2>
<p>Before adding Google analytics, make sure you've already deployed your project. Here's one that I've deployed using Netlify: <a target="_blank" href="https://astro-article.netlify.app/">https://astro-article.netlify.app/</a>. This is an Astro blog template created with the <code>npm create astro@latest -- --template blog</code> command.</p>
<p>This section will be divided into two sub-sections. In the first sub-section, you'll learn how to create an Analytics account and how to set up your account for tracking and monitoring your website.</p>
<p>In the second sub-section, you'll learn how to configure your code to sync with Google Analytics.</p>
<h3 id="heading-how-to-set-up-google-analytics">How to Set Up Google Analytics</h3>
<p>You can follow these steps to set up Google Analytics:</p>
<h4 id="heading-step-1-account-creation-page">Step #1 – Account Creation Page</h4>
<p>The first step is to create a Google Analytics account. You can do that by visiting <a target="_blank" href="https://analytics.google.com/">the Google Analytics website</a>.</p>
<p>When the page loads, you should see something similar to this:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/google-analytics-2.png" alt="Image" width="600" height="400" loading="lazy">
<em>Google Analytics home page</em></p>
<p>Click on the "Start measuring" button. This will take you to the account creation page where you'll fill in some info about your website/app. That is:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/account-creation.png" alt="Image" width="600" height="400" loading="lazy">
<em>"Create an Account" page on Google Analytics</em></p>
<p>After inputting an account name, scroll down the page and click on the "Next" button.</p>
<h4 id="heading-step-2-property-creation-page">Step #2 – Property Creation Page</h4>
<p>On the property creation page, you can create a property name, time zone, and currency.</p>
<p>A property acts as a unique identifier for your websites and apps. So it is like a container for all the data related to a specified website or app.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/property-creation.png" alt="Image" width="600" height="400" loading="lazy">
<em>"Create a Property" page on Google Analytics</em></p>
<h4 id="heading-step-3-business-details-page">Step #3 – Business Details Page</h4>
<p>On the business details page, you can specify your industry category and the business size.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/business-details.png" alt="Image" width="600" height="400" loading="lazy">
<em>"Describe your business" page on Google Analytics</em></p>
<h4 id="heading-step-4-business-objectives-page">Step #4 – Business Objectives Page</h4>
<p>The business objectives helps Analytics generate personalized reports for your business. You can select the options that are important to your website, app, or product category.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/business-objectives.png" alt="Image" width="600" height="400" loading="lazy">
<em>"Business objectives" page on Google Analytics</em></p>
<p>After selecting the options, click on the "Create" button.</p>
<h4 id="heading-step-5-data-collection-page">Step #5 – Data Collection Page</h4>
<p>On the data collection page, you can choose between different platforms to collect data from. Since we're working with a website, we'll go with the Web option.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/data-collection.png" alt="Image" width="600" height="400" loading="lazy">
<em>"Start collecting data" page on Google Analytics</em></p>
<p>Next, you'll have to create a data stream by inputting your website URL and your stream name (this can be whatever you want, but I'll recommend using a name related to your website).</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/stream.png" alt="Image" width="600" height="400" loading="lazy">
<em>Page to setup a data stream on Google Analytics</em></p>
<p>In my data stream information, I used <a target="_blank" href="https://astro-article.netlify.app/">https://astro-article.netlify.app/</a> as the URL and "Astro Blog Template" as the stream name. </p>
<p>Make sure you remove the "https://" part of the link in the input box so you don't get the "Valid website URL is required" error. </p>
<p>So instead of <a target="_blank" href="https://astro-article.netlify.app/">https://astro-article.netlify.app/</a>, it should be astro-article.netlify.app/.</p>
<p>This is what mine looks like:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/stream-data.png" alt="Image" width="600" height="400" loading="lazy">
<em>Example of information entered properly in data stream setup page</em></p>
<p>Go ahead and click on the "Create stream" button.</p>
<p>On the next page/prompt, you'll see your stream name, URL, ID, and measurement ID values. </p>
<p>The measurement ID will be important in our code integration so you can copy and paste it somewhere.</p>
<p>Here's what the page looks like:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/stream-details.png" alt="Image" width="600" height="400" loading="lazy">
<em>"Web stream details" page on Google Analytics</em></p>
<p>If you click on "View tag instructions", you'll see a code snippet for manual integration with code. Copy the code and paste somewhere because we'll make use of it soon.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/installation-instructions.png" alt="Image" width="600" height="400" loading="lazy">
<em>Installation instructions</em></p>
<p>Here's the code snippet if you missed it:</p>
<pre><code class="lang-js">&lt;!-- Google tag (gtag.js) --&gt;
<span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">async</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://www.googletagmanager.com/gtag/js?id=MEASUREMENT_ID"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></span>
<span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span>&gt;</span><span class="javascript">
  <span class="hljs-built_in">window</span>.dataLayer = <span class="hljs-built_in">window</span>.dataLayer || [];
  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">gtag</span>(<span class="hljs-params"></span>)</span>{dataLayer.push(<span class="hljs-built_in">arguments</span>);}
  gtag(<span class="hljs-string">'js'</span>, <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>());

  gtag(<span class="hljs-string">'config'</span>, <span class="hljs-string">'MEASUREMENT_ID'</span>);
</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></span>
</code></pre>
<p>If you're copying the code above, make sure you replace the two <code>MEASUREMENT_ID</code> values with the actual value of your measurement ID.</p>
<h3 id="heading-how-to-integrate-google-analytics-and-astro">How to Integrate Google Analytics and Astro</h3>
<p>At this point, we've created a Google Analytics account and generated our project's measurement ID. The next thing to do is to sync our code to Google Analytics.</p>
<p>But first, we have to install a library called Partytown. When using third party integrations, you may run into performance issues because you're making use of third-party code.</p>
<p>Partytown allows these integrations to run as expected without reducing your site's performance. You can read more about that <a target="_blank" href="https://partytown.builder.io/how-does-partytown-work">here</a>.</p>
<h4 id="heading-step-1-install-partytown">Step #1 – Install Partytown</h4>
<p>Head to your Astro project terminal and run this command to install Partytown:</p>
<pre><code class="lang-bash">npm install @astrojs/partytown
</code></pre>
<h4 id="heading-step-2-configure-partytown">Step #2 – Configure Partytown</h4>
<p>After the installation is complete, you'll need to add the integration to your <strong>astro.config.mjs</strong> file:</p>
<pre><code class="lang-js"><span class="hljs-keyword">import</span> { defineConfig } <span class="hljs-keyword">from</span> <span class="hljs-string">'astro/config'</span>;
<span class="hljs-keyword">import</span> mdx <span class="hljs-keyword">from</span> <span class="hljs-string">'@astrojs/mdx'</span>;
<span class="hljs-keyword">import</span> partytown <span class="hljs-keyword">from</span> <span class="hljs-string">'@astrojs/partytown'</span>

<span class="hljs-keyword">import</span> sitemap <span class="hljs-keyword">from</span> <span class="hljs-string">'@astrojs/sitemap'</span>;

<span class="hljs-comment">// https://astro.build/config</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> defineConfig({
    <span class="hljs-attr">site</span>: <span class="hljs-string">'https://astro-article.netlify.app/'</span>,
    <span class="hljs-attr">integrations</span>: [
        mdx(), 
        sitemap(),
        partytown({
            <span class="hljs-attr">config</span>: {
              <span class="hljs-attr">forward</span>: [<span class="hljs-string">"dataLayer.push"</span>],
            },
        }),
    ],
});
</code></pre>
<p>In the code above, we imported the Partytown library: <code>import partytown from '@astrojs/partytown'</code>.</p>
<p>We then added this piece of code to the <code>integrations</code> object:</p>
<pre><code class="lang-js">partytown({
      <span class="hljs-attr">config</span>: {
        <span class="hljs-attr">forward</span>: [<span class="hljs-string">"dataLayer.push"</span>],
      },
})
</code></pre>
<p>Everything else in the code came with the Astro project.</p>
<h4 id="heading-step-3-add-google-tag-to-your-pages">Step #3 – Add Google Tag to your Pages</h4>
<p>Remember the code snippet from Google Analytics? This is where we'll use it.</p>
<pre><code class="lang-js">&lt;!-- Google tag (gtag.js) --&gt;
<span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"text/partytown"</span> <span class="hljs-attr">async</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://www.googletagmanager.com/gtag/js?id=MEASUREMENT_ID"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></span>
<span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"text/partytown"</span>&gt;</span><span class="javascript">
  <span class="hljs-built_in">window</span>.dataLayer = <span class="hljs-built_in">window</span>.dataLayer || [];
  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">gtag</span>(<span class="hljs-params"></span>)</span>{dataLayer.push(<span class="hljs-built_in">arguments</span>);}
  gtag(<span class="hljs-string">'js'</span>, <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>());

  gtag(<span class="hljs-string">'config'</span>, <span class="hljs-string">'MEASUREMENT_ID'</span>);
</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></span>
</code></pre>
<p>Note that we added the <code>type="text/partytown"</code> attribute to both <code>&lt;script&gt;</code> tags.</p>
<p>Remember to change the <code>MEASUREMENT_ID</code> values to the value of your measurement ID. Mine looks like this:</p>
<pre><code class="lang-js">&lt;!-- Google tag (gtag.js) --&gt;
<span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"text/partytown"</span> <span class="hljs-attr">async</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://www.googletagmanager.com/gtag/js?id=G-KM36S70L8Y"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></span>
<span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"text/partytown"</span>&gt;</span><span class="javascript">
    <span class="hljs-built_in">window</span>.dataLayer = <span class="hljs-built_in">window</span>.dataLayer || [];
    <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">gtag</span>(<span class="hljs-params"></span>)</span>{dataLayer.push(<span class="hljs-built_in">arguments</span>);}
    gtag(<span class="hljs-string">'js'</span>, <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>());

    gtag(<span class="hljs-string">'config'</span>, <span class="hljs-string">'G-KM36S70L8Y'</span>);
</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span></span>
</code></pre>
<p>Do not use the snippet above as it has my measurement ID so it's already associated with my website (not my actual website :)). Copy the first code snippet and change the <code>MEASUREMENT_ID</code> values to the value of your measurement ID.</p>
<p>To track and monitor a page, you need to paste the code snippet in that page. Copy the code and paste it in the <code>&lt;head&gt;</code> section of every page file (<strong>index.astro</strong>, <strong>BlogPost.astro</strong>, and so on) you want to track and analyze using Google Analytics.</p>
<p>Here's an example using the <strong>index.astro</strong> file in the <strong>pages</strong> directory:</p>
<pre><code class="lang-js">---
<span class="hljs-keyword">import</span> BaseHead <span class="hljs-keyword">from</span> <span class="hljs-string">'../components/BaseHead.astro'</span>;
<span class="hljs-keyword">import</span> Header <span class="hljs-keyword">from</span> <span class="hljs-string">'../components/Header.astro'</span>;
<span class="hljs-keyword">import</span> Footer <span class="hljs-keyword">from</span> <span class="hljs-string">'../components/Footer.astro'</span>;
<span class="hljs-keyword">import</span> { SITE_TITLE, SITE_DESCRIPTION } <span class="hljs-keyword">from</span> <span class="hljs-string">'../consts'</span>;
---

&lt;!doctype html&gt;
<span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">html</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">"en"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
        <span class="hljs-comment">&lt;!-- Google tag (gtag.js) --&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"text/partytown"</span> <span class="hljs-attr">async</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://www.googletagmanager.com/gtag/js?id=MEASUREMENT_ID"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"text/partytown"</span>&gt;</span><span class="javascript">
            <span class="hljs-built_in">window</span>.dataLayer = <span class="hljs-built_in">window</span>.dataLayer || [];
            <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">gtag</span>(<span class="hljs-params"></span>)</span>{dataLayer.push(<span class="hljs-built_in">arguments</span>);}
            gtag(<span class="hljs-string">'js'</span>, <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>());

            gtag(<span class="hljs-string">'config'</span>, <span class="hljs-string">'MEASUREMENT_ID'</span>);
        </span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">BaseHead</span> <span class="hljs-attr">title</span>=<span class="hljs-string">{SITE_TITLE}</span> <span class="hljs-attr">description</span>=<span class="hljs-string">{SITE_DESCRIPTION}</span> /&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
... <span class="hljs-comment">&lt;!-- The rest of the HTML code --&gt;</span></span>
</code></pre>
<p>In the code above, we put the code from Google Analytics in the <code>&lt;head&gt;</code> tag. You can do this for all the pages you want to track and monitor.</p>
<p>Remember to add the  <code>type="text/partytown"</code> attribute to the <code>&lt;script&gt;</code> tags, and to change <code>MEASUREMENT_ID</code> (used in two places in the snippet) to the value of your measurement ID.</p>
<h4 id="heading-step-4-build-and-deploy-your-project">Step #4 – Build and Deploy your Project</h4>
<p>Deploy your project using your preferred process.</p>
<p>In my case, I use <code>npm run build</code> to build into the <strong>dist</strong> folder, and then push the code to GitHub. This automatically triggers a Netlify deployment.</p>
<p>You don't have to use my method, but make sure your project builds before deployment.</p>
<p>Once your website has been deployed, you may have to wait up to 48 hours to start seeing your analytics in the Google Analytics dashboard.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/pending-data-collection.png" alt="Image" width="600" height="400" loading="lazy">
<em>Pending page</em></p>
<p>You'll see this page when you click on the "Continue to Home" button:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2023/10/analytics-home.png" alt="Image" width="600" height="400" loading="lazy">
<em>Analytics dashboard</em></p>
<p>This is your Analytics dashboard. You can view and analyze your data here when there's data available.</p>
<p>And there you have it! You've successfully integrated your project with Google Analytics.</p>
<h2 id="heading-summary">Summary</h2>
<p>In this article, we saw how to add Google Analytics to an Astro project. This can be used to track and analyze traffic and events on websites and apps.</p>
<p>We saw how to create and set up an Analytics account. </p>
<p>We then saw how to integrate Google Analytics to an Astro project using code.</p>
<p>Happy coding! You can read more Astro tutorials on <a target="_blank" href="https://ihechikara.com/">my blog</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Learn the Astro Web Framework ]]>
                </title>
                <description>
                    <![CDATA[ Astro is rapidly emerging as a go-to choice for developers looking to build fast, content-focused websites. Whether you're crafting landing pages, informative blogs, or intricate technical documentation, Astro offers a comprehensive suite of tools to... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/learn-the-astro-web-framework/</link>
                <guid isPermaLink="false">66b2052927569435a9255adc</guid>
                
                    <category>
                        <![CDATA[ Astro ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Fri, 29 Sep 2023 16:05:36 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2023/09/astro.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Astro is rapidly emerging as a go-to choice for developers looking to build fast, content-focused websites. Whether you're crafting landing pages, informative blogs, or intricate technical documentation, Astro offers a comprehensive suite of tools to make the process seamless.</p>
<p>We just posted an Astro crash course on the freeCodeCamp.org YouTube channel, presented by none other than James Q Quick.</p>
<p>James Q Quick is a popular instructor and keynote conference speaker. In this course, he combines his vast experience with his unique teaching style, making him the ideal guide to introduce you to the wonders of Astro.</p>
<p>In this crash course, James teaches the core concepts of Astro. Here are some of the topics that you will learn about:</p>
<ul>
<li><strong>File-Based Routing</strong>: Navigate the straightforward routing system that Astro uses.</li>
<li><strong>Markdown and MDX Content</strong>: Create and manage your content with ease.</li>
<li><strong>Dynamic Routes</strong>: Understand how to set up routes that adapt based on your content.</li>
<li><strong>Deployment</strong>: Get your Astro site up and running on platforms like Netlify and Vercel.</li>
<li><strong>Server-Side Capabilities</strong>: Transition from a statically generated site to a server-side rendered one, and discover how to integrate server endpoints.</li>
</ul>
<p>In the modern web landscape, website performance can make or break user engagement. With Astro's focus on optimized performance, mastering this framework equips developers with the skills to create websites that not only look good but also function seamlessly. This course isn't just about learning a new tool; it's about staying ahead in the competitive world of web development.</p>
<p>Here are the sections in this course:</p>
<ul>
<li>What We'll Cover</li>
<li>Creating an Astro Project</li>
<li>Astro Overview (components, file based routing, css, and more)</li>
<li>Astro Setup in VS Code</li>
<li>Setting Up Tailwind</li>
<li>Updating the Site Layout</li>
<li>Working with Markdown and Content Collections</li>
<li>Querying and Displaying Blog Posts Using Content Collections</li>
<li>Creating Dynamic Routes For Individual Blog Posts</li>
<li>Tailwind Typography Plugin</li>
<li>Optimizing Images</li>
<li>Using the ViewTransitions API</li>
<li>Adding Support for MDX</li>
<li>Deploy to Netlify and Vercel</li>
<li>Enabling SSR in Astro</li>
<li>Astro Server Endpoints</li>
<li>Deploying SSR to Netlify and Vercel</li>
</ul>
<p>You can watch the full course on <a target="_blank" href="https://youtu.be/e-hTm5VmofI">the freeCodeCamp.org YouTube channel</a> (1-hour watch).</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/e-hTm5VmofI" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
