Alt textAlt text

Ultimate Sanity SEO best practices

Reading Time

11 min read

Published on

April 12, 2024











We started this article because we noticed that the same old points come up every time somebody talks about SEO with Sanity — use meta-titles, implement a live preview, design open graph images etc. We're a little guilty of doing this ourselves.

However, what we haven’t seen, is a fully fledged, highly technical article full of real world examples and actual code, not just surface-level hodgepodge.

In this article, we have pieced together all our Sanity SEO related best practices and the ways to implement them. We start with the basics, but if you're impatient, you can skip to the hard stuff.

The easy stuff


🚨 If you're a dev, make sure to read this paragraph and really take it in before you start. This underpins all of our schema and is extremely important.

I want to talk about fallbacks, because this is our opinionated method of handling SEO and Sanity schema in general. We build all of our documents with a title and description. Not meta, not open graph, just title, just description.

Why do we do this? Well it's because it's our fallback. Meaning we can throw a required validation in our Sanity schema, and it's the bare minimum required to generate a document. This also means that whenever we are generating our meta titles and meta descriptions we can use this as our rock bottom, lowest effort version of the page.

Why is this important? Well we believe in content velocity. Emphasis in velocity. If you ask an author to fill out: a title, a description, a meta title, a meta description, an OG title and OG description... You'll never get anything done. So how do we get round this? We ask the bare minimum of the editor, to add a title and a description.

This is especially useful for developers, especially those using Typescript, because you won't have to use a hell of a lot of conditionals throughout your code, you always know that if a client hasn't setup a meta title, it'll fallback to a title, so no sweat.

How we structure this? See below.

Meta titles

metaTitle else title

This one is a little different in comparison to the rest, because you may realise there isn't a <meta title="..."/>. However, you may want to use the title when referencing this as a card from another page. E.g:

An illustration of a blog post using the title for the card pulling through from a different page

Meta descriptions

metaDescription else description

Open graph titles

ogTitle else title

Open graph descriptions

ogDescription else description
It makes perfect sense right? That's why we use it. Don't overcomplicate it.

How many titles & descriptions?

By now you're probably wondering why there's so many titles and descriptions of varying natures. Surely an editor isn't going to fill out all of these, surely this is a huge waste of time... Bingo. An editor isn't going to waste time filling all of these out. However, they will care if their article hits tens of thousands of views and the traffic starts converting to sales. That's the reason you do this. Then and only then, is when you tweak, change and optimise and it's worth putting the time in.

The other benefit of doing this is the ability to standardise your SEO code across all page types. If you standardize, you can make it DRY (don't repeat yourself) and spend less time building page types in the future. More predictability when pull meta data, faster dev, better website.

Alt texts

Think about this for a second, you're a person with a visual impairment, you're on a website and you want to know what's going on... Lucky for you, somebody set the alt text to: "Stock Image"...

As you can imagine this doesn't actually help with understanding what is going on in the image. Is it a "swan flying gracefully in the sky" or maybe a "cat clinging on to a branch", with text beneath reading "hang in there"?

Whatever it is, adding an alt text field and pulling that alt text into your image components — can help your folks who use screen readers and you get some free good karma as part of the process.

If you want a nice and simple method of doing this on your images, you can use the fields on an image schema. Check it out in our examples below.

Simple version of Sanity Image

This is the barebones with the alt text field attached

{ name: 'image', title: 'Image', type: 'image', // Add the alt tag to this as an additional field fields: [ { name: 'alt', type: 'string', title: 'Alt Text', }, ], },

Fancier version with alt, required & captions

This is the "all the trimmings" version, aim for this level of fidelity

import { defineField } from 'sanity'; // Import of the define field // Include the definefield so it tells you // When you screw up. Cheers Typescript. defineField({ name: 'image', title: 'Image', type: 'image', validation: (Rule) => Rule.required(), fields: [ { title: 'Alt Text', name: 'alt', type: 'string', description: "An alternative text for folks with screen readers. Describe what is in the image", }, { title: 'Caption', name: 'caption', type: 'string', description: "A caption that sits below the image for context", }, ], options: { // Add the hotspot to allow the client to // choose which parts of the image will get cropped hotspot: true, }, }),

Want to learn more about handling images within Next.js and Sanity? We've got a whole article about what we think is the best Next.js image component.

Indexing, or not indexing

There will be times when certain pages need not be indexed by the search engine, so to let the editor control which pages don't get indexed, we make a boolean field like this. If you want to avoid a lot of pain in the future, make sure you set the initial value as false:

Do not index schema

{ name: 'seoHideFromLists', title: 'Hide from lists', description: "If checked, this content won't appear in any list pages. A user will still be able to visit the page if they know the exact URL", type: 'boolean', initialValue: () => false, }

Here's a visual of it inside the studio, simple right?

Do not index page toggle

Bonus tip: "Hide from lists"

So no-index is great for avoiding Google indexing the page. However, there may be times when you want to show somebody a preview of a page who is not a Studio member. That's where our "Hide from lists" toggle comes into play.

{ name: 'seoHideFromLists', title: 'Hide from lists', description: "If checked, this content won't appear in any list pages. A user will still be able to visit the page if they know the exact URL", type: 'boolean', initialValue: () => false, group: CONST.SEO, }

Then for your GROQ query, you want something along the lines of:

*[_type == "blogPost" && !seoHideFromLists]{ // Then whatever you want in here }

Improving your site structure

So start with all your pages and content you want to cover, and simply break them down into a hierarchal list indented. The indentations are very important, so don't dismiss it as though I've lost my marbles. You can go into a tool like Apple Notes and just dump a very lo-fi top level of your content types. E.g blogs, projects, services etc

⚠️ Remember: this is a speed-run so don't sweat this part too much. I'll share a rough idea of ours.
A list of our data in a visual hierarchy

Feeding lo-fi sitemap into Figjam

If you don't know what Figjam is, it's not a preserve, it's basically Figma's answer to Miro (an interactive multiplayer whiteboard). What's really cool about it though is that you can use it for free, and you can collaborate with your team in a very low-friction way.

So here's what we're going to do, we're going to take the above indented list, and we're going to feed it into figma and ask it to return a sitemap using the Visual Sitemap plugin.

What you'll get is a rough outline of your sitemap in a hierarchy. These are great for planning in the most basic way, how you want your content URLs to look. For example:

A sitemap of a  rough, top level representation of how our blogs are structurally laid out

Structured data

Next.js introduces a versatile Metadata API for enhancing SEO and shareability, we do have a helper function that generates dynamic content-specific metadata.

// lib/seo.ts export const getOgImage = ( seoImage?: ImageProps, image?: ImageProps, options?: { title?: string; type: string; id: string; }, ) => { if (seoImage) return urlFor(seoImage) .width(ogImageDimensions.width) .height(ogImageDimensions.height) .url(); const { title, type, id } = options ?? {}; const params = new URLSearchParams({}); if (title) params.set('title', title); if (id) params.set('id', id); if (type) params.set('type', type); if (image) params.set('image', urlFor(image).url()); return 'api/og?' + params.toString(); }; export const getMetaData = (data: any): Metadata => { const { _id, _type, seoDescription, seoImage, seoTitle, slug, image, ogTitle, ogDescription, title, description, } = data; const meta = { seoTitle: seoTitle ?? title ?? '', seoDescription: seoDescription ?? description ?? '', ogTitle: ogTitle ?? title ?? '', ogDescription: ogDescription ?? description ?? '', }; const ogImage = getOgImage(seoImage, image, { title: meta.ogTitle, type: _type, id: _id, }); const metadata: Metadata = { title: meta.seoTitle, metadataBase: new URL(baseUrl), description: meta.seoDescription, creator: 'Roboto Studio', authors: { name: 'Roboto Studio', }, openGraph: { type: 'website', countryName: 'UK', description: meta.seoDescription, title: meta.seoTitle, images: [ { url: ogImage, width: ogImageDimensions.width, height: ogImageDimensions.height, alt: meta.seoTitle, secureUrl: ogImage , }, ], url: getPageUrl(slug?.current), }, }; return metadata; };

this function is then used on pages like this.

// /page.tsx export const generateMetadata = async ({ params, }: LocaleParams): Promise<Metadata> => { const { locale } = params ?? {}; const data = await getHomePageData(locale); if (!data) return {}; return getMetaData(data); };


We've built a sophisticated solution for managing redirects, utilizing Sanity as our content infrastructure. This approach enables us to store and dynamically fetch redirect rules within our Next.js applications. It offers flexibility and scalability, allowing us to update and maintain our redirects efficiently, without the need for hard-coded adjustments or deployments.


Here's how we pull it from inside of next.config.js. Just be aware, this can be pretty destructive if you screw it up, so I would highly recommend creating validation so you don't end up nuking your own website.

// next.config.js const redirectDocs = `*[_type == 'redirects'][0].redirects` module.exports = { async redirects() { const docs = await sanityClient.fetch(redirectDocs); return{ destination, source, permanent }) => ({ destination, source, permanent:!!permanent, })); }, }


We are using the simplest possible solution to generate a sitemap on the fly, not just that, but it's typesafe as well, kudos to Next.js docs here

import { MetadataRoute } from 'next'; import { baseUrl } from '~/config'; import { getClient } from '~/lib/sanity'; import { SitemapProjection } from '~/types'; type SiteMap = Pick< MetadataRoute.Sitemap[number], 'changeFrequency' | 'priority' >; const formatToSitemap = ( data: SitemapProjection[], options: SiteMap, ): MetadataRoute.Sitemap => { return{ _updatedAt, slug }) => ({ url: `${baseUrl}/${slug.split('/').filter(Boolean).join('/')}`, lastModified: new Date(_updatedAt), ...options, })); }; export const sitemapQuery = groq` *[_type in $types && defined(slug.current) && seoNoIndex != true ]{ "slug":slug.current, _updatedAt, _type, _id }`; export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const client = getClient(); const slugPages = await client.fetch(sitemapQuery, { types: [ 'mainPage', 'page', 'blog', 'product', 'categoryIndex', 'blogIndex', ], }); const formattedSitemaps = formatToSitemap([...slugPages], { changeFrequency: 'weekly', priority: 1, }); return [...formattedSitemaps]; }

Harder stuff

Organizing all the above

Sanity allows us to have groups in the editor layout. With something like this, we can avoid doom scrolling to find relevant content pieces when you need to edit something quickly. Here's a quick video that helps understand this better:

Here's a code example on how we can group fields for a page.ts document.

export const page = { name: 'page', title: 'Page', type: 'document', groups: [ { title: 'Main Content', name: 'mainContent', }, { title: 'SEO', name: 'seo', }, ], fields: [ { name: 'name', title: 'Name', type: 'string', group: 'mainContent', }, { name: 'slug', title: 'Slug', type: 'slug', group: 'mainContent', }, { name: 'email', title: 'Email', type: 'string', group: 'mainContent', }, { name: 'seoTitle', title: 'SEO Meta Title Override', type: 'string', group: 'seo', }, { name: 'seoDescription', title: 'SEO Meta Description Override', type: 'text', group: 'seo', }, ], }

Generating open graph images

Asking to create an open graph image for each page on the website is a great way to piss off your designer. It's best to follow a set template for a group of pages and automate the image generation with code. That way nobody is waiting in line to get things done.

For this to work, we will be using Next.js's in-built function ImageResponse().

We will be going with a simple API route which will generate the image for us, but to understand how we usually structure it, here's a simple diagram explaining the whole setup.

A diagram explaining the whole sequence of events for generating an image

The API route will get the relevant information through query parameters so that information like name, date etc are up to date on the generated image.

Abstracting away the getOgImage seems unnecessary at first, but when your site grows, all the pages need not follow the same template for the image ex: blogs, team etc. So, you can sort out your parameters in this function and call the relevant methods within the API.

And here's the code for the API route:

import { ImageResponse } from 'next/og'; const genImage = async ({ title }: any) => { return new ImageResponse( ( <div tw="flex h-full w-full flex-col items-center justify-center bg-violet-900 text-white py-16 pl-16"> <h1 tw="text-6xl mb-1 text-center">{title}</h1> </div> ), { width: 1200, height: 630, }, ); }; export function GET({ url }: Request) { const { searchParams } = new URL(url); const params = Object.fromEntries(searchParams.entries()); try { return genImage(params); } catch (error: any) { return new Response(error.message, { status: 500 }); } }

And with that, we can test it on the browser to check if it's working like this:

Screen shot of testing the image generation

Adding a character count & limit to one of your components

Did you know that google shows only 160 characters in the meta description and trims out the rest? so it's best to know when you are about to reach the limit.

// in /schema/author.tsx or .jsx import {Stack, Text} from '@sanity/ui' function MyStringInput(props: any) { return ( <Stack space={3}> {props.renderDefault(props)} <Text size={1}>Characters: {props.value?.length || 0}</Text> </Stack> ); } export const author = { name: 'author', title: 'Author', type: 'document', fields: [ // same schema from above { name: 'seoDescription', title: 'SEO Meta Description Override', type: 'text', group: 'seo', validation: (Rule: any) => Rule.min(50).max(160).warning("The SEO Meta Description should be between 50 and 160 characters."), components: { input: MyStringInput }, }, ], };

The above code gives a warning sign beside the title of the component if the character count either exceeds 160 characters or is less than 50 characters. It also shows a character counter right below the input field so that you can keep track of it. We are using Sanity's own UI kit which comes with it so that the UI looks in sync with their design system.

Pretty tricky stuff

Generating FAQ JSON-LD on the fly

Let's start by saying - generating JSON-LD is a pain in the ass. With that said, why don't we just handle it programmatically? Well that's because it's an even greater pain in the ass. Especially with Typescript (more on that later). So that's why we've included our code below so you can rip it off and pretend it's yours and impress your boss. Good on you.

So how do we make Typescript happy? Luckily Google actually built a typescript package to do exactly that. It's JSON-LD with all the nicieties of typesafety.

Here's how we structure our FAQs into a JSON-LD friendly data structure. All you need to do now is bring your own FAQs. We'd recommend building them as a Document rather than a Block for the sake of reusability. Thank us later.

import { FAQPage, WithContext } from 'schema-dts'; import { Faqs } from '~/types'; const getJsonFaqs = (faqs: Faqs) => { const faqJson: WithContext<FAQPage> = { '@context': '', '@type': 'FAQPage', mainEntity: faqs?.map((faq) => ({ '@type': 'Question', name: faq.question, acceptedAnswer: { '@type': 'Answer', text: portableRichTextToHtml(faq.answer), }, })), }; return faqJson; };

and now using faq JSON-LD data from above inside FaqsBlock for our pageBuilder

export const FaqsBlock: FC<FaqBlockProps> = ({ faqs, title, richText }) => { const jsonFaqs = getJsonFaqs(faqs); return ( <section className="relative my-16 px-4"> {/* Faqs Block Design Code */} <script type="application/ld+json" dangerouslySetInnerHTML={ { __html: JSON.stringify(jsonFaqs) } } /> </section> ); };

Internal linking is a powerful strategy to boost the SEO performance of your website as well as link related contents. However it can be time consuming having to look through your articles and cherry pick content...

Wouldn't it be easier if it was entirely programmatic so you don't even need to worry? We've got you covered.

Here's a way in JSON-LD that you can use to describe multiple posts related to the article.

{ '@context': '', '@type': 'Article', name: 'Article title', url: '', datePublished: new Date().toISOString(), dateModified: new Date().toISOString(), description: 'Article description, keep it short and sweet', hasPart: [ Article) => ({ '@type': 'Article', name: blog.title, url: '', datePublished: new Date(blog._createdAt).toISOString(), dateModified: new Date(blog._updatedAt).toISOString(), description: blog?.description ?? 'Placeholder description', })), ], }

You got the JSON-LD sorted, but what about the actual related articles? Well, if you have categorised your articles with tags, then we can write a groq query to fetch the latest 3 articles under the tag and filtering out the present article:

*[_type == "article" && count((categories[]->_id)[@ in $categories]) > 0 && slug.current != $slug] | order(date desc) [0...3]{ "categories": categories[] -> title, title, _id, description, "slug":slug.current, image }

Bonus: Google indexing script

This is not so much something that applies to Sanity but it's definitely worth knowing about if you're just starting with SEO and you're trying to get your pages indexed. A GitHub user called Goenning created google-indexing-script. For reference, we did use this ourselves, and we found it indexed around 30 extra pages that it wasn't already.

Indexed pages for Roboto Studio


With all of the above, you should be well on your way to climbing through the ranks with just technical SEO alone, but I want to finish off with some heartfelt advice from our own experience of writing content for a fairly decent amount of time.

Go write. We read far too much doom-mongering and SEO black-hatting – it's brain-numbing. By far our most popular articles are scarcely related to on-trend topics, but at the end of the day, some of the articles we're most proud of, probably had double digits readership.

With that being said, you're probably looking for a silver bullet, or some ultimate wisdom about SEO... The reality is, most people don't know what the hell is going on with algorithm changes and sudden de-indexes, so don't take it as life and death. Just focus on writing content you would actually want to read, and hope to god the Google overlords decide to give you traffic.

Either that or just put a graph with exponential growth on it. That seems to work too...

A graph exploding with a skyrocketing line, for the 2 people that read the article
Get in touch

Swayed by the graph?

We thought you might be.

Here's the sales pitch: you shouldn't have to focus on your technical SEO, it should be set and forget. We'll bring the technical, you bring the awesome content.



Like what you see ?

Sign up for a 30 min chat and see if we can help

© 2024 Roboto Studio Ltd - 11126043

Roboto Studio Ltd,

86-90 Paul Street,

London, EC2A 4NE

Registered in England & Wales | VAT Number 426637679