DEV Community

Cover image for Creating a Site Builder with NextJS and Sanity CMS
TimJ
TimJ

Posted on • Edited on • Originally published at timjames.dev

Creating a Site Builder with NextJS and Sanity CMS

Using a headless CMS provides developers with the flexibility they need, and exposes a user-friendly interface to clients that allows them to manage their own content. In this post, I'll go through the configuration of an SEO optimised app backed by a site builder with a realtime editor preview, achieved with NextJS and Sanity CMS.

Overview

We'll be extending the NextJS + SanityCMS blog starter, which gives us the following:

  • Static site generation via Next js for a performant and SEO friendly site.
  • Content is defined using a headless CMS (Sanity.io).
  • Users can interact with the CMS via Sanity Studio deployed to https://SITE_URL/studio:
    • Draft mode with a side-by-side preview in Sanity Studio or live at https://SITE_URL/api/preview.
    • Secured by Sanity's build in auth.
    • Real-time collaboration.
    • Version history for published data.
  • Incremental Static Revalidation via a webhook-trigger to publish new site content.

Instead of a blog, we'll be updating the schema to allow users to create their own general static site.

Sanity Studio

Including:

  • Any number of pages for their site:
    • Define content using a rich text editor:
    • Basic markdown-style formatting (headers, lists, links, etc.)
    • Embed files, PDFs, and images which are optimisied and served by NextJS.
    • SEO title and description
  • Custom nav bar with configurable routes for each page:
    • Uses NextJS's routing to provide optimised code splitting and client-side routing after the initial load.
    • Support for nested routes.

We'll be using Sanity Studio, which will provide our users with an interface to interact with the CMS and customise their site. Unlike other SaaS site builders, Sanity themselves don't host or manage Sanity Studio, it is essentially just a React app that uses their API. This means we can configure the studio however we want, and we are far less constrained than a service like SquareSpace or Wix. We'll be deploying the studio along side the app itself, at the /studio path.

Defining the Entity Structure

We first need to customise how we handle each type of top-level entity in Sanity Studio. We have 3 kinds:

  • "Documents" - multiple instances of an entity with their own configuration (pages):
    • Display these in a list, where each page automatically opens a preview showing that specific page.
    • Supporting the ability to create/delete each document.
  • "Singletons" - global config where only 1 can exist (settings for the site title, nav, etc.):
    • Need to make sure only 1 of these entities exist, so disable creation/deletion.
  • "Singleton with preview" - it makes sense to display a preview for certain singletons (the home page, since we need to ensure that exists at the root path).

Entity list

This is achieved in sanity.config.ts. The previewStructurePlugin function places the 2 variants of singletons Sanity Studio sidebar and configures the preview as needed. We also need to remove them from the global "new document" button, so we pass them to singletonPlugin, then add the "open preview" button to the desired preview singletons via productionUrl. All the other documents (just pages here) go to previewDocumentNode.

Defining the Schemas

You can browse the source code for the schemas I'll be discussing here, and check Sanity's documentation if you want to add your own.

Pages

First, define a page schema:

export default defineType({
  name: "pages",
  title: "Pages",
  icon: DocumentsIcon,
  type: "document",
  preview: {
    select: { title: "metadata.title", subtitle: "metadata.description" },
  },
  fields: [
    defineField({
      name: "metadata",
      title: "Metadata",
      type: "pageMeta",
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: "content",
      title: "Content",
      type: "page",
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

View full source

A document type is the fundamental building block of Sanity, which allows us to configure arbitrary fields. I've split this into two field 'groups' - metadata (title, URL path, etc.), and the page content itself. We also infer the preview information (which appears in Sanity Studio) from the metadata.

Page

The metadata schema is as follows:

export default defineType({
  name: "pageMeta",
  title: "Page Metadata",
  icon: DocumentsIcon,
  type: "object",
  preview: { select: { title: "title", subtitle: "description" } },
  fields: [
    defineField({
      name: "title",
      title: "Page Title",
      description: "Appears in the browser tab and search results.",
      type: "string",
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: "description",
      description: "Appears in search results.",
      title: "Description",
      type: "string",
      validation: (rule) => rule.max(155),
    }),
    defineField({
      name: "path",
      title: "Path",
      description: `Used for the URL path: https://example.com/your-path`,
      type: "slug",
      options: {
        source: "metadata.title",
        isUnique: (value, context) => context.defaultIsUnique(value, context),
      },
      validation: (rule) => rule.required(),
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

View full source

We have a few standard fields here, which you can read more about here. The path is the more interesting one:

  • Type slug ensures that the path is unique (since it'll be part of the URL) across other pages, and has a valid format.
  • We infer the default value from the title, which is automatically converted into a valid format for the slug.

Now to define the content:

export default defineType({
  name: "page",
  title: "Page",
  icon: DocumentsIcon,
  type: "object",
  preview: { select: { title: "header" } },
  fields: [
    defineField({
      name: "header",
      title: "Header",
      description: "Appears at the top of the page.",
      type: "string",
    }),
    defineField({
      name: "body",
      title: "Body",
      description:
        "Rich text content, including sub headings, links, images, and PDFs.",
      ...
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

Now for the interesting part - we need a way for the user to customise the content of the page in a familiar editor-like experience

Rich Text Blocks

Raw markdown is an option, but instead we'll be using portable text to support some custom object types.

Portable text

Out of the box, Sanity gives us a block type for rich text. That gives us basic formatting options (decorators like bold, italics, etc.), but we want to extend this with a few things. You can follow along with the source code here. It must be used with an array so we can define custom objects to appear within the page:

defineField({
  name: "body",
  title: "Body",
  description:
    "Rich text content, including sub headings, links, images, and PDFs.",
  type: "array",
  of: [
    {
      type: "block",
      marks: {
        ...
      },
    },
    ...
  ],
}),
Enter fullscreen mode Exit fullscreen mode

We'll tackle links first, allowing the user to highlight text, and convert it into a link. We can use annotations, which allow us to create an arbitrary object associated with some text. An annotation for a link to an external URL looks like this:

{
  name: "urlLink",
  type: "object",
  title: "URL Link",
  description: "Link to an external URL.",
  icon: LinkIcon,
  fields: [
    {
      name: "url",
      type: "url",
      title: "URL",
      validation: (rule) => rule.uri().required(),
    },
    {
      name: "hoverText",
      type: "string",
      title: "Description Text",
    },
    {
      name: "shouldUseNewTab",
      type: "boolean",
      title: "Open link in new tab.",
      initialValue: true,
    },
  ],
}
Enter fullscreen mode Exit fullscreen mode

Later, we'll decide how to use these attributes to render this link, but you can imagine how these could translate into an HTML anchor element.

A few things to note:

  • We provide some helpful descriptions and icons to determine how Sanity Studio displays this object to our user (who doesn't need to be particularly tech savvy).
  • We also provide some validation for the format of the URL. You can read more about the validation options Sanity provides here.

Link schema

Similarly, we can create a link for a file:

{
  name: "fileLink",
  type: "object",
  title: "File Link",
  description: "Link pieces of text to a file.",
  icon: DocumentTextIcon,
  fields: [
    {
      name: "file",
      type: "file",
      title: "File Attachment",
      validation: (rule) => rule.required(),
    },
    {
      name: "fileName",
      type: "string",
      title: "File Name",
    },
  ],
},
Enter fullscreen mode Exit fullscreen mode

The file type does a lot of the heavy lifting here, allowing the user to upload a file of their choosing, which Sanity will then serve to the user.

We also want to allow users to embed images in their site. We don't use an annotation for this, since images are independent from text, so it is a separate element in the array of the parent body field:

{
  type: "object",
  name: "embeddedImage",
  title: "Image",
  icon: ImageIcon,
  fields: [
    {
      name: "imageFile",
      type: "image",
      title: "Image File",
      validation: (rule) => rule.required(),
    },
    {
      name: "imageDescription",
      type: "string",
      title: "Description",
      description: "If the image fails to load, this text will appear.",
      validation: (rule) => rule.required(),
    },
    {
      name: "imageWidth",
      type: "number",
      title: "Image Size",
      description:
        "Pixel width of the image, leave empty for auto scaling.",
      validation: (rule) => rule.max(5000).min(10),
    },
    {
      name: "wrapText",
      type: "boolean",
      title: "Wrap Text",
      description: "Wrap text around the image.",
      initialValue: false,
    },
  ],
},
Enter fullscreen mode Exit fullscreen mode

Sanity has a built-in image type too, but we need the user to specify additional fields so we can determine how to render the image in the frontend later.

Image schema

Let's create an embedded file too:

{
  type: "object",
  name: "embeddedFile",
  title: "File",
  icon: DocumentPdfIcon,
  fields: [
    {
      name: "file",
      type: "file",
      title: "File Attachment",
      validation: (rule) => rule.required(),
    },
    {
      name: "fileName",
      type: "string",
      title: "File Name",
    },
    {
      name: "shouldRenderPdf",
      type: "boolean",
      title: "Render PDF",
      description: "If the file is a PDF, display it on the page.",
      initialValue: true,
    },
    {
      name: "pdfHeight",
      type: "number",
      title: "PDF Display Height",
      description:
        "Height for the PDF display, leave empty for auto scaling.",
      hidden: ({ parent }) => !parent?.shouldRenderPdf,
      validation: (rule) => rule.max(5000).min(50),
    },
  ],
},
Enter fullscreen mode Exit fullscreen mode

We also give the user an option to embed a PDF file, which we'll use to render the PDF within an iframe. Since we have a field that is specific to whether the PDF is rendered or not, we can use the hidden option.

All together, that gives us a rich text editor with some custom objects.

View full source

Defining the Nav

export default defineType({
  name: "navigation",
  title: "Navigation",
  icon: MasterDetailIcon,
  type: "document",
  preview: {
    prepare: () => ({ title: "Navigation" }),
  },
  fields: [
    {
      name: "items",
      title: "Nav Items",
      type: "array",
      of: [
        {
          type: "object",
          name: "menuItem",
          fields: [
            {
              name: "title",
              title: "Title",
              type: "string",
              validation: (rule) => rule.required(),
            },
            {
              name: "routes",
              title: "Routes",
              description:
                "Pages for the nav item. Define multiple routes for a" +
                " dropdown menu.",
              type: "array",
              of: [
                {
                  type: "object",
                  name: "subMenuItem",
                  fields: [
                    {
                      name: "title",
                      title: "Title",
                      description:
                        "If this is your only route, leave blank." +
                        " Otherwise used for the dropdown menu.",
                      type: "string",
                    },
                    {
                      name: "page",
                      title: "Page",
                      type: "reference",
                      to: [{ type: "pages" }],
                      validation: (rule) => rule.required(),
                    },
                  ],
                },
              ],
            },
          ],
        },
      ],
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

View full source

We want our navbar to support a dropdown for nested routes, so we have 2 levels of arrays. Using reference, the user can easily search for the pages they created.

For the sake of brevity, I'll leave out the other schemas, you can browse them here.

Building Out the React Frontend

With Sanity being a headless CMS, we have the freedom to render our components and style the site however we want. I'll speed through some of the logic here to get to the interesting parts (you can find the general approach in the NextJS + SanityCMS blog starter).

Since we're using NextJS and want static site generation, data from the CMS comes from getStaticProps. Thankfully, this means we don't need to deal with any loading states, since the site will be rendered before it reaches the client. Nice.

A reusable function getStaticPageProps allows us to query the page data for any path. This runs the following query:

export const pageQuery = (path: string) =>
  groq`*[_type == "pages" && metadata.path.current == "${path}"][0]`;
Enter fullscreen mode Exit fullscreen mode

groq is Sanity's custom query language. We won't go into detail for it here, but you can treat it a bit like GraphQL. One useful thing to note: Sanity Studio has a built-in playground called "Vision" which is handy for development.

The page data ultimately ends up as props in the PageLayout component. We also need to know if the page is being viewed as a preview since it that case the site isn't pre-rendered and we need to handle a loading state.

const PageLayout = (props: PageLayoutProps) => {
  const {
    preview,
    loading,
    page: { content, metadata },
    settings,
    routes,
  } = props;

  return (
    <>
      <PageHead pageMeta={metadata} settings={settings} />

      <Layout loading={loading} preview={Boolean(preview)}>
        <Navigation routes={routes} />
        <Container>
          <h1 className={clsx("pt-3")}>{content?.header ?? "Heading"}</h1>
          <hr />
          {content?.body ? (
            <PortableText
              components={PortableTextRenderer}
              value={content.body}
            />
          ) : (
            "Body"
          )}
        </Container>
      </Layout>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

We also treat these props as optional, since even though they are required, the user configuring the CMS data in Sanity Studio can view the realtime preview before that data is valid, and it's preferable that they see the empty 'structure' of the site rather than an error. We also don't have type safety for the schema we defined for Sanity, so I've had to define my own types for these props... (more on this later).

The complex part here is PortableText, so let's give into that next.

Portable Text

Remember all those objects we defined for the rich text editor? Now we can decide how to actually render them.

The magic happens in the PortableTextRenderer:

const PortableTextRenderer: PortableTextComponents = {
  marks: {
    em: ({ children }) => <em>{children}</em>,
    strong: ({ children }) => <strong>{children}</strong>,
    u: ({ children }) => <u>{children}</u>,
    code: ({ children }) => <code>{children}</code>,
    urlLink: UrlLink,
    fileLink: FileLink,
  },
  list: {
    bullet: ({ children }) => <ul>{children}</ul>,
    number: ({ children }) => <ol>{children}</ol>,
  },
  listItem: {
    bullet: ({ children }) => <li>{children}</li>,
    number: ({ children }) => <li>{children}</li>,
  },
  block: {
    blockquote: ({ children }) => (
      <blockquote className={clsx("blockquote")}>{children}</blockquote>
    ),
  },
  types: {
    embeddedImage: ImageComponent,
    embeddedFile: FileComponent,
  },
};
Enter fullscreen mode Exit fullscreen mode

Breaking that down piece by piece:

  • We are essentially defining React components to render
  • marks (the annotations and decorations for text) wrap children (the text) in some basic HTML like em for bold, etc. This also includes our custom URL and file links.
  • list, listItem and block map to their HTML counterparts.
  • Our custom types for images and files.

URL Links

The UrlLink looks like this:

const { url, hoverText, shouldUseNewTab } = parsedUrlResult.data;
return (
  <a
    href={url}
    rel="noreferrer"
    target={shouldUseNewTab ? "_blank" : "_self"}
    title={hoverText}
  >
    {children}
  </a>
);
Enter fullscreen mode Exit fullscreen mode

We map the user defined options from the Sanity schema into a link. Before we do that however, we need to validate the input from the API is what we expect. Again, we don't know if the data specified in preview mode is valid (initially it won't be), and we don't want the preview to crash, instead it should give some visual feedback to the user.

Invalid link

We'll be using zod to do that validation at runtime:

const urlLinkSchema = z.object({
  _key: z.string(),
  _type: z.literal("urlLink"),
  url: z.string(),
  hoverText: z.string().optional(),
  shouldUseNewTab: z.boolean().optional(),
});

...

const parsedUrlResult = urlLinkSchema.safeParse(value);
if (!parsedUrlResult.success) {
  console.error("Url object did not match schema", {
    cause: parsedUrlResult.error,
  });
  return <span className={clsx("text-danger")}>{children}</span>;
}
Enter fullscreen mode Exit fullscreen mode

View full source

Note that we need to be careful our Sanity native validation and frontend validation align here - the user in Sanity Studio shouldn't be able to save their changes and exit draft mode while the frontend is unable to render the content correctly. Unfortunately, Sanity doesn't provide any tooling to generate zod schemas from their native validation, or even type definitions more generally for our frontend to consume, so we have to write them manually. As an aside, this might be my biggest gripe with Sanity overall, type safety is not a priority of theirs unfortunately.

File Links

FileLink is similar, but we need to construct a URL to the file that has been uploaded on Sanity:

const fileData = parsedFileResult.data;
const { fileName } = fileData;
const [_file, id, extension] = fileData.file.asset._ref.split("-");
return (
  <a
    href={`${sanityBaseUrl}/${id}.${extension}?dl=${
      fileName ? encodeURIComponent(fileName) : id
    }.${extension}`}
  >
    {children}
  </a>
);
Enter fullscreen mode Exit fullscreen mode

View full source

Embedded Files and PDFs

Since we give the user the option to embed PDF files, we do this conditionally with an iframe:

const { fileName, shouldRenderPdf, pdfHeight, file } = parsedFileResult.data;
const [_file, id, extension] = file.asset._ref.split("-");
return extension === "pdf" && shouldRenderPdf ? (
  <iframe
    height={pdfHeight}
    src={`${sanityBaseUrl}/${id}.${extension}`}
    title={fileName || "PDF"}
    width="100%"
  />
) : (
  <a
    href={`${sanityBaseUrl}/${id}.${extension}?dl=${
      fileName ? encodeURIComponent(fileName) : id
    }.${extension}`}
  >
    {`${fileName || "file"}.${extension}` || "Download file"}
  </a>
);
Enter fullscreen mode Exit fullscreen mode

View full source

Images

Now for the tricky one: we want to serve the image in an optimised way using NextJS's Image. This gives us:

  • Size Optimization - Automatically serve correctly sized images for each device, using modern image formats like WebP and AVIF.
  • Visual Stability - Prevent layout shift automatically when images are loading.
  • Faster Page Loads - Images are only loaded when they enter the viewport using native browser lazy loading, with optional blur-up placeholders.
  • Asset Flexibility - On-demand image resizing, even for images stored on remote servers.

while serving the image directly from Sanity does not.

Thankfully, someone has already built a library to do the heavy lifting for us - next-sanity-image - which gives us a few helper functions:

const ImageRenderer: React.FC<{
  image: ImageType;
  isInline: boolean;
  sanityClient: SanityClient;
}> = ({ image, isInline, sanityClient }) => {
  const imageProps = useNextSanityImage(sanityClient, image.imageFile);
  const { width, height } = getImageDimensions(image.imageFile);
  return (
    <Image
      {...imageProps}
      alt={image.imageDescription || ""}
      loading="lazy"
      style={{
        // Display alongside text if image appears inside a block text span
        display: isInline ? "inline-block" : "block",
        float: image.wrapText ? "left" : "none",

        // Avoid jumping around with aspect-ratio CSS property
        aspectRatio: width / height,
      }}
      width={image.imageWidth || width}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

View full source

And boom, we don't need to worry about an external service like Cloudinary; NextJS covers all our images optimisation needs.

Deployment

There are a few other components you can explore, like the nav, but we now have a decently versatile page builder.

You can deploy this app to Vercel using the template, and create your own Sanity data lake (the free tier is pretty generous).

You'll want to configure incremental static regeneration to ensure published changes to CMS data are automatically pushed to the main site (see this file - be aware that if you update the schema you'll also need to update the logic that handles that revalidation). This allows you to trigger re-validations on only data which has changed, rather than rebuilding the entire app.

Wrapping Up

Again, you can view the full source code here, which is built with minimal Bootstrap components. Since the CMS is headless, you can style the frontend however you need, or switch to another component library like Material UI - whatever you want really. This makes the template easily adaptable to different clients, you can restyle the app to a brand and grant your client access to Sanity Studio for a minimal maintenance burden. Take a look at this template in action on a full project here.

Top comments (0)