Week 6Week 8 | Bonus

Week 7 - Continuing with CMSs

~58 min read

Pre-Workshop

Sanity Plugins

One of the best parts about Sanity is the ability to add plugins to your project. Plugins are packages that add functionality to your Sanity project. There are a lot of plugins available, such as:

  • Google Maps integration
  • Scheduled Publishing
  • Code Input
  • Color Input

We'll be downloading two plugins for our project. If you want to see the full list of plugins, you can check out Plugins and tools for Sanity.

Media Browser Plugin

In the last workshop, we uploaded an image for our blog post. But where did it go? Sanity has a pretty basic media browser that's extremely limited. However, with the Media Browser plugin, we'll be able to browse, manage, and select all of our assets in one place.

Run the following command in your project to install the plugin:

npm i sanity-plugin-media

Then, go into sanity.config.js, and add it to the end of the plugins array:

sanity.config.js
import { media } from "sanity-plugin-media";

export default defineConfig({
  // ...
  plugins: [media()],
});

Learn more about this plugin here.

GROQ Query Language

In the last workshop, we opened up the Vision tab and pasted this query:

*[_type == "photo"]

This query is written in a language called GROQ. GROQ is a query language that is used to query data from Sanity. GROQ stands for Graph-Relational Object Queries. With GROQ, we can describe exactly what data we need, and we can get it in a single request. We can combine documents, exclude documents, get specific fields, create aliases, and more.

Let's break the following query:

*[_type == 'movie' && releaseYear >= 1979]

A query typically starts with *. This asterisk represents every document in your dataset. To do any useful work this is typically followed by a filter in brackets. The filter above has two terms:

The filter

First, we filter by document type. Every document in Sanity is required to have a type, and the type is always in the _type field. (We prefix any Sanity-specific fields with an underscore in an attempt to avoid clashing with any of your field names.) So _type == 'movie' limits this query to documents of the type ‘movie’. && is the operator “and”.

The second term releaseYear >= 1979 assumes that the movies have a field called releaseYear that contains numbers. It will match any document where this number is larger than or equal to 1979.

Projections

So if we run this query, the result will be an array containing all movies from the year 1979 onwards in the dataset. Nice! However in a typical application movies might be huge documents containing information on actors, staff, posters, tag-lines, show-times, ratings, and whatnot. If our goal is to render a list of movies in an overview, we are wasting bandwidth. Projections to the rescue.

The typical projection is wrapped in braces and describes the data we want to see for each movie. A nice and simple projection for this query would give us the id, title, and release year for each movie. It could look like this: {_id, title, releaseYear}. Putting it all together:

*[_type == 'movie' && releaseYear >= 1979]{ _id, title, releaseYear }

Basic Sorting

Now there is another problem. Our movies appear in some unspecified order. Let’s say we want to sort our movies by year. For this, we use the order-function. Order takes a number of fields and sort directions and orders your documents accordingly. We wanted to sort our movies by releaseYear. This is easily accomplished with order(releaseYear), like this:

*[_type == 'movie' && releaseYear >= 1979] | order(releaseYear) {
  _id, title, releaseYear
}

(We need the | operator here in front of the order()-function, we'll discuss that more later.)

We think of GROQ statements as describing a data flow from left to right. First everything (*) flows through the filter [_type == 'movie' && …], then all those movies flow through the order()-function which is then all mapped through the projection {_id, title, ...} which picks out the bits we want to be returned.

The order function accepts a list of fields, and optionally you can specify the sort direction for each field. If you wanted to sort the movies by year, and then within each year we want them alphabetical by title, we could use this ordering: order(releaseYear, title) And if we wanted the newest movies first, we could reverse the direction like this: order(releaseYear desc, title).

There's so much power we can get from GROQ. We can do things like:

  • Slice arrays
  • Combine documents
  • Create references and join data
  • Naked Projections

If you want to learn more, check out the GROQ Cheat Sheet, which has examples for all kinds of queries you'll need.

If you want to learn everything GROQ, check out Learn GROQ in 45 minutes, an interactive learning website from a former Sanity developer.

Taken from How Queries Work - GROQ

Object Schemas

In the last workshop, we made a ton of document schemas. We mentioned Object schemas, but we didn't go into detail about them. Object schemas are a way to group fields together. Let's go back to our Dog schema:

Dog Schema

What if I wanted to make more document schemas for other pets, but wanted to reuse the fields from the Dog schema? I could copy and paste the fields, but that's not very efficient. Instead, I can make an Object schema, maybe for Pet Info:

Dog Schema - Object

We can now use the Pet Info Object schema in any other schema (including other Object schemas!)

For the sake of our project, we don't really need to use Object schemas. But it's good to know that they exist, and that they can be useful in some situations. Here's the code for the example above:

petInfo.js
export default {
  name: "petInfo",
  title: "Pet Info",
  type: "object",
  fields: [
    {
      name: "name",
      title: "Name",
      type: "string",
    },
    {
      name: "age",
      title: "Age",
      type: "number",
    },
  ],
};
dog.js
export default {
  name: "dog",
  title: "Dog",
  type: "document",
  fields: [
    {
      name: "petInfo",
      title: "Pet Info",
      type: "petInfo",
    },
    {
      name: "breed",
      title: "Breed",
      type: "string",
    },
  ],
};

We can just reuse the Pet Info Object schema anywhere! We can even use it in other Object schemas:

dragon.js
export default {
  name: "dragon",
  title: "Dragon",
  type: "document",
  fields: [
    {
      name: "petInfo",
      title: "Pet Info",
      type: "petInfo",
    },
    {
      name: "gold",
      title: "Gold",
      type: "number",
    },
  ],
};

Querying Data from Sanity in Next.js

Let's go back to our Next.js project. We're going to be using the Sanity Client to query data from Sanity. The Sanity Client is a JavaScript library that allows us to query data from Sanity. We can use it in our Next.js project to get data from Sanity, and then display it on our website.

We've already installed the Sanity Client in our project, so we can just jump straight into using it.

You've learned a bit of GROQ, so let's use it to query data from Sanity. On our blog page, let's write a query to get all of our blog posts, being selective towards the fields we want to get. For example, we don't really need the contents of the blog post, we just need the title, description, date, slug, and image.

*[_type == "blogPost"] | order(date desc) {
  title,
  description,
  date,
  "slug":slug.current,
  image
}

Remember one of the best parts about GROQ is that we can be selective about the fields we want to get. We don't need to get all of the fields, we can just get the ones we need.

This query will get all of our blog posts, and then order them by date, with the newest blog post first. Let's use this query in our Next.js project.

Using the Sanity Client

First, let's import the Sanity Client at the top of app/blog/page.js:

app/blog/page.js
import { client } from "@/sanity/lib/client";

The client allows us to query data from Sanity. If you open up sanity/lib/client.js, you'll see that the client has already been configured for us. We just need to import it and use it. But how do we get our data from Sanity?

Remember back in the Data and Networking Workshop, we created async functions to get data from an API. We're going to do the same thing here, but instead of getting data from our API, we're going to get data from Sanity.

Create the following function:

app/blog/page.js
async function getBlogPosts() {
  const query = `*[_type == "blogPost"] | order(date desc) {
    title,
    description,
    date,
    "slug":slug.current,
    image
  }`;

  const posts = await client.fetch(query);
  return posts;
}

Looks familiar, right? We're using the Sanity Client to fetch data from Sanity. We're using the query we wrote earlier to get all of our blog posts, and then we're returning them.

Before we display our data, we'll need a component to display our blog posts. Create the following components in their respective files:

app/blog/components/BlogPost.js
import { urlForImage } from "@/sanity/lib/image";
import { format } from "date-fns";
import Image from "next/image";
import Link from "next/link";
import DatePill from "./DatePill";

export default function BlogPostCard({ post }) {
  return (
    <Link
      href={`/blog/${post.slug}`}
      className="space-y-4 md:hover:opacity-75 transition-opacity"
    >
      <Image
        src={urlForImage(post.image).auto("format").size(1920, 1080).url()}
        width={1920}
        height={1080}
        alt={post.title}
        className="rounded-2xl border border-primary-400"
      />
      <div className="space-y-2">
        <DatePill date={post.date} />
        <div>
          <h2 className="text-lg font-semibold">{post.title}</h2>
          <p className="line-clamp-1 text-sm text-primary-600">
            {post.description}
          </p>
        </div>
      </div>
    </Link>
  );
}

There's a few things new things going on here:

  • We're wrapping it in a Next Link component, so that we can click on it and go to the blog post page - but why is it a link to /blog/${post.slug}? What does that even mean? (You'll find out soon.)
  • We're using this thing called urlForImage to get the URL for the image. This is a method from the Sanity Client that allows us to utilize Sanity's Image Pipeline. It manages resolution, size, and other types of transformations for us. Learn more about it here. In this component, we're setting the image to be 1920x1080, and we're using the auto method to automatically choose the best format for the image.

date-fns

We also need to create this component which takes a date and formats it:

app/blog/components/DatePill.js
import { format } from "date-fns";

export default function DatePill({ date }) {
  return (
    <p className="text-xs font-medium px-2 py-1 rounded-full bg-secondary-200 text-secondary-600 inline">
      {format(new Date(date), "MMMM dd, yyyy")}
    </p>
  );
}

It won't take long for you to realize that this won't work. We haven't installed date-fns yet. Let's do that now:

npm i date-fns

With that installed, we can now use it to format our date. We're using the format method to format our date. We're passing in a new Date object, and then we're passing in a format string. The format string tells date-fns how to format the date. In this case, we're formatting it to be MMMM dd, yyyy, which will give us a date like November 14, 2023.

Using our blog post component

Now that we've made our blog post and date components, let's use them in our blog page:

app/blog/page.js
...
import BlogPostCard from "@/app/blog/components/BlogPost";

export default async function Blog() {
  const posts = await getBlogPosts();

  return (
    <Container>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
        {posts.map((post) => (
          <BlogPostCard key={post.slug} post={post} />
        ))}
      </div>
    </Container>
  );
}
...

Remember that we have getBlogPosts and also the import for the Sanity Client at the top of the file.

You should be pretty familiar with this code. We're using our getBlogPosts function to get all of our blog posts, and then we're mapping over them and displaying them using our BlogPostCard component.

If you go to the blog page, you'll hopefully see all of your blog posts!

Adapting for Photos (DIY)

The same ideas apply to the Photos page, as well as the Home page. Try and figure out what queries you'll need to use! Here's some helpful components:

app/photos/components/PhotoCard.js
import { urlForImage } from "@/sanity/lib/image";
import Image from "next/image";

export default function Photo({ photo: { title, image, favorite } }) {
  return (
    <div className="space-y-2 group">
      <Image
        src={urlForImage(image).auto("format").size(1920, 1080).url()}
        width={1920}
        height={1080}
        alt={title}
        className="rounded-2xl border border-primary-400 md:group-hover:scale-95 transition-transform transform"
      />
      <h2 className="font-medium flex items-center justify-center">
        {favorite ? <p className="text-sm mr-2">⭐️</p> : null}
        {title}
      </h2>
    </div>
  );
}

The only unique thing here is that we're deconstructing the photo object in the function parameters. This is just a shortcut to get the title, image, and favorite fields from the photo object. We're also using the urlForImage method to get the URL for the image.

Making a Blog Post Page

Remember the slug field we added to our blog post schema? We're going to use that to make a blog post page. We're going to use the slug to get the blog post from Sanity, and then we're going to display it on the page.

Up until now, we've created a new file/folder for every page we want to make. But we're going to do something different here. We're going to create a dynamic route. A dynamic route is a route that can have different values. For example, /blog/first-post and /blog/second-post are both dynamic routes. They both have the same base path, but they have different values. We're going to use a dynamic route to create a blog post page.

Creating Dynamic Routes

The way we create dynamic routes is extremely similar to just creatng a normal page. We want a structure like /blog/first-post and /blog/second-post, so inside of the app/blog folder, create a [post] folder, with a page.js file inside. The brackets around post tell Next.js that this is a dynamic route. We can now access the value of post in that page.

Let's start adding some code to our page:

app/blog/[post]/page.js
export default async function BlogPost({ params }) {
  return (
    <Container>
      {/* Content */}
    </Container>
  );
}

Notice the parameter params in the function. This is where we can access the value of post. We can access it with params.post. We're going to use this to get the blog post from Sanity. There's a few things we need to do to finish this page:

  1. We'll need to get the blog post from Sanity
  2. We'll need to display the blog post
    • Show a header with the title and date
    • Display the post contents

Getting post data from Sanity

Let's create a query for the blog post. We want to get the blog post with the slug that matches the slug in the URL. We can do that with the following query:

*[_type == "blogPost" && slug.current == $slug] {
  title,
  description,
  date,
  "slug":slug.current,
  image,
  content
}

We're using the $slug variable to get the slug from the URL. We're also getting the content field, which is a Portable Text field. Now, let's create our getBlogPost function:

app/blog/[post]/page.js
import { client } from "@/sanity/lib/client";

async function getBlogPosts(slug) {
  const query = `*[_type == "blogPost" && slug.current == $slug] {
    title,
    description,
    date,
    "slug":slug.current,
    image,
    content
  }`;

  const posts = await client.fetch(query, { slug });
  return posts;
}

We're using the $slug variable in our query, and we're passing in the slug variable as a parameter to our function. We're also passing in the slug variable as a parameter to the fetch method. This is how we pass variables to our query. Since they're the same name, we can just pass in the slug variable as a parameter to our function. Otherwise, we'd have to do something like this:

const posts = await client.fetch(query, { slug: thisIsaSlugVariable });

Here's a component to display the header of our blog post:

app/blog/[post]/components/BlogPostHeader.js
import DatePill from "../../components/DatePill";

export default function BlogPostHeader({ post }) {
  return (
    <header className="text-center space-y-4">
      <DatePill date={post.date} />
      <h1 className="font-semibold text-4xl">{post.title}</h1>
      <p className="font-medium text-primary-700 text-lg">{post.description}</p>
    </header>
  );
}

We can now use this in our page:

app/blog/[post]/page.js
...
import BlogPostHeader from "./components/BlogPostHeader";

export default async function Page({ params }) {
  const post = await getBlogPost(params.post);

  return (
    <Container>
      <div className="mx-auto max-w-5xl space-y-8 py-8">
        <BlogPostHeader post={post} />
      </div>
    </Container>
  );
}
...

Now, if you click on any of your blog posts, you should see the header of the blog post on their own, unique, dynamic page!

Portable Text

The next step is getting our blog post content to display. We're going to use a library called @portabletext/react to convert our Portable Text to React components:

npm i @portabletext/react

This allows us to use a component called PortableText to display our Portable Text. We can immediately use it in our page:

app/blog/[post]/page.js
...
import { PortableText } from "@portabletext/react";

export default async function Page({ params }) {
  const post = await getBlogPost(params.post);

  return (
    <Container>
      <div className="mx-auto max-w-5xl space-y-8 py-8">
        <BlogPostHeader post={post} />
        <hr className="border-primary-200" />
        <article>
          <PortableText value={post.content} components={[]} />
        </article>
      </div>
    </Container>
  );
}
...

If you go to the blog post page, you'll see that it's not styled at all. That's something we can easily fix with a plugin for Tailwind CSS.

Tailwind Typography

Tailwind CSS has a bunch of plugins that add additional functionality to Tailwind CSS. One of those plugins is Tailwind Typography. The Typography plugin provides a set of prose classes we can use to add great typographic defaults to any vanilla HTML you don't control (like HTML rendered from Markdown, or pulled from a CMS).

We can install it with the following command:

npm install -D @tailwindcss/typography

Notice the -D flag. This is short for --save-dev. This means that we're installing it as a development dependency. We don't need it in production, so we don't need to install it as a dependency. More on this later!

Now that it's installed, we can add it to our tailwind.config.js file:

tailwind.config.js
module.exports = {
  theme: {
    ...
  },
  plugins: [
    require('@tailwindcss/typography'),
    ...
  ],
}

Now, let's go back to our blog post page and update the page with our new prose classes:

app/blog/[post]/page.js
...
export default async function Page({ params }) {
  const post = await getBlogPost(params.post);

  return (
    <Container>
      <div className="mx-auto max-w-prose space-y-8 py-8">
        <BlogPostHeader post={post} />
        <hr className="border-primary-200" />
        <article className="prose md:prose-md prose-primary mx-auto">
          <PortableText value={post.content} components={portableTextComponents} />
        </article>
      </div>
    </Container>
  );
}
...

Now, if you go to the blog post page, you should see that it's styled with the Tailwind Typography plugin! But there's one final problem you might have noticed...

Custom Portable Text Components

Remember back to how we defined our Portable Text schema? We defined it like this:

{
  name: "content",
  title: "Content",
  type: "array",
  of: [
    { type: "block" },
    {
      type: "image",
    },
  ],
},

We defined it as an array of blocks and images. The Portable Text component doesn't know how to handle images, by default. We can fix this by creating a custom component for images:

app/blog/[post]/page.js
import { urlForImage } from "@/sanity/lib/image";
import { tryGetImageDimensions } from "@sanity/asset-utils";
...
const portableTextComponents = {
  types: {
    image: ImageComponent,
  },
};

function ImageComponent({ value }) {
  const { width, height } = tryGetImageDimensions(value);

  return (
    <Image
      src={urlForImage(value).fit("max").auto("format").url()}
      width={width}
      height={height}
      loading="lazy"
      className="mx-auto md:max-w-prose rounded-lg"
      style={{
        aspectRatio: width / height,
      }}
    />
  );
}

And then updating the Portable Text component to use our custom components:

app/blog/[post]/page.js
<PortableText value={post.body} components={portableTextComponents} />

Now, if you go to the blog post page, you should see that the images are displaying correctly!

Why this is important

When we talked about Block Content & Portable Text, we mentioned that it's a way to store rich text. But it's more than that. As seen, we can have it store images, and we can even create custom components to display those images. But the options are limitless. You could have:

  • Video Player
  • Code Editors
  • Quizzes
  • Music Players
  • Games

and more, just with expanding your Portable Text schema and creating custom components. This is why Portable Text is so powerful. If you can think it and make it, you can store it in Portable Text. Since it's so flexible, you can design software that can interpret it in any way you want.

Practice

The Project Showcase is in two weeks from now, with next week being a design and interaction oriented workshop. This means that you have two weeks to work on your project. Here's some ideas for what you can do:

  • Add more pages
  • Add more components
  • Add more styles
  • Add more content with Sanity
  • Customize your CMS

We want to see you make your website your own. Feel free to go crazy with it. If you want to add a feature, but you don't know how, ask us! We're here to help you.

Workshops