Movable type slugs

Patterns for Next.js,, Catch-all Routes and Slugs

Hard coding document slugs in with validation rules can save you a lot of double-handling on both front and back end.

View in Sanity Studio


I'm 100% sold on Next.js over Gatsby. It's not that Next.js is perfect, it just feels much more like that's where the puck is going. The speed and quality of releases just feels higher. And the feature set is broader.

I also feel like for anything outside of simple static pages Gatsby still will give you one experience in development and surprise you with random behaviour in production.

Sanity's already my CMS of choice. Even if Sanity "hate being compared to headless CMS", it's an excellent headless CMS.

With anything in programming there are so many different ways to achieve the same thing. So the patterns I'm outlining here are filed under "Works For Me". Not to be considered "Correct" or "Best Practice". Got thoughts? Let me hear them.

Slugs in Sanity

Using Sanity correctly is to model content types, not simply the pages of your website. However, the existence of a "Slug" field type infers a relationship between a Document in Sanity and a Page on a website.

Slugs are basically human-readable ID's. They should be unique. But there's not really a "Wrong" way to store them. Including adding / characters.

In its simplest form, your slug field is likely just a slugified version of the Document title. Depending on the structure of your website, you may then add prefixes to that slug, depending on the document _type.

With no extra configuration, your _type, title and slug field data will look something like this:

_type: `page`,
title: `Hello World`,
slug: { current: `hello-world` }
_type: `article`,
title: `Annual Report`,
slug: { current: `annual-report` }

These document's slugs aren't indicative of the final pathname on your website. So anywhere you plan to turn these into URLs, you'll run logic to prefix the document slug with the full URL on the website.

_type ===`article`?`/news/${slug.current}`:`/${slug.current}`;

But you can imagine how quickly this breaks down the more _types produce pages on the website. Or if you want the pathname to use something other than the _type. The number of times you re-run this logic quickly adds up too.

Instead, let's bake the pathname's prefixes into the slug.current. Because everything in Sanity is "Just JavaScript" that's as simple as some validation rules and a helper function. Here's how my documents's first few fields look:

fields: [
{ name: "title", type: "string" },
slugWithType(`news`, `title`),
// ...and so on

The slugWithType function takes two arguments. The prefix to put before the document's slug, and the field which the generate key will use.

import slugify from "slugify";
function formatSlug(input, slugStart) {
const slug = slugify(input, { lower: true });
return slugStart + slug;
export function slugWithType(prefix = ``, source = `title`) {
const slugStart = prefix ? `/${prefix}/` : `/`;
return {
name: `slug`,
type: `slug`,
options: {
slugify: (value) => formatSlug(value, slugStart),
validation: (Rule) =>
Rule.required().custom(({ current }) => {
if (typeof current === "undefined") {
return true;
if (current) {
if (!current.startsWith(slugStart)) {
return `Slug must begin with "${slugStart}". Click "Generate" to reset.`;
if (current.slice(slugStart.length).split("").includes("/")) {
return `Slug cannot have another "/" after "${slugStart}"`;
if (current === slugStart) {
return `Slug cannot be empty`;
if (current.endsWith("/")) {
return `Slug cannot end with "/"`;
return true;

Now our previous documents write slugs like this:

_type: `page`,
title: `Hello World`,
slug: { current: `/hello-world` }
_type: `article`,
title: `Annual Report`,
slug: { current: `/news/annual-report` }

Benefits of this approach

  1. No more repeating logic to combine the _type with the slug, in the Document Preview, or in your resolveProductionUrl helper function, or anywhere else!
  2. Reference fields are now actual links. No need to repeat that same logic again on the front end. Querying a document for its slug.current gives you the full path of the page.
  3. Relationship between Documents and Web Pages are now explicit.
  4. Sanity is now the single source of truth for all pathnames.

And, if your documents are being re-used for projects other than a single website, just add another slug field!

Slugs in Next.js

Next.js's file based routing is designed to give explicit control over what is displayed on any given path. And it may be tempting to create a folder for each schema type with prefixed pathnames, and a [slug].js file within each folder.


But for your average brochure website, this level of control is not worth the code duplication. Each one of these files likely needs to query for the same information. Header and Footer content, SEO details, etc. Then there's Sanity's usePreviewSubscription hook which is a whole lot more syntax to include in each file.

Instead, let's create every page on the website from one file:


This is a "Catch all route". And in it, depending on your particular schema, we can query every page and create every path, all from one query.

// pages/[[..slug]].js
export async function getStaticPaths() {
const pageQueries = await getClient().fetch(
groq`*[_type in ["homePage", "page", "article"] && defined(slug.current)][].slug.current`
// Split the slug strings to arrays (as required by Next.js)
const paths = => ({
params: { slug: slug.split('/').filter((p) => p) },
return { paths }

(Before changing all my documents slugs to have complete pathnames I had such a wonderful looking switch statement to cleverly loop over different types and object keys to create paths. Now, that's all gone!)

Because the only data we can send from getStaticPaths to getStaticProps is the slug array, this is the time where we'll need to do some detective work to match each slug with a GROQ query to fetch that page's required data.

// pages/[[..slug]].js
export async function getStaticProps({ params }) {
const client = await getClient();
// Every website has a bunch of global content that every page needs, too!
const globalSettings = await client.fetch(globalSettingsQuery);
// A helper function to work out what query we should run based on this slug
const { query, queryParams, docType } = getQueryFromSlug(params.slug);
// Get the initial data for this page, using the correct query
const pageData = await client.fetch(query, queryParams);
return {
props: {
data: { query, queryParams, docType, pageData, globalSettings },

The getQueryFromSlug function is where things get hairy, so I've only inserted a simplified version of it below. The more _type's you have, the more complex this function gets. But it should be the only place where you have to do this slug-array-to-query matching.

  1. docQuery contains the queries we need to give each page its data. Because your actual GROQ queries are likely to be extended to include references and such, I'd store these in a separate file and import them as variables.
  2. Switch over the slugArray and depending on its composition, send the right query to the document.
  3. Send along a variable called docType to load the correct Component.
function getQueryFromSlug(slugArray = []) {
const docQuery = {
home: groq`*[_id == "homePage"][0]`,
news: groq`*[_type == "article" && slug.current == $slug][0]`,
page: groq`*[_type == "page" && slug.current == $slug][0]`,
if (slugArray.length === 0) {
return {
docType: "home",
queryParams: {},
query: docQuery.home,
const [slugStart] = slugArray;
// We now have to re-combine the slug array to match our slug in Sanity.
let queryParams = { slug: `/${slugArray.join("/")}` };
// Keep extending this section to match the slug against the docQuery object keys
if (docQuery.hasOwnProperty(slugStart)) {
docType = slugStart;
} else {
docType = `page`;
return {
query: docQuery[docType],

So now, we're:

  1. Querying every Sanity document that will create a page
  2. Finding the correct GROQ query for that page's data, based on each document's slug
  3. Sending that query from getStaticProps to our component to render the page

The query is passed along from getStaticProps to the Page because of how Sanity's usePreviewSubscription needs to re-use it. And that is another whole blog post in itself!

Here's what our final Page component looks like (again, cut down to the bare basics for this demo). See how we re-use the query as passed-in from getStaticProps to populate the preview.

Then, depending on the docType set in our getQueryFromSlug function, we selectively import the correct Component for that page layout. It's important to note the dynamic() imports here. If the components were not dynamically imported, they would be automatically bundled into each Page. Blowing out our bundle size! (Which, too, is another whole blog post).

// pages/[[..slug]].js
import dynamic from "next/dynamic";
const PageSingle = dynamic(() => import("../components/layouts/PageSingle"));
const NewsSingle = dynamic(() => import("../components/layouts/NewsSingle"));
export default function Page({ data, preview }) {
const { data: pageData } = usePreviewSubscription(data?.query, {
params: data?.queryParams ?? {},
initialData: data?.pageData,
enabled: preview,
const { globalSettings, docType } = data;
return (
<Layout globalSettings={globalSettings}>
<Seo meta={pageData.meta} />
{docType === "home" && <PageSingle page={pageData} />}
{docType === "page" && <PageSingle page={pageData} />}
{docType === "news" && <NewsSingle post={pageData} />}

Benefits of this approach

  1. Setting up our Global data, <Layout /> and Sanity Preview once in one file.
  2. Keeping the relationship between Slugs and Page queries to one helper function.

There are potentially some drawbacks to this method of using catch-all routing. But I haven't found them yet.