SEO for SaaS
Start
Guides

Astro

A static blog under /blog, rendered by the SEO for SaaS React components at build time, rebuilt by your host's deploy hook.

Tested on Astro 7 with a static build. The pages render at build time, so no JavaScript is shipped for articles and the content key stays on the build machine.

1. Set where articles live

Dashboard → your site → Settings → Site and URLs → Articles live under: /blog. SEO for SaaS builds canonical URLs and the links between articles on it. Until you set it, articles live at the site root. Articles written before you set it keep their old paths.

2. Environment

.env
SEOFORSAAS_PROJECT_ID=YOUR_PROJECT_IDSEOFORSAAS_CONTENT_KEY=      # Delivery → Content API keys → Create key

Variables without the PUBLIC_ prefix are available to server and build code only. Set them in your host's build settings too.

3. Install

bash
npx astro add react sitemapfor f in lib/seoforsaas/types.ts lib/seoforsaas/client.ts \  components/seoforsaas/article.tsx components/seoforsaas/blocks.tsx components/seoforsaas/rich-text.tsx \  components/seoforsaas/video.tsx components/seoforsaas/json-ld.tsx components/seoforsaas/article.css; do  curl -fsSL --create-dirs -o "src/$f" "https://seoforsaas.dev/r/files/$f"done

The React integration renders the React components to static HTML; nothing hydrates. The video block then shows its poster linked to YouTube.

4. Config

astro.config.mjs
// @ts-checkimport { defineConfig } from "astro/config";import react from "@astrojs/react";import sitemap from "@astrojs/sitemap";import { loadEnv } from "vite";import { createClient } from "./src/lib/seoforsaas/client.ts";// The config runs before Astro loads .env into import.meta.env: read it here.const env = loadEnv(process.env.NODE_ENV ?? "production", process.cwd(), "");/** * Path → last reader-visible change, fetched once per build. Keyed by path, not * URL, so a www or http difference between `site` and the project cannot drop * every date. * @type {Promise<Map<string, string>> | undefined} */let articleDates;/** @param {string} url */const lastModifiedOf = async (url) => {  // Inside the promise: a missing variable makes createClient throw, and that  // must reach the .catch below like any failed request.  articleDates ??= Promise.resolve()    .then(() =>      createClient({        projectId: env.SEOFORSAAS_PROJECT_ID,        contentKey: env.SEOFORSAAS_CONTENT_KEY,      }).getSitemap(),    )    .then((entries) => new Map(entries.map((e) => [new URL(e.url).pathname, e.lastModified])))    // @astrojs/sitemap drops the whole sitemap on a throw and still reports    // success; a sitemap without dates is the better failure.    .catch((error) => {      console.warn(`[seoforsaas] sitemap dates unavailable, writing it without them: ${error}`);      return new Map();    });  return (await articleDates).get(new URL(url).pathname);};// https://astro.build/configexport default defineConfig({  // Your production origin: canonical URLs and the sitemap resolve against it.  site: "https://example.com",  // Article URLs have no trailing slash (neither has seo.canonicalPath):  // serve them that way, or every canonical would point at a redirect.  trailingSlash: "never",  build: { format: "file" },  integrations: [    react(),    sitemap({      // Articles get their lastmod from the API; your own pages are left as they are.      serialize: async (item) => ({ ...item, lastmod: (await lastModifiedOf(item.url)) ?? item.lastmod }),    }),  ],});

The sitemap integration lists every page the build writes; serialize gives each article the lastmod the sitemap endpoint reports, one request per build, and leaves your other pages alone. The config runs before Astro loads .env, hence loadEnv. If that request fails, the build still writes the sitemap, without dates, and says so.

Dates are matched by path, so site may differ from your website URL in www or protocol and every article still gets its date.

5. Data

src/lib/blog.ts
import { createClient } from "./seoforsaas/client";// Server-only: Astro exposes variables without the PUBLIC_ prefix to server code alone.export const seoforsaas = createClient({  projectId: import.meta.env.SEOFORSAAS_PROJECT_ID,  contentKey: import.meta.env.SEOFORSAAS_CONTENT_KEY,});

6. Pages

src/pages/blog/index.astro
---import { seoforsaas } from "../../lib/blog";const articles = await seoforsaas.listAllArticles();---<html lang="en">  <head>    <meta charset="utf-8" />    <meta name="viewport" content="width=device-width" />    <title>Blog</title>  </head>  <body>    <main>      <h1>Blog</h1>      <ul>        {articles.map((article) => (          <li>            <a href={`/blog/${article.slug}`}><h2>{article.title}</h2></a>            <p>{article.seo.metaDescription}</p>          </li>        ))}      </ul>    </main>  </body></html>
src/pages/blog/[slug].astro
---import type { GetStaticPaths } from "astro";import { Article } from "../../components/seoforsaas/article";import { JsonLd } from "../../components/seoforsaas/json-ld";import { safeHref } from "../../components/seoforsaas/rich-text";import "../../components/seoforsaas/article.css";import { seoforsaas } from "../../lib/blog";export const getStaticPaths = (async () => {  const articles = await seoforsaas.listAllArticles();  return articles.map(({ slug }) => ({ params: { slug } }));}) satisfies GetStaticPaths;const { slug } = Astro.params;const article = await seoforsaas.getArticle(slug);// Static build: every slug came from the list a moment ago.if (!article) throw new Error(`Article "${slug}" was unpublished during the build`);const { seo } = article;const canonical = new URL(seo.canonicalPath, Astro.site);---<html lang="en">  <head>    <meta charset="utf-8" />    <meta name="viewport" content="width=device-width" />    <title>{seo.metaTitle}</title>    <meta name="description" content={seo.metaDescription} />    <link rel="canonical" href={canonical} />    <meta property="og:type" content="article" />    <meta property="og:title" content={seo.openGraph.title} />    <meta property="og:description" content={seo.openGraph.description} />    {seo.openGraph.image && <meta property="og:image" content={seo.openGraph.image} />}    <JsonLd data={seo.jsonLd} />  </head>  <body>    <main>      <article>        <h1>{article.title}</h1>        {article.thumbnailUrl && <img src={article.thumbnailUrl} alt="" width="1536" height="1024" />}        <Article blocks={article.blocks} />        {article.related.length > 0 && (          <nav aria-label="Related articles">            <h2>Related</h2>            <ul>              {article.related.map((link) => {                const href = safeHref(link.href);                return href && <li><a href={href}>{link.title}</a></li>;              })}            </ul>          </nav>        )}      </article>    </main>  </body></html>

To serve the pages from another path, change Articles live under to match.

7. Rebuild on publish

A static site updates by rebuilding. Create a deploy hook in your host (Cloudflare Pages, Vercel, and Netlify all offer one) and paste its URL into Delivery → Webhook. SEO for SaaS sends a POST to it when an article is published, changed, or unpublished; the host ignores the request body and rebuilds. Select Send test event to check. See Static sites.

If you run Astro with a server adapter, read with seoforsaas.getArticle() on each request, and mount the webhook handler as an endpoint that purges your cache, passing { secret: import.meta.env.SEOFORSAAS_WEBHOOK_SECRET }.

Check

CheckExpect
npx astro check0 errors
npx astro builddist/blog/<slug>.html for every article, and sitemap-0.xml listing them, each article with a <lastmod>
Open an article's HTMLone <title>, a description, a canonical link without a trailing slash, og: tags, one application/ld+json script