Add a New Content Type

Space Madness is an opinionated way to stitch together a Sanity content editor and an Astro website. This guide will walk you through how to create a new content model that can be used as a Sanity definition, queried with Sanity's custom query language, and used in a type safe way in Astro.

Sanity has its own functions to help create their content models. We'll use those functions, along with our own to generate a few different objects that should™ result in type safety across our stack.

For this guide, we'll create a new "Person" object that we can use to connect to a post as an author.

Create a Content Model

In your editor, navigate to packages/content-models/src. Create a new file named person.ts

Paste in the following code:

packages/content-models/src/person.ts
import { defineField, defineType } from "sanity";
import { z } from "zod";
import * as S from "sanity-zod-types";

export const personSanityDefinition = defineType({
  name: "person",
  title: "Person",
  type: "document",
  fields: [
    defineField({
      name: "name",
      title: "Name",
      type: "string",
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: "slug",
      title: "Slug",
      type: "slug",
      options: {
        source: "name",
        maxLength: 96,
      },
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: "image",
      title: "Image",
      type: "image",
      options: {
        hotspot: true,
      },
    }),
    defineField({
      name: "body",
      title: "Body",
      type: "blockContent",
    }),
  ],
  preview: {
    select: {
      title: "name",
      media: "image",
    },
  },
});

We are creating a new "Person" object that has a title, slug, image, and a body for their bio. Sanity will consume this object definition and allow for the creation of new "Person" objects in Sanity Studio.

Let's hook up the definition to Sanity Studio. Most of our Sanity Schema data is orchestrated in the content-models index.ts file.

Open packages/content-models/src/index.ts and add import and export statements:

packages/content-models/src/index.ts
import * as person from "./person";
export * from "./person";

Ok, our model is available for import anywhere in our stack. Let's bring it into Sanity and create our first Person. Scroll a bit further down the page and add the Person Sanity definition to the sanitySchemaTypes object:

content-models/src/index.ts
export const sanitySchemaTypes = [
  concept.conceptSanityDefinition,
  post.postSanityDefinition,
  person.personSanityDefinition,
  resource.resourceSanityDefinition,
  resourceContent.resourceContentSanityDefinition,
  tag.tagSanityDefinition,
  blockContent.blockContentSanityDefinition,
  partial.partialSanityDefinition,
];

Space Madness will do some magic behind the scenes, so we'll also need to register the Person schema for a few other functions.

Scroll a bit further down the page and add a "person" literal to SanityLinkableType.

content-models/src/index.ts
export const SanityLinkableType = z.union([
  z.literal("concept"),
  z.literal("post"),
  z.literal('person'),
  z.literal("resource"),
  z.literal("tag"),
]);

English is a weird language. Since it's not easy to pluralize Person by adding "s" to it, we can tell our Space Madness templates how to pluralize our schema manually:

content-models/src/index.ts
sanitySchemaTypes
  .filter((schemaType) => schemaType.type === "document")
  .forEach((sanitySchema) => {
    switch (sanitySchema.name) {
      case "post":
        SanityTypeDisplayNames[sanitySchema.name] = ["Article", "Articles"];
        break;
      case "person":
          SanityTypeDisplayNames[sanitySchema.name] = ["Person", "People"];
          break;
      case "resourceContent":
        SanityTypeDisplayNames[sanitySchema.name] = [
          "Resource Content",
          "Resource Content",
        ];
      default:
        // Basic pluralization is to add "s" to the title
        SanityTypeDisplayNames[sanitySchema.name] = [
          sanitySchema.title ?? "No title",
          `${sanitySchema.title ?? "No title"}s`,
        ];
        break;
    }
  });

That's all you need to do. Sanity will register your new type, and we'll have some nice features setup for us automatically.

If you haven't started your dev server yet, open a terminal at the root of your project and run turbo dev. The console will output your Sanity Studio URL that you can click to open.

Try adding a new person and clicking "Publish" to make your data public and queryable. If you'd like to learn more about how to use Sanity Studio, check out their .

Query Your Content Model

Our shiny new data is now sitting in the Sanity Content Lake. We will need to query our new person object using GROQ, the custom query language for Sanity.

Thankfully, Space Madness comes with enough queries where you should be able to copy/paste your way to success in most cases.

Let's build a query that will get all existing People documents. This can act as a directory of who writes content for our site.

In our Astro site, navigate to the queries folder and create a new file named person.ts. Add in the following code:

site-astro/src/queries/person.ts
import { groq, useSanityClient } from "astro-sanity";
import { blockContentQuery } from "./partials/blockContent";

export async function getAllPeopleList() {
  const query = groq`*[_type == "person"] | order(_createdAt desc) {
    name,
    slug,
    ${blockContentQuery}
  }`;

  const data = await useSanityClient().fetch(query);

  return data;
}

This is a basic query. We're getting all objects by type "person", then getting the name, slug, and body (using a string partial to get all the fancy text content).

The one problem you may have noticed: this data is not typed! data is any right now, which can cause some serious issues if the shape of our data changes in the future.

We can fix this! It's a bit of extra work, but Space Madness includes examples and helpers to make this a bit easier.

Let's re-open our content model person.ts file and create a object to match the Sanity definition.

content-models/src/person.ts
import { defineField, defineType } from "sanity";
import { z } from "zod";
import * as S from "sanity-zod-types";

export const personSanityDefinition = defineType({
  name: "person",
  ...
});

export const Person = S.Document.extend({
  name: S.String,
  slug: S.Slug,
  image: S.Image.nullable(),
  body: z.any().nullable(), // Zod will not validate Portable Text
});

Each attribute in our Person Zod object will map to the Sanity definition above it. These two models are co-located so you can remember to keep them in sync as your data changes.

One more thing to do before we close this file. Zod is able to generate Typescript types for us automatically. Add this line to squeeze that type safe goodness out of our Zod object.

content-models/src/person.ts
export type Person = z.infer<typeof Person>;

Great! We now have a unified model that can be used for Sanity structured objects, Zod runtime parsing, and Typescript hints while developing.

Let's bring that type safety over to our query. Replace your query's file contents with this:

site-astro/src/queries/person.ts
import { groq, useSanityClient } from "astro-sanity";
import { Person } from "content-models";
import { z } from "zod";
import { blockContentQuery } from "./partials/blockContent";

export async function getAllPeopleList() {
  const query = groq`*[_type == "person"] | order(_createdAt desc) {
    name,
    slug,
    ${blockContentQuery}
  }`;

  const PersonResult = z.array(
    Person.pick({
      name: true,
      slug: true,
      body: true,
    })
  );

  const data = await useSanityClient().fetch(query);

  try {
    return PersonResult.parse(data);
  } catch (error: any) {
    throw new Error(`Error parsing getAllPeopleList, \n${error.message}`);
  }
}

Here's what we're doing now:

  1. Build the GROQ query to get the name, slug, and body content
  2. Create a new Zod object as an array of all Person objects that only have the name, slug, and body content.
  3. Run the query to get an untyped data object.
  4. Parse that data against our expected structure and return it. Throw an error if there is a mismatch.

We had to do a bit of extra work to ensure type safety, but it will be worth it when you don't wake up in a cold sweat at 3AM wondering if the data structure has changed.

There's also type hinting if you're into that sort of thing.

Speaking of, let's see that type hinting in action.

Render a page of People

Let's bring our data to life on our Astro site. Create a new page for our people directory and add the following code:

site-astro/src/pages/people/index.astro
---
import Layout from "@/layouts/Layout.astro";
import PortableText from "@/components/PortableText/PortableText.astro";
import { getAllPeopleList } from "@/queries/person";
import { Debug } from "astro/components";

const people = await getAllPeopleList();
---

<Layout>
  <header class="prose-width content-container">
    <h1 class="h2">People</h1>
  </header>

  <main
    class="content-container prose-width flex flex-col gap-12"
    data-pagefind-ignore
  >
    <Debug {people} />
  </main>
</Layout>

Navigate to the /people route in your browser and you should see a nicely formatted Debug block that will help you build out the rest of your UI.

Let's see our type safety in action. Try to type out a JSX map function to print out the name and body of each person. You should see your editor provide type hints to ensure your markup maps to your verified data structure.

{
  people.map((person) => {
    return (
      <div>
        <h4>{person.name}</h4>
        <PortableText value={person.body} />
      </div>
    );
  })
}

Look at that. We're type safe, we're validating our data, and we're acknowledging the fake people we made up to work through this tutorial.

We salute you Test Testerton 🫡.

If you'd like to, now would be a good time to create a /people/[slug].astro file, where each person would have their own page. This is helpful for backlinking functionality.

Create a Slug Route

Create a new file: pages/people/[slug].astro

This bracket syntax will tell Astro to build a new page for each slug we provide to the page's function.

Our Astro site will build all pages during the build phase, before deployment. In order to build all possible Person pages, we need to query all People and build an array of objects with our slug params. Here's what it will look like:

site-astro/src/pages/people/[slug].astro
---
import PortableText from "@/components/PortableText/PortableText.astro";
import Layout from "@/layouts/Layout.astro";
import { getAllPeopleList } from "@/queries/person";
import type { InferGetStaticPropsType } from "astro";

export async function getStaticPaths() {
  const data = await getAllPeopleList();
  const staticProps = data.map((person) => {
    return {
      params: {
        slug: person.slug.current,
      },
      props: {
        person: person,
      },
    };
  });

  return staticProps;
}

type Props = InferGetStaticPropsType<typeof getStaticPaths>;

const { person } = Astro.props as Props;
---

<Layout>
  <header class="prose-width content-container">
    <h1 class="h2">{person.name}</h1>
  </header>

  <main
    class="content-container prose-width flex flex-col gap-12"
    data-pagefind-ignore
  >
  <PortableText value={person.body} />
  </main>
</Layout>

The important bits:

  1. Astro expects getStaticPaths() to return an array of objects with params and props attributes.
  2. params will map to the dynamic sections in our file name: slug => [slug]
  3. props will map to the data that is available in the rest of the file, just like a normal component
  4. InferGetStaticPropsType gives us that Typescript goodness 😎

Now we have a page for each person, so Mr Testerton can head over to /people/test-testerton to see their bio.

Build those URLs

There's one more thing we need to do to button up our new Person creation. If another document references Test Testerton, that page can automatically build a link to our Person. Believe it or not, we've done most of the work to achieve this already, we just need to update our url.ts script to match our new Astro route(s).

Make this update and you're done!

site-astro/src/lib/url.ts
import type { SanityLinkableType } from "content-models";

// This funciton should be kept in sync with the
// Astro/pages directory and the "SanityLinkableType" union
// in packages/content-models/src/index.ts

export function getUrlForSanityType(type: SanityLinkableType, slug: string) {
  switch (type) {
    case "post":
      return `/blog/${slug}`;
    case "concept":
      return `/concepts/${slug}`;
    case "tag":
      return `/tags/${slug}`;
    case "resource":
      return `/resources/${slug}`;
    case "person":
      // If you added the people/[slug].astro route
      return `/people/${slug}`;
      // If you want to only use a directory page
      return `/people/`;
    default:
      throw new Error(`URL cannot be created for type: ${type}`);
  }
}

Where to from here?

One thought you may have while working through this:

This all makes sense for single data sources, but what if I want to combine an Article with a Person to show a post author?

That's what we'll tackle in our next guide, Combining Content Models .

See you over there, Space Cowboy 🧑‍🚀🤠

Backlinks