Cover image for the article. Credits to retrosupply
Photo by retrosupplyopens a new window

How I created my blog using Next.js and Sanity Part 2

10 minutes read
  • JavaScript
  • React.js

In the last article, we did all the necessary setup in Sanity in order to be able to easily create blog posts for our section, now it's time to make all our previous work visible in our front-end using Next.js. In this article we'll be learning quite a few things like querying for Sanity's data, creating dynamic routes, how pre-rendering works in Next.js and more.

The steps

Installing all dependencies

We'll be installing quite a few things, some of them are optional.

  1. Create a Next.js project using npx create-next-app@latest
  2. Install the Sanity client: npm install @sanity/client
  3. Install some Sanity utilities that we'll be needing: npm install @sanity/image-url @sanity/asset-utils
  4. Install Refractor for code block syntax highlighting: npm install react-refractor. You can use many other libraries for this. I tried React Syntax Highlighter and it was just painfully heavy and really had a negative effect on my website's performance, that's why I ended up choosing Refractor since it's really lightweight.
  5. Install React Portable Text: npm install @portabletext/react
  6. Framer-motion for animations (optional).
  7. TailwindCSS (optional).
  8. Next-share for social media sharing buttons (optional).

Configuring Sanity client

Let's create a new file in the src folder called sanityClient.js so we can configure our client. You can read more details about this in Sanity's docs.opens a new window

import sanityClient from "@sanity/client";
import imageUrlBuilder from "@sanity/image-url";

export const client = sanityClient({
  projectId: "your-project-id",
  dataset: "production",
  apiVersion: "2022-08-01",
  useCdn: true,
});

const builder = imageUrlBuilder(client);

export const urlFor = (source) => {
  return builder.image(source);
};

Building our queries

Now we need to create our queries in order to fetch the data that we need from our Sanity Blog schema, for this I like to create a new file to store all the queries that we would be needing, this way everything is organized in one place and we keep our project as clean as possible. Let's create a folder called constants and inside that folder, we create the file queries.js.

Sanity uses GROQ as its query language, it's very similar to GraphQL, so if you have worked with it in the past, it'll be really easy to pick up. For reference, you can learn more about GROQ and how queries work following this link.opens a new window

export const blogQuery = () => {
  const query = `*[_type == "blog"] | order(_createdAt desc) {
    _id,
    slug,
    excerpt,
    articleTitle,
    publishDate,
    coverImage {
      altText,
      image {
       asset -> {
         url
          }  
        }
     } 
  }`;

  return query;
}; // This query will retrieve all the basic information that we would be displaying in our /blog page, basically a list of all our blog articles

export const blogPostQuery = (slug) => {
  const query = `*[_type == "blog" && slug.current == "${slug}"] {
    _id,
    slug,
    excerpt,
    articleBody,
    articleTitle,
    publishDate,
    socialShareImage {
        asset -> {
          url
        }
    },
    coverImage {
      altText,
      image {
       asset -> {
         url
          }  
        }
     } 
  }`;

  return query;
}; // This query will retrieve all the information of an individual article based on the slug that we pass it as a parameter. We will be displaying this data in our /blog/[slug] dynamic pages

Structural components and SEO

What is a blog without good Search Engine Optimization? We need to make sure that we have good SEO in our blog site, that's key. Let's create some reusable structural components and also a MetaData component that will take care of our SEO.

First, we'll go with the MetaData component, in my case it looks like this:

import React from "react";
import Head from "next/head";

const MetaData = ({
  title,
  description,
  canonicalUrlPath,
  socialCardImage,
  contentType,
}) => {
  const defaultTitle = "Ivan Atias · Front-End Engineer, UI Designer";
  const defaultDescription =
    "Ivan Atias is a Front-End Engineer and UI Designer who enjoys a lot building good looking and functional websites and apps.";
  const defaultImage = "https://www.ivanatias.codes/card.png";
  const defaultOgType = "website";

  return (
    <Head>
      <title>{title ? `${title} - Ivan Atias` : defaultTitle}</title>
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <meta charSet="UTF-8" />
      <meta name="description" content={description || defaultDescription} />
      <meta
        name="keywords"
        content="Frontend Developer, UI Designer, Ivan Atias, Portfolio, Blog"
      />
      <meta name="author" content="Ivan Atias" />
      <link
        rel="canonical"
        href={`https://www.ivanatias.codes${canonicalUrlPath || ""}`}
      />
      <meta
        property="og:url"
        content={`https://www.ivanatias.codes${canonicalUrlPath || ""}`}
      />
      <meta
        property="og:title"
        content={title ? `${title} - Ivan Atias` : defaultTitle}
      />
      <meta
        property="og:description"
        content={description || defaultDescription}
      />
      <meta name="twitter:card" content="summary_large_image" />
      <meta property="og:site_name" content="Ivan Atias Website" />
      <meta
        name="twitter:title"
        content={title ? `${title} - Ivan Atias` : defaultTitle}
      />
      <meta
        name="twitter:description"
        content={description || defaultDescription}
      />
      <meta
        name="image"
        property="og:image"
        content={socialCardImage || defaultImage}
      />
      <meta name="twitter:image" content={socialCardImage || defaultImage} />
      <meta property="og:type" content={contentType || defaultOgType} />
    </Head>
  );
};

export default MetaData;

Now let's take care of those structural components I was talking about, take in consideration that I'm using framer-motion library to add some interesting animations, but this is totally optional.

import React from "react";
import { MetaData } from "../../components";
import { motion } from "framer-motion";

const variants = {
  hidden: { opacity: 0, x: 0, y: 20 },
  enter: { opacity: 1, x: 0, y: 0 },
  exit: { opacity: 0, x: 0, y: 20 },
};

const Section = ({
  children,
  title,
  description,
  canonicalUrlPath,
  socialCardImage,
  contentType,
}) => {
  return (
    <>
      <MetaData
        title={title}
        description={description}
        canonicalUrlPath={canonicalUrlPath}
        socialCardImage={socialCardImage}
        contentType={contentType}
      />
      <motion.section
        className="flex flex-col gap-10"
        initial="hidden"
        animate="enter"
        exit="exit"
        variants={variants}
        transition={{ duration: 0.4, type: "easeInOut" }}
      >
        {children}
      </motion.section>
    </>
  );
};

export default Section;
import React from "react";
import { motion } from "framer-motion";

const Article = ({ children, title, delay = 0 }) => {
  return (
    <motion.article
      className="flex flex-col w-full gap-4"
      initial={{ opacity: 0, y: 10 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.6, delay }}
    >
      {title && (
        <h2 className="text-xl font-bold text-black dark:text-gray-100 md:text-2xl">
          {title}
        </h2>
      )}
      {children}
    </motion.article>
  );
};

export default Article;
import React from "react";

const Paragraph = ({ children }) => {
  return (
    <p className={`text-black dark:text-gray-300 text-base 2xl:text-lg`}>
      {children}
    </p>
  );
};

export default Paragraph;

The blog page

This is where we'll render all our blog articles, basically our blog index. We need to fetch all our blog articles from Sanity. In order to do so Next.js offers some options, see Next.js docs.opens a new window In this case, since this is a static website, we'll be going for the static generation strategy using getStaticProps which will pre-render the page at build time.

Putting it simple, getStaticProps will fetch the data from Sanity at build time and then pass this data as props to our component. This is great for SEO because the page's HTML will be generated ahead of a request and later on, it will be sent to the client to be hydrated with JavaScript to make it fully interactive. Also, this HTML will be reused on each request. The result: a blazing fast and performant page.

In the pages folder, let's create a new folder called blog and inside that folder, create an index.jsx file. In this file we'll be importing some of the structural components we previously created, our blogQuery and the Sanity client we configured.

import React from "react";
import { Article, BlogGrid, MainSection, Paragraph } from "../../components";
import { blogQuery } from "../../constants/queries";
import { client } from "../../sanity/client";

const BlogPage = ({ blog }) => {
  return (
    <MainSection title="Blog" canonicalUrlPath="/blog">
      <Article title="Blog" delay={0.1}>
        <Paragraph>
          Writing about web development and performance, my personal experiences
          in this field, or simply random thoughts that cross my mind.
        </Paragraph>
      </Article>
      <h3 className="text-base font-semibold text-black 2xl:text-lg dark:text-gray-100 mb-[-24px]">
        Latest articles
      </h3>
      <BlogGrid data={blog} />
    </MainSection>
  );
};

export async function getStaticProps() {
  const blogInfo = blogQuery();
  const blog = await client.fetch(blogInfo);

  return {
    props: {
      blog,
    },
  };
}

export default BlogPage;

Individual article pages

Now that we created our blog index page, it's time to create the dynamic routes that will represent each one of our articles. You can create these routes with any dynamic value, in this case, we'll be using our articles slugs.

Inside the blog folder, let's create another file that we'll be calling [slug].jsx. The square brackets represent a dynamic parameter. So if we have an article with "my-amazing-article" as a slug, the route will be /blog/my-amazing-article.

import React from "react";
import dynamic from "next/dynamic";
import Image from "next/image";
import { MainSection, CustomPortableText } from "../../components";
import {
  blogPostQuery,
  blogPostReadingTimeQuery,
  blogQuery,
} from "../../constants/queries";
const DynamicSocialShare = dynamic(() =>
  import("../../components/SocialShare")
);
import { client } from "../../sanity/client";
import { dateFormat, readingTimeFormat } from "../../utils/helpers";

const BlogArticle = ({ post, readingTime, date }) => {
  return (
    <MainSection
      title={post.articleTitle}
      description={post.excerpt}
      canonicalUrlPath={`/blog/${post.slug.current}`}
      socialCardImage={post.socialShareImage && post.socialShareImage.asset.url}
      contentType="article"
    >
      <article className="flex flex-col gap-5">
        <div className="flex flex-col w-full gap-3">
          <div className="relative flex-shrink-0 w-14 h-14">
            <Image
              src={post.coverImage.image.asset.url}
              placeholder="blur"
              blurDataURL={post.coverImage.image.asset.url}
              alt={post.coverImage.altText}
              layout="fill"
              objectFit="contain"
              sizes="56px"
            />
          </div>
          <h2 className="text-2xl font-bold text-black dark:text-gray-100 2xl:text-3xl">
            {post.articleTitle}
          </h2>
        </div>
        <div className="flex items-center gap-3">
          <span className="text-xs text-black 2xl:text-sm dark:text-gray-400">
            {date}
          </span>
          <span className="text-xs text-black underline 2xl:text-sm dark:text-gray-400">
            {readingTime}
          </span>
        </div>
        <CustomPortableText value={post.articleBody} />
        <DynamicSocialShare slug={post.slug.current} />
      </article>
    </MainSection>
  );
};

export async function getStaticPaths() {
  const blogInfo = blogQuery();
  const blogPosts = await client.fetch(blogInfo);

  const paths = blogPosts.map((post) => ({
    params: { slug: post.slug.current },
  }));

  return {
    paths,
    fallback: false,
  };
}

export async function getStaticProps({ params }) {
  const postInfo = blogPostQuery(params.slug);
  const readingTimeInfo = blogPostReadingTimeQuery(params.slug);
  const post = await client.fetch(postInfo);
  const readingTime = await client.fetch(readingTimeInfo);

  const { estimatedReadingTime } = readingTime[0];
  const readingTimeText = readingTimeFormat(estimatedReadingTime);
  const { publishDate } = post[0];
  const date = dateFormat(publishDate);

  return {
    props: {
      post: post[0],
      readingTime: readingTimeText,
      date,
    },
  };
}

export default BlogArticle;

We talked a bit about getStaticProps and how it works, but there is also this function called getStaticPaths. Think about it, how can we tell Next.js that we have all these individual article pages that we also want to pre-render? We need to define a list of all the paths that we want to statically generate, and that's exactly what getStaticPaths does for us. After defining all our dynamic static paths, we can fetch all the data for each one of them in our getStaticProps function. Great, isn't it?

Custom portable text component

You probably noticed this component called CustomPortableText, this is what will allow us to properly format and render Sanity's portable text. This is basically the body of our article and this is why we installed React Portable Text library in the first place.

This will allow us to serialize the arrays that contain our content into the format that we need.

So, let's create that component inside our components folder.

import React from "react";
import dynamic from "next/dynamic";
import { PortableText } from "@portabletext/react";
import { Paragraph, SuspenseWrapper } from "../../components";
const DynamicCustomCode = dynamic(() => import("./CustomCode"), {
  suspense: true,
});
const DynamicCustomImage = dynamic(() => import("./CustomImage"), {
  suspense: true,
});
import { getImageDimensions } from "@sanity/asset-utils";
import { urlFor } from "../../sanity/client";

const components = {
  block: {
    h3: ({ children }) => (
      <h3 className="text-xl font-semibold text-black 2xl:text-2xl dark:text-gray-100">
        {children}
      </h3>
    ),
    h4: ({ children }) => (
      <h4 className="text-lg font-semibold text-black 2xl:text-xl dark:text-gray-100">
        {children}
      </h4>
    ),
    normal: ({ children }) => <Paragraph>{children}</Paragraph>,
    blockquote: ({ children }) => (
      <blockquote className="pl-2 text-sm italic text-black border-l-2 2xl:text-base dark:text-gray-100 border-l-pink-800 dark:border-l-pink-600">
        {children}
      </blockquote>
    ),
  },

  types: {
    articleImage: ({ value }) => {
      const { width, height } = getImageDimensions(value?.image);
      const imageUrl = urlFor(value?.image).url();
      return (
        <SuspenseWrapper
          loadingMessage="Loading image"
          containerHeight={height}
          threshold={0.15}
        >
          <DynamicCustomImage
            image={imageUrl}
            caption={value?.caption}
            altText={value?.altText}
            width={width}
            height={height}
          />
        </SuspenseWrapper>
      );
    },
    customCode: ({ value }) => (
      <>
        <div className="flex justify-between items-center mb-[-28px]">
          <div className="flex-1 text-base italic tracking-tighter text-black dark:text-gray-100 2xl:text-lg">
            {value?.codeFilename}
          </div>
          <div className="py-1 text-base font-semibold text-black uppercase dark:text-gray-100 2xl:text-lg">
            {value?.code?.language}
          </div>
        </div>
        <SuspenseWrapper loadingMessage="Loading code" rootMargin="-10px">
          <DynamicCustomCode
            code={value?.code?.code}
            language={value?.code?.language}
          />
        </SuspenseWrapper>
      </>
    ),
  },
  marks: {
    em: ({ children }) => <em className="italic">{children}</em>,
    strong: ({ children }) => <strong className="font-bold">{children}</strong>,
    code: ({ children }) => (
      <code className="px-1 text-base italic tracking-tighter text-black bg-gray-200 dark:bg-gray-800 dark:text-gray-300 2xl:text-lg">
        {children}
      </code>
    ),
    link: ({ value, children }) => {
      const rel = value?.isExternal ? "noreferrer noopener" : undefined;
      const target = value?.isExternal ? "_blank" : undefined;
      return (
        <a
          href={value?.href}
          rel={rel}
          target={target}
          className="text-pink-800 underline dark:text-pink-600"
        >
          {children}
        </a>
      );
    },
  },

  list: {
    bullet: ({ children }) => (
      <ul className="flex flex-col gap-2 pl-3">{children}</ul>
    ),
    number: ({ children }) => (
      <ol className="flex flex-col gap-2 pl-3">{children}</ol>
    ),
  },

  listItem: {
    bullet: ({ children }) => (
      <li
        className="text-base text-black dark:text-gray-300 2xl:text-lg"
        style={{ listStyleType: "disc" }}
      >
        {children}
      </li>
    ),
    number: ({ children }) => (
      <li
        className="text-base text-black dark:text-gray-300 2xl:text-lg"
        style={{ listStyleType: "decimal" }}
      >
        {children}
      </li>
    ),
  },
};

const CustomPortableText = ({ value }) => {
  return <PortableText value={value} components={components} />;
};

export default CustomPortableText;

The PortableText component takes 2 props: The value which will be the rich text content fetched from Sanity and components, which is an object where we can customize how each element of the portable text should be rendered in our front-end. Feel free to let your imagination fly and make this your own, there are endless possibilities.

And that's basically it, now we have a nice blog section where we can write amazing articles, share our knowledge, opinions, experience, etc.

That's all for this article, hope you found it useful and/or entertaining. See you in the next one.