Hard coding document slugs in Sanity.io with validation rules can save you a lot of double-handling on both front and back end.
View in Sanity Studio2021-04-19
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.
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 slug
s 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: { source, 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` }
_type
with the slug
, in the Document Preview, or in your resolveProductionUrl helper function, or anywhere else!slug.current
gives you the full path of the page.And, if your documents are being re-used for projects other than a single website, just add another slug
field!
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.
pages/index.jspages/[slug].jspages/news/index.jspages/news/[slug].js
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:
pages/[[...slug]].js
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 = pageQueries.map((slug) => ({ 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.
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.slugArray
and depending on its composition, send the right query to the document.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 { docType, queryParams, query: docQuery[docType], };}
So now, we're:
getStaticProps
to our component to render the pageThe 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]].jsimport 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} />} </Layout> );}
<Layout />
and Sanity Preview once in one file.There are potentially some drawbacks to this method of using catch-all routing. But I haven't found them yet.