Is SEO easier with Astro or Wordpress?
Moving to Astro from Wordpress is daunting for many reasons, but the big battle is really about doing for yourself what plugins were doing for you. A big concern is the technicality of SEO code: how to implement it and make it work for your website

Chris Good
Digital Strategist
Like many, I've transitioned from Wordpress to Astro in early 2026...and there's a lot to learn in that transition.
The process is daunting for a lot of developers who feel that WordPress is lagging behind and certainly can't keep up with the pace of AI websites.
One of the biggest arguments for staying with WordPress is the ecosystem.
Now, that's an interesting statement because it's the ecosystem that has often been to the detriment of WordPress and I consider there to be a real argument to saying it is (and has been) more of a curse than a blessing. Plugin developers frequently go out of business or ditch plugins you rely on for your site. So in my mind, one of the best things we can do is make ourselves independent of all these dependencies.
SEO is one of those areas where people feel confused about how to implement it in code. It feels daunting, which becomes an argument to stay with WordPress.
The thing I hear regularly is that with WordPress, you only need an SEO plugin like RankMath—the one I've used—and everything is right at your fingertips.
But it's actually very simple to implement both the most used and least used features of RankMath inside an Astro site. We have to remember that it's all just code. Once you understand how to implement it and what it looks like, you realise we're mostly using code snippets and templates anyway. If you've done it once in a project, you can reuse it across many others.
Below, you'll find a load of snippets for implementing SEO code into an Astro project. This includes guidance on generating a sitemap, which is actually an automatic process in Astro, as well as implementing the most important on-page SEO features, like meta titles, meta descriptions, and Open Graph (OG) tags. Getting to grips with this and learning how to implement it in your Astro templates is far simpler than it seems.
To be honest, it's much quicker than constantly setting up and configuring RankMath, along with managing all those dependencies and potential rooms for error.
Another thing to consider is that RankMath (especially on free plans), like any other SEO plugin, imposes limitations to upsell you to premium tiers. In Astro, you are free to add every single advanced feature your site needs and benefits from. In this day and age of SEO, AEO, and GEO, you shouldn't be without them. It really comes down to a choice: pay for RankMath Pro, or learn how to implement these features directly in your Astro sites.
Here's the big kicker, though: AI can write these implementations for you automatically within 30 seconds. Once you consider that, there's really nothing else to debate.
As I say in my videos, I'll always recommend doing something once manually, and occasionally in refreshment training, but the fact is that there's no easier way to implement SEO into a website than to ask AI to add it to your Astro project.
The first question everyone asks coming from WordPress is some version of "but what about the helpful plugins and the supporting ecosystem?. And underneath it there's a real fear, which is that you're about to give up a load of functionality in exchange for a faster site and a nicer developer experience. One step forward, two back. More than that...all the responsibility is on you to actually code it.
More specifically, there are questions about the big plugins that do the big things...can we live without them? "What about Yoast or Rankmath?". Are we giving up a ton of SEO features for an Astro website?
That's not the trade. You're not losing the features. You're losing the plugin.
Every single thing Rank Math or Yoast does (meta titles, descriptions, canonicals, Open Graph, Twitter cards, schema, sitemaps, redirects, noindex toggles) is a small piece of code. None of it is difficult. None of it is proprietary. The plugin isn't doing anything clever; it's doing something tedious, once, on your behalf, and then charging you for the premium tier.
Here's the bit that is super empowering and which I've really started to lean into: once you're writing it yourself, there is no premium tier. There's no feature gate, no upsell, no "redirect manager available in Pro." You want per-page schema? Write it. Want a noindex toggle that also strips the page from your sitemap? That's one line in a GROQ query. Want a redirect system your marketing team can edit without touching code? Half an hour. You get everything the paid plugins do, plus everything they don't do, because nobody's product roadmap is deciding what's possible on your site.
And you're removing a dependency. That's the part people undervalue and which, again, I'm really leaning into. A plugin is someone else's code running on your client's site, updating on someone else's schedule, with someone else's security record and someone else's abandonment risk. Every plugin is a bet that a third party will keep caring. Some of them stop. Elementor went pants. Etch ditched Wordpress and left the LTD members in the dust. Wordpress itself is pivoting and you have to either pivot with, or leave.
All the more reason to become independent. What you write instead is typed, versioned, sitting in git, and does exactly what it says. When something's wrong you open the file and read it. No settings panel with forty toggles where thirty-eight of them do nothing you understand.
The honest cost: roughly two hours the first time, fifteen minutes on every project after, because you copy the same three files across. That's the whole investment.
Below, I outline some of the main features we use in Rankmath (or other household brands of Wordpress SEO plugins). This will equip you with knitting together the SEO fundamentals you need in your Astro projects; and you'll realise that Rankmath and Yoast (and all the others) are charging a lot for something you can do yourself.
Quick answers
If you just want the short version before the detail:
Sitemaps. One command — npx astro add sitemap — and it generates from your built routes. Set site in the config or it silently produces nothing. filter handles exclusions. On a CMS-driven site I skip the integration and hand-roll a sitemap.xml.ts endpoint instead, so lastmod comes from Sanity's _updatedAt and the noindex toggle removes a page from both the meta tag and the sitemap. One switch, no contradictory signals.
Caching on Vercel. Nothing to install. There's no WP Rocket equivalent because there's nothing to cache — static HTML sits on the CDN and hashed assets get immutable long-cache headers automatically. Caching plugins exist to stop WordPress rebuilding a page from the database on every request. Astro built the page at deploy time. The problem doesn't exist.
Rank Math equivalent. One SEO.astro component, props from the layout, fields in Sanity so marketers can edit them. coalesce() in GROQ handles fallbacks — blank meta title uses the page title. About thirty lines more gets you a live Google snippet preview in the Studio, which is the one Yoast feature people genuinely miss.
Redirects. A redirect document type in Sanity, a prebuild script that writes vercel.json, and Vercel handles them at the edge. The marketing team owns the list. The one honest caveat: they go live on the next deploy, not instantly — so wire up a webhook and tell the client it takes a minute or two.
Analytics. Vercel Analytics and Speed Insights are one line each. Plausible or Fathom if you want something with a proper dashboard and no cookie banner.
A note before you start
If you just want this working on a project, open Claude Code and say:
Set up SEO for this Astro site: install and configure @astrojs/sitemap with the site URL set, create an SEO.astro component handling title, description, canonical, Open Graph and Twitter card with absolute image URLs, wire it into the base layout via props, and add the sitemap reference to robots.txt.
It'll do it in about ninety seconds and it'll be correct.
This guide isn't competing with that. It's here so you understand what it produced, can debug it when a client's OG image doesn't render on LinkedIn, and can make the architectural calls the AI won't make for you — which pages to exclude, whether to use the sitemap integration or hand-roll the endpoint, where the schema lives.
Understanding beats typing. Type as little as you like.
1. Sitemaps
Install
npx astro add sitemap
Where the configuration lives
This is the first thing that feels wrong coming from a plugin ecosystem, so let's get it out of the way: there is no admin screen, and no config file gets created.
npx astro add sitemap does exactly two things. Installs the package. Adds sitemap() to your integrations array. That's the entire footprint. Nothing is scaffolded into src/. There's no sitemap.config.js waiting to be opened.
Everything lives in one file, in your project root:
my-astro-site/
├── astro.config.mjs ← here
├── package.json
├── tsconfig.json
├── public/
├── src/
└── node_modules/
Same folder as package.json, not inside src/. The extension might be .ts or .mts depending on how the project was scaffolded — same file, same contents, just check what's actually sitting there.
Configure
Straight after astro add, the file looks like this:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
integrations: [sitemap()],
});
Change it to this:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://client.co.uk', // REQUIRED
trailingSlash: 'never', // pick one and stick to it
integrations: [sitemap()],
});
site is not optional. Leave it out and the integration logs a warning and silently produces nothing. This is the single most common cause of "my sitemap is empty," and it's silent enough that people spend an afternoon on it.
Restart the dev server after editing. Config changes aren't hot-reloaded — Astro usually restarts itself, but if something looks stale, kill it and run npm run dev again.
Filtering
You were calling sitemap() with empty brackets. To configure it, you pass it an object. Every option is a key in that object — that's the whole pattern, and it's the same for every Astro integration you'll ever add.
export default defineConfig({
site: 'https://client.co.uk',
integrations: [
sitemap({
filter: (page) => !page.includes('/thanks/'),
}),
],
});
filter is a function you write inline, in the config, and there is nowhere else it can go.
What page actually is: the full absolute URL string — https://client.co.uk/thanks/ — not a path, not a route object. Return true to keep it, false to drop it. Runs once per discovered page.
Excluding a few things:
sitemap({
filter: (page) =>
!page.includes('/thanks/') &&
!page.includes('/preview/') &&
!page.includes('/styleguide/'),
})
The second mechanism is serialize, which runs per entry just before the file gets written. It's where per-page lastmod goes, and because returning undefined drops an entry, it can filter too:
sitemap({
serialize(item) {
if (/\/preview\//.test(item.url)) return undefined; // drop it
if (/\/blog\//.test(item.url)) {
item.lastmod = new Date();
item.priority = 0.8;
}
return item; // you MUST return the item, or it vanishes
},
})
Forget that final return item and your sitemap silently empties. It's a common one, and there's no error — you just get an empty file and a confused half hour.
Why you can't "see" the filter working
Worth understanding this properly, because it's the thing that makes the whole setup feel opaque at first.
It's a build-time hook, not runtime config. Astro finishes rendering everything, fires an internal astro:build:done event, hands the sitemap integration a list of every page it just produced, your filter function runs across that list, and XML gets written into dist/.
There's no intermediate state anywhere. Nothing to inspect. The only place the result exists is the output:
npm run build
ls dist/sitemap*
cat dist/sitemap-0.xml
If a page you expected is missing, or one you excluded is still in there, that file is your source of truth. Nothing in src/ will tell you anything, because nothing in src/ knows.
And astro dev does not generate a sitemap at all. Hit localhost:4321/sitemap-index.xml and you get a 404, at which point most people reasonably conclude they've broken it. You haven't. You just have to run a full build.
To debug a filter that's misbehaving, log what it's actually receiving:
filter: (page) => {
console.log('SITEMAP:', page);
return !page.includes('/thanks/');
},
Run the build, read the terminal. Nine times out of ten it's a trailing-slash mismatch — you filtered /thanks and the real string is /thanks/. Which is exactly why setting trailingSlash explicitly in the config earns its keep.
Four things nobody mentions
It only sees build-time pages. The hook exposes rendered page paths, so on-demand and SSR routes are invisible to it. Use customPages for those. Non-issue on a static brochure site, but it's the kind of thing that bites six months later when you add a dynamic route.
changefreq and priority are ignored by Google. Astro's own docs say so. Setting them is cargo cult, inherited from a decade of WordPress plugins that put them in the UI because they could. lastmod is the only element that carries any weight, and only if it's honest — a sitemap claiming every page updated today is a sitemap Google stops trusting.
It doesn't touch robots.txt. Add it yourself:
# public/robots.txt
User-agent: *
Allow: /
Sitemap: https://client.co.uk/sitemap-index.xml
The output isn't called sitemap.xml. You get sitemap-index.xml plus sitemap-0.xml. Submit the index to Search Console. Splits at 45,000 entries by default. People submit the wrong URL constantly and then wonder why nothing's indexed.
2. Meta and Open Graph, manually
We're going to do this the ugly way first. Not because you'd ship it, but because it makes the constraint visible — and the constraint is what shapes everything that comes after.
Astro has no head-hoisting. No Helmet, no next/head, no magic mechanism for a nested component to inject tags up into <head>. Meta tags render exactly where you put them, and they have to be inside <head>. That single fact is why data flows page → layout via props, rather than the other way round.
The crudest possible version
---
// src/pages/about.astro
const title = 'About Us | Client Name';
const description = 'Twenty years fitting bathrooms across Devon.';
const canonical = new URL(Astro.url.pathname, Astro.site);
const ogImage = new URL('/og/about.jpg', Astro.site);
---
<html lang="en-GB">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta property="og:site_name" content="Client Name" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImage} />
</head>
<body>
<h1>About Us</h1>
</body>
</html>
That works. It's also completely unmaintainable across twelve pages, which is the point of showing it.
The two traps
Absolute OG image URLs. Writing content="/og/about.jpg" looks fine in your markup, validates fine, and fails silently in every social scraper on earth. Facebook, LinkedIn and X all need a fully-qualified URL. new URL(path, Astro.site) fixes it — and that's the second time site in your config has earned its place.
This is worth internalising because the failure is invisible. Nothing errors. The page looks perfect. You find out when a client shares their new homepage on LinkedIn and gets a grey box.
Canonicals and trailing slashes. Astro.url.pathname respects your trailingSlash setting. On the default ('ignore') you can end up emitting /about in your canonical while the sitemap emits /about/. Google treats those as two URLs. Set it explicitly and the problem disappears:
export default defineConfig({
site: 'https://client.co.uk',
trailingSlash: 'never', // or 'always' — just pick
integrations: [sitemap()],
});
On og:type
Original Open Graph spec — one of four required properties alongside og:title, og:image and og:url. Most tutorials skip it, and nothing visibly breaks when you do, because scrapers default to website.
Where it actually does something is gating type-specific sub-properties:
<meta property="og:type" content="article" />
<meta property="article:published_time" content="2026-08-20T09:00:00Z" />
<meta property="article:modified_time" content="2026-08-20T14:30:00Z" />
<meta property="article:author" content="Chris Good" />
Order matters for strict parsers — the structured properties follow their parent og:type, they don't precede it.
In practice the decision is: website for pages, article for posts. That's the entire complexity. Don't let anyone sell you a course on it.
Test it
- Facebook — developers.facebook.com/tools/debug
- LinkedIn — linkedin.com/post-inspector
- X — cards-dev.twitter.com/validator
All three cache aggressively. Facebook's debugger has a "Scrape Again" button and you'll want it, because otherwise you'll spend twenty minutes debugging a response that was cached before you made the fix.
3. The same thing, as a component
Two files, and every page gets shorter.
The SEO component
---
// src/components/SEO.astro
interface Props {
title: string;
description: string;
image?: string;
type?: 'website' | 'article';
noindex?: boolean;
published?: string;
modified?: string;
}
const {
title,
description,
image = '/og/default.jpg',
type = 'website',
noindex = false,
published,
modified,
} = Astro.props;
const SITE_NAME = 'Client Name';
const canonical = new URL(Astro.url.pathname, Astro.site);
// Handles both local paths and already-absolute CDN URLs
const ogImage = image.startsWith('http')
? image
: new URL(image, Astro.site).href;
---
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex,nofollow" />}
<meta property="og:type" content={type} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:locale" content="en_GB" />
{type === 'article' && published && (
<meta property="article:published_time" content={published} />
)}
{type === 'article' && modified && (
<meta property="article:modified_time" content={modified} />
)}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImage} />
That image.startsWith('http') check is small and load-bearing. It's what lets this exact component serve both the hand-written version and the Sanity version later without a single character changing. Worth noticing now so the payoff lands.
About interface Props
If you've not written TypeScript in Astro before, this bit is magic-by-name and worth explaining.
Declare a type called exactly Props in a component's frontmatter and Astro uses it to type-check Astro.props — and, more usefully, every place the component gets used. The name is the wiring. Call it SEOProps and nothing happens; you get no error, just no checking.
interface Props {
title: string; // required
description: string; // required
image?: string; // the ? makes it optional
type?: 'website' | 'article'; // only these two exact strings allowed
}
The ? is the whole point — it's a contract saying title and description are mandatory and everything else has a default. That union type, 'website' | 'article', gives you autocomplete for both values and turns type="artical" into a red squiggle in your editor rather than a broken OG tag you find in three weeks.
It's entirely optional. Destructure Astro.props without any of it and Astro doesn't care. But SEO props fail silently — a missing description doesn't crash anything, doesn't look wrong in a browser, and you find out when someone runs Screaming Frog over the site. The interface turns a silent omission into a loud editor error, which is exactly the trade you want here.
One gotcha: a normal npm run build won't fail on type errors by default. Run astro check && astro build if you want it actually enforced.
The layout
---
// src/layouts/Base.astro
import SEO from '../components/SEO.astro';
interface Props {
title: string;
description: string;
image?: string;
type?: 'website' | 'article';
noindex?: boolean;
published?: string;
modified?: string;
}
const props = Astro.props;
---
<!doctype html>
<html lang="en-GB">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="sitemap" href="/sitemap-index.xml" />
<SEO {...props} />
<slot name="head" />
</head>
<body>
<slot />
</body>
</html>
Two details worth pointing at:
<SEO {...props} /> — that's a spread. Adding a prop to the interface doesn't mean editing three files.
<slot name="head" /> — the escape hatch. Page-specific one-offs go in without widening the prop list on every page forever:
<Base title="…" description="…">
<Fragment slot="head">
<link rel="preload" href="/fonts/heading.woff2" as="font" crossorigin />
</Fragment>
<h1>…</h1>
</Base>
The page
---
// src/pages/about.astro
import Base from '../layouts/Base.astro';
---
<Base
title="About Us | Client Name"
description="Twenty years fitting bathrooms across Devon."
image="/og/about.jpg"
>
<h1>About Us</h1>
</Base>
Forty lines down to seven. And every page now has a canonical, a correct absolute OG URL and a consistent Twitter card, whether the person building it remembered or not. That last bit is the real value — it's not that you typed less, it's that it's no longer possible to forget.
How props actually get passed
Four syntaxes, and mixing them up is the standard first-hour stumble.
Literal strings — quotes, exactly like HTML attributes:
<SEO title="This is my page title" description="A short summary." />
Anything that isn't a literal string — braces:
<SEO
title="This is my page title"
description={page.description}
noindex={true}
modified={new Date().toISOString()}
/>
Note noindex={true} and not noindex="true". The second passes the string "true", which is truthy but wrong, and it's the exact kind of thing declaring noindex?: boolean in your interface catches for you.
Shorthand — when the variable and prop names match:
<SEO {title} {description} />
Identical to title={title} description={description}.
Spread — forward a whole object as individual props:
<SEO {...props} />
If props is { title: '…', description: '…' } then that's the same as writing both out. It's how the layout forwards everything down without needing to know what's in it.
Object-literal syntax with colons only shows up in frontmatter, between the --- fences, when you're building an object rather than passing props:
---
const schema = { '@type': 'WebPage', name: title };
---
<SchemaGraph schema={schema} />
The full chain
about.astro Base.astro SEO.astro
───────────── ────────── ─────────
<Base const props = const { title } =
title="About Us" → Astro.props → Astro.props
description="…" <SEO {...props} /> <title>{title}</title>
/>
Attributes on the tag become Astro.props inside the component. That's the entire mechanism, repeated at each level. Once that clicks, Astro components stop being mysterious.
Collections get it for free
Set it once in the dynamic route and every post inherits it:
---
// src/pages/blog/[...slug].astro
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<Base
title={`${post.data.title} | Client Name`}
description={post.data.description}
image={post.data.ogImage ?? '/og/blog-default.jpg'}
type="article"
published={post.data.publishDate.toISOString()}
modified={post.data.updatedDate?.toISOString()}
>
<article><Content /></article>
</Base>
Written once, applies to every post forever.
4. Driven from Sanity
Here's where it gets useful for clients, and here's the headline: the SEO component doesn't change. Not one character. Only the data source moves.
That's worth saying plainly because it's the thing that makes the manual version worth learning rather than a throwaway teaching exercise. Whatever you build by hand, you keep.
Studio: step 1 — the reusable object
// schemaTypes/objects/seo.ts
import { defineType, defineField } from 'sanity';
export const seo = defineType({
name: 'seo',
title: 'SEO',
type: 'object',
options: { collapsible: true },
fields: [
defineField({
name: 'title',
title: 'Meta title',
type: 'string',
description: 'Leave blank to use the page title. Under 60 characters.',
validation: (R) => R.max(60).warning('Google will truncate this'),
}),
defineField({
name: 'description',
title: 'Meta description',
type: 'text',
rows: 3,
description: 'Leave blank to use the page excerpt. Under 155 characters.',
validation: (R) => R.max(160).warning('Google will truncate this'),
}),
defineField({
name: 'ogImage',
title: 'Social share image',
type: 'image',
options: { hotspot: true },
description: '1200 × 630. Falls back to the site default.',
}),
defineField({
name: 'noindex',
title: 'Hide from search engines',
type: 'boolean',
initialValue: false,
}),
],
});
.warning() throughout, never .error(). This matters more than it looks. Never block a marketer from publishing because their title is 62 characters. That behaviour — the red light that stops you shipping — is precisely what people hate about Yoast, and repeating it in a bespoke system you built on purpose is inexcusable.
Studio: step 2 — register it
// schemaTypes/index.ts
import { seo } from './objects/seo';
import { page } from './documents/page';
import { post } from './documents/post';
import { siteSettings } from './documents/siteSettings';
export const schemaTypes = [seo, page, post, siteSettings];
Studio: step 3 — attach it, with a tab
// schemaTypes/documents/page.ts
export const page = defineType({
name: 'page',
type: 'document',
groups: [
{ name: 'content', title: 'Content', default: true },
{ name: 'seo', title: 'SEO' },
],
fields: [
defineField({ name: 'title', type: 'string', group: 'content' }),
defineField({
name: 'slug',
type: 'slug',
options: { source: 'title' },
group: 'content',
}),
defineField({ name: 'excerpt', type: 'text', rows: 2, group: 'content' }),
defineField({
name: 'body',
type: 'array',
of: [{ type: 'block' }],
group: 'content',
}),
defineField({ name: 'seo', type: 'seo', group: 'seo' }),
],
});
One line — defineField({ name: 'seo', type: 'seo', group: 'seo' }) — and a complete SEO tab appears on that document type. Repeat across every type you've got. Define once, reuse everywhere, and when you decide to add an ogImageAlt field in a year it appears everywhere at once.
Studio: step 4 — sitewide defaults
// schemaTypes/documents/siteSettings.ts
export const siteSettings = defineType({
name: 'siteSettings',
type: 'document',
fields: [
defineField({ name: 'siteName', type: 'string' }),
defineField({
name: 'titleTemplate',
type: 'string',
initialValue: '%s | Client Name',
description: '%s is replaced with the page title',
}),
defineField({ name: 'defaultDescription', type: 'text', rows: 3 }),
defineField({ name: 'defaultOgImage', type: 'image' }),
],
});
This is your ACF Options page. Same concept exactly — one document, sitewide values. Pin it as a singleton in your structure builder so editors can't create a second one or delete the only one.
One mechanical difference from ACF that catches everyone. In WordPress, options are ambient: get_field('site_name', 'option') works in any template at any depth, because there's a PHP runtime with a live database connection sitting behind it.
Astro has no ambient anything. Every page renders independently at build time and there's no global to reach into. So settings has to be fetched and passed down. The clean way is a module-scope fetch:
// src/lib/settings.ts
import { sanityClient } from 'sanity:client';
export const settings = await sanityClient.fetch(`
*[_type == "siteSettings"][0]{
siteName, titleTemplate, defaultDescription, defaultOgImage
}
`);
ES modules evaluate once and cache, so that's one network request for the entire build no matter how many pages import it. Import it directly in your layout and siteName disappears from every page's prop list entirely. As close to ACF's ambient behaviour as Astro gets.
Only wrinkle: because it's module-cached, changing content in Sanity won't show up in astro dev until you restart it. You'll notice that once and then always know.
Studio: step 5 — the live snippet preview
Optional, thirty lines, and it's the single highest-impact thing in this whole guide for client happiness.
// components/SeoPreview.tsx
import { useFormValue } from 'sanity';
import { Card, Stack, Text } from '@sanity/ui';
export function SeoPreview() {
const metaTitle = useFormValue(['seo', 'title']) as string;
const pageTitle = useFormValue(['title']) as string;
const metaDesc = useFormValue(['seo', 'description']) as string;
const excerpt = useFormValue(['excerpt']) as string;
const slug = useFormValue(['slug', 'current']) as string;
const title = metaTitle || pageTitle || 'Untitled';
const desc = metaDesc || excerpt || 'No description set';
return (
<Card padding={4} radius={2}>
<Stack space={3}>
<Text size={1} muted>client.co.uk › {slug ?? ''}</Text>
<Text size={3}>{title}</Text>
<Text size={1}>{desc}</Text>
<Text size={0} muted>
Title {title.length}/60 · Description {desc.length}/155
</Text>
</Stack>
</Card>
);
}
Wire it in as a document view:
S.document().views([
S.view.form(),
S.view.component(SeoPreview).title('Search Preview'),
])
The editor now sees the actual Google result update live as they type — including the fallback chain resolving in front of them, so they can see what happens if they leave a field blank. The snippet preview is the one feature people genuinely miss when they leave WordPress, and it's an afternoon at most.
Astro: step 6 — the query, with fallbacks in GROQ
// src/lib/queries.ts
import groq from 'groq';
export const pageQuery = groq`{
"settings": *[_type == "siteSettings"][0]{
siteName, titleTemplate, defaultDescription, defaultOgImage
},
"page": *[_type == "page" && slug.current == $slug][0]{
title, body, _updatedAt,
"seo": {
"title": coalesce(seo.title, title),
"description": coalesce(seo.description, excerpt),
"ogImage": seo.ogImage,
"noindex": coalesce(seo.noindex, false)
}
}
}`;
coalesce() is doing all the work here. Editor leaves the meta title blank, it falls back to the page title. Blank description falls back to the excerpt.
Put that logic in the query rather than scattering ?? operators through your templates. One place to look, one place to change, and it's the piece that makes the whole thing feel like Yoast without you having built Yoast.
Astro: step 7 — image URLs
// src/lib/image.ts
import imageUrlBuilder from '@sanity/image-url';
import { sanityClient } from 'sanity:client';
const builder = imageUrlBuilder(sanityClient);
export function ogUrl(source) {
if (!source) return null;
return builder.image(source)
.width(1200).height(630)
.fit('crop').auto('format')
.url();
}
Returns an absolute CDN URL, so no new URL() wrapper needed. Remember that startsWith('http') check we put in the SEO component? This is why. It handles both cases without modification.
Astro: step 8 — render it
---
// src/pages/[...slug].astro
import Base from '../layouts/Base.astro';
import { sanityClient } from 'sanity:client';
import { pageQuery } from '../lib/queries';
import { ogUrl } from '../lib/image';
import { PortableText } from 'astro-portabletext';
export async function getStaticPaths() {
const pages = await sanityClient.fetch(
`*[_type == "page" && defined(slug.current)]{ "slug": slug.current }`
);
return pages.map((p) => ({ params: { slug: p.slug } }));
}
const { slug } = Astro.params;
const { page, settings } = await sanityClient.fetch(pageQuery, { slug });
const title = settings.titleTemplate.replace('%s', page.seo.title);
const image = ogUrl(page.seo.ogImage) ?? ogUrl(settings.defaultOgImage);
---
<Base
title={title}
description={page.seo.description ?? settings.defaultDescription}
image={image}
noindex={page.seo.noindex}
modified={page._updatedAt}
>
<PortableText value={page.body} />
</Base>
And there it is. The CMS is a data source, not a different architecture.
Astro: step 9 — sitemap from Sanity
At this point the sitemap integration becomes the weaker option, because it can't know about _updatedAt and it can't know about your noindex toggle.
// src/pages/sitemap.xml.ts
import type { APIRoute } from 'astro';
import { sanityClient } from 'sanity:client';
export const GET: APIRoute = async ({ site }) => {
const docs = await sanityClient.fetch(`
*[_type in ["page", "post"]
&& defined(slug.current)
&& !(_id in path("drafts.**"))
&& seo.noindex != true
]{ "slug": slug.current, _updatedAt, _type }
`);
const urls = docs.map((d) => {
const path = d._type === 'post' ? `blog/${d.slug}` : d.slug;
const loc = new URL(path, site).href;
return `<url><loc>${loc}</loc><lastmod>${d._updatedAt}</lastmod></url>`;
}).join('');
return new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`,
{ headers: { 'Content-Type': 'application/xml' } }
);
};
Get that namespace exactly right — http://www.sitemaps.org/schemas/sitemap/0.9. Search Console rejects the file outright if it's wrong, with an error that doesn't tell you why.
Drop the integration from astro.config.mjs if you go this route, or you'll ship two competing sitemaps.
Why this is worth the extra twenty lines: the noindex toggle in Sanity now controls the meta tag and sitemap inclusion, from one switch. Telling Google "don't index this" while simultaneously listing the page in your sitemap is the most common technical SEO fault on CMS-driven sites — Yoast and Rank Math both let you do it, because the two settings live in different places. Here it's impossible by construction. You couldn't create the contradiction if you tried.
That's the pattern to look for generally, by the way. Not "can I replicate the plugin," but "can I make the mistake structurally impossible."
5. Redirects
This is the one area where WordPress genuinely wins on convenience out of the box, so let's deal with it honestly.
In WP you install Redirection or use Rank Math's manager, and it works instantly because there's a PHP process intercepting every request with a database behind it. On a static site there's no process and no database. So redirects have to be compiled into something the host understands.
That sounds like a downside. It's mostly not — edge redirects are dramatically faster than a PHP lookup, and they work for paths that were never pages. But it does introduce one real behavioural difference you must tell your client about.
The Sanity document type
// schemaTypes/documents/redirect.ts
import { defineType, defineField } from 'sanity';
export const redirect = defineType({
name: 'redirect',
title: 'Redirect',
type: 'document',
fields: [
defineField({
name: 'source',
title: 'From',
type: 'string',
description: 'Old path, starting with a slash. e.g. /old-services',
validation: (R) => R.required().regex(/^\//, {
name: 'leading slash',
invert: false,
}).error('Must start with /'),
}),
defineField({
name: 'destination',
title: 'To',
type: 'string',
description: 'New path or full URL. e.g. /services',
validation: (R) => R.required(),
}),
defineField({
name: 'permanent',
title: 'Permanent (301)',
type: 'boolean',
initialValue: true,
description: 'Leave on unless this is genuinely temporary.',
}),
defineField({
name: 'note',
title: 'Why',
type: 'string',
description: 'Optional. Future you will thank present you.',
}),
],
preview: {
select: { title: 'source', subtitle: 'destination' },
},
});
That note field looks like padding. It isn't. Two years in you'll have sixty redirects and no memory of why half of them exist, and without a note nobody will ever dare delete one.
The prebuild script
// scripts/build-redirects.mjs
import { createClient } from '@sanity/client';
import { writeFileSync, readFileSync } from 'node:fs';
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET,
apiVersion: '2024-01-01',
useCdn: false,
});
const redirects = await client.fetch(`
*[_type == "redirect" && defined(source) && defined(destination)]{
source, destination, permanent
}
`);
const config = JSON.parse(readFileSync('./vercel.json', 'utf8'));
config.redirects = redirects.map((r) => ({
source: r.source,
destination: r.destination,
permanent: r.permanent !== false,
}));
writeFileSync('./vercel.json', JSON.stringify(config, null, 2));
console.log(`Wrote ${redirects.length} redirects to vercel.json`);
Hook it into the build:
{
"scripts": {
"build": "node scripts/build-redirects.mjs && astro build"
}
}
useCdn: false matters — you want fresh data at build time, not something cached from ten minutes ago.
The bit everyone gets wrong
Here's the two-stage thing, and it's worth being precise about because people describe it badly:
- The list is compiled at build time. Your script reads Sanity and writes
vercel.json. - Each redirect executes at the edge, per request. Vercel's router handles it before any function runs.
So performance is excellent. That's not the issue.
The issue is that a marketer publishes a redirect in Sanity and absolutely nothing happens until the next deploy. They'll test it, it won't work, and they'll conclude the system is broken.
Fix it with a Sanity webhook pointed at a Vercel deploy hook, filtered to the redirect document type:
Sanity → API → Webhooks → Create
URL: [your Vercel deploy hook URL]
Dataset: production
Trigger: Create, Update, Delete
Filter: _type == "redirect"
Publish a redirect, rebuild fires, live in about ninety seconds. Filtering on _type matters or every content edit on the site triggers a full rebuild.
And then tell the client, in writing: redirects take a minute or two to go live, not instantly. That one sentence prevents the support email.
Pros and cons, honestly
In favour:
- Executes at the CDN edge — faster than any PHP-based redirect, and it happens before your app is even involved
- Works for paths that never existed as pages, which plugin-based redirects often struggle with
- The list lives in the CMS, so marketing owns it without touching code
- It's in version control via
vercel.json, so you can diff it and see who broke what - No plugin, no database table, nothing to maintain or update
Against:
- Not instant. Needs a deploy. This is the real cost and there's no way around it on a static host
- You built it. There's no UI showing hit counts or 404 logs unless you add one
- Large lists get unwieldy — Vercel handles thousands fine, but nobody wants to scroll a flat list of 400 documents in Sanity without adding search
- No automatic 404 detection. Rank Math watches your 404 log and offers to redirect; here you'd wire that up yourself from analytics
The pattern that actually saves you: a slugHistory array on your page documents, appended automatically whenever a slug changes. Then old URLs redirect themselves and nobody has to remember to create a redirect at all. That's more work up front and deserves its own write-up, but it's the version that scales — because the failure mode of any manual redirect system is a human forgetting.
6. Caching and analytics on Vercel
Short section, because there's genuinely not much to say — and that's the point.
There is no caching plugin, because there's nothing to cache.
Worth understanding why, since it's the clearest illustration of what actually changed. WP Rocket, W3 Total Cache and the rest exist because WordPress rebuilds every page from the database on every single request. PHP boots, queries run, templates render, HTML is assembled — for every visitor, every time. A caching plugin's entire job is to save that HTML and serve it instead.
Astro builds the HTML at deploy time. It's already a file. Vercel puts it on a CDN. There's no rebuild to prevent, so there's no plugin to prevent it.
What you get automatically:
- Static HTML served from the edge, close to the visitor
- Hashed asset filenames (
app.a3f8c2.css) with immutable long-cache headers — so returning visitors re-download nothing, and cache invalidation is free because a change produces a new filename - Brotli compression, HTTP/2, the whole lot, with nothing to configure
If you want a specific header rule, it's a few lines in vercel.json:
{
"headers": [
{
"source": "/fonts/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
}
]
}
But you'll rarely need it. If you find yourself hand-tuning cache headers on a static Astro site, be suspicious — it usually means something's misconfigured elsewhere.
Analytics. Vercel's own are one line each:
npm i @vercel/analytics @vercel/speed-insights
---
import Analytics from '@vercel/analytics/astro';
import SpeedInsights from '@vercel/speed-insights/astro';
---
<Analytics />
<SpeedInsights />
Speed Insights gives you real-user Core Web Vitals, which is the number Google actually uses, as opposed to the Lighthouse score from your laptop on fibre.
If you want a proper dashboard, Plausible or Fathom are both a single script tag, GDPR-friendly, and don't need a cookie banner — which is a genuine saving in build time and one less thing to annoy visitors with.
Checklist
[ ] site set in astro.config.mjs
[ ] trailingSlash set explicitly
[ ] sitemap builds — check dist/ after npm run build
[ ] robots.txt references the sitemap index
[ ] every page has a unique title and description
[ ] canonical present and absolute
[ ] OG image absolute, 1200×630
[ ] tested in the Facebook debugger and LinkedIn Post Inspector
[ ] noindex pages excluded from the sitemap
[ ] redirect script wired into the build command
[ ] Sanity webhook firing the Vercel deploy hook
[ ] sitemap index submitted to Search Console
What this doesn't cover
Schema and JSON-LD. Deliberately left out — the entity graph is a bigger topic than a meta tag and deserves proper treatment. Short version: a @graph in the head holding Organization, WebSite and a page node, plus component-owned nodes in the body referencing it by @id. The advantage over a plugin is that a component emitting its own schema can't describe content that's been deleted, which centrally-generated schema absolutely can.
Automatic slug-history redirects. The slugHistory pattern mentioned above. It's the version that scales, and it's a separate write-up.
Generated OG images. Build-time per-page image generation via src/pages/og/[slug].png.ts. Nice to have, not essential, and easy to over-engineer.
Preferred sources
Follow my work in Google
Add chrisgood.online as a preferred source and my insights get pushed up your Google Search, News and Discover results.