DEV Community

loading...
Cover image for Build a Food Blog with Next.js, MDX, Tailwind CSS, and TypeScript

Build a Food Blog with Next.js, MDX, Tailwind CSS, and TypeScript

Koji Mochizuki
Developer | TypeScript React Next.js | Web3 dApps enthusiast
Originally published at kjmczk.dev ・8 min read

I think it's a good idea to write a blog post using Markdown. But if you have text to reuse, it doesn't make sense to copy and paste it into an article every time. If you want to modify that text, you will have to modify all markdown files. MDX allows you to write JSX in your markdown files, making it easy and flexible to create and update articles.

In this tutorial, we'll build a food recipes blog with Next.js, MDX, Tailwind CSS, and TypeScript. Food blogs use headlines such as "Ingredients" and "Directions" in every article, so it makes sense to use MDX.

Create a Next.js App

Create a new Next.js app using create-next-app, which is named "my-food-blog" in this tutorial:

yarn create next-app my-food-blog
Enter fullscreen mode Exit fullscreen mode

Then, start your dev server by running the following command:

cd my-food-blog
yarn dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000 from your browser. You should see a page that says "Welcome to Next.js!".

Install Dependencies

For MDX:

yarn add next-mdx-remote gray-matter
Enter fullscreen mode Exit fullscreen mode

For Tailwind CSS:

yarn add tailwindcss postcss autoprefixer @tailwindcss/typography
Enter fullscreen mode Exit fullscreen mode

For TypeScript:

yarn add --dev typescript @types/react @types/react-dom @types/node
Enter fullscreen mode Exit fullscreen mode

Convert Existing JavaScript to TypeScript

Rename the two .js files to .tsx files:

  1. pages/_app.js -> _app.tsx
  2. pages/index.js -> index.tsx

We won't use the api/hello.js file, so you can delete it.

Creating TSConfig

Running yarn dev again will generate the next-env.d.ts and tsconfig.json files in your project root.

Open the tsconfig.json file and turn on strict mode:

{
  "compilerOptions": {
    ...
    "strict": true,
    ...
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

This will enable strict type checking.

Setting Up Tailwind CSS

Create Config Files

The following command will generate the tailwind.config.js and postcss.config.js files:

yarn tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

Configure Tailwind

Open the tailwind.config.js file and configure it as follows:

// tailwind.config.js
module.exports = {
  purge: ['./components/**/*.tsx', './pages/**/*.tsx'],
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {},
  },
  variants: {},
  plugins: [require('@tailwindcss/typography')],
};
Enter fullscreen mode Exit fullscreen mode

Configuring the purge option will automatically remove unused styles from CSS in production.

Adding the @tailwindcss/typography plugin allows you to use a set of prose classes.

Include Tailwind

Open pages/_app.tsx and import Tailwind directly in it:

// pages/_app.tsx
import type { AppProps } from 'next/app';

// import '../styles/globals.css' // remove
import 'tailwindcss/tailwind.css'; // add

const MyApp: React.FC<AppProps> = ({ Component, pageProps }: AppProps) => {
  return <Component {...pageProps} />;
};

export default MyApp;
Enter fullscreen mode Exit fullscreen mode

In addition, add the built-in type AppProps as above while we are here.

Now that the basic configuration and setup are complete, let's create the contents.

Create a Post List Page

Open pages/index.tsx and rewrite all the code in it as follows:

// pages/index.tsx
import { GetStaticProps } from 'next';
import Head from 'next/head';
import Link from 'next/link';

import Layout from '../components/Layout';
import Thumbnail from '../components/Thumbnail';
import { IPost } from '../types/post';
import { SITE_NAME } from '../utils/constants';
import { getAllPosts } from '../utils/mdxUtils';

type Props = {
  posts: IPost[];
};

const Home: React.FC<Props> = ({ posts }: Props) => {
  return (
    <Layout>
      <Head>
        <title>{SITE_NAME}</title>
      </Head>

      <h1 className="text-4xl font-bold mb-4">Recipes</h1>

      <div className="space-y-12">
        {posts.map((post) => (
          <div key={post.slug}>
            <div className="mb-4">
              <Thumbnail
                slug={post.slug}
                title={post.title}
                src={post.thumbnail}
              />
            </div>

            <h2 className="text-2xl font-bold mb-4">
              <Link href={`/posts/${post.slug}`}>
                <a>{post.title}</a>
              </Link>
            </h2>

            <p>{post.description}</p>
          </div>
        ))}
      </div>
    </Layout>
  );
};

export default Home;

export const getStaticProps: GetStaticProps = async () => {
  const posts = getAllPosts([
    'slug',
    'date',
    'thumbnail',
    'title',
    'description',
  ]);

  return { props: { posts } };
};
Enter fullscreen mode Exit fullscreen mode

Use getStaticProps to fetch a list of posts at build time.

This page requires several files, so let's create them one by one.

mdxUtils

Create utils/mdxUtils.ts in your project root:

// utils/mdxUtils.ts
import fs from 'fs';
import { join } from 'path';
import matter from 'gray-matter';

type Items = {
  [key: string]: string;
};

type Post = {
  data: {
    [key: string]: string;
  };
  content: string;
};

const POSTS_PATH = join(process.cwd(), '_posts');

function getPostFilePaths(): string[] {
  return (
    fs
      .readdirSync(POSTS_PATH)
      // Only include md(x) files
      .filter((path) => /\.mdx?$/.test(path))
  );
}

export function getPost(slug: string): Post {
  const fullPath = join(POSTS_PATH, `${slug}.mdx`);
  const fileContents = fs.readFileSync(fullPath, 'utf8');
  const { data, content } = matter(fileContents);

  return { data, content };
}

export function getPostItems(filePath: string, fields: string[] = []): Items {
  const slug = filePath.replace(/\.mdx?$/, '');
  const { data, content } = getPost(slug);

  const items: Items = {};

  // Ensure only the minimal needed data is exposed
  fields.forEach((field) => {
    if (field === 'slug') {
      items[field] = slug;
    }
    if (field === 'content') {
      items[field] = content;
    }

    if (data[field]) {
      items[field] = data[field];
    }
  });

  return items;
}

export function getAllPosts(fields: string[] = []): Items[] {
  const filePaths = getPostFilePaths();
  const posts = filePaths
    .map((filePath) => getPostItems(filePath, fields))
    // sort posts by date in descending order
    .sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
  return posts;
}
Enter fullscreen mode Exit fullscreen mode

This utility file is based on that of blog-starter-typescript and I modified it for MDX.

Each MDX file in the _posts directory (which we will create later) is read and gray-matter returns an object containing its front matter and content.

constants

Create utils/constants.ts:

// utils/constants.ts
export const SITE_URL = 'http://localhost:3000';
export const SITE_NAME = 'My Food Blog';
export const TWITTER_USERNAME = '@MyAwesomeFoodBlog';
Enter fullscreen mode Exit fullscreen mode

IPost

Create types/post.ts in your project root and declare the post object structure using the interface as follows:

// types/post.ts
export interface IPost {
  slug: string;
  date: string;
  thumbnail: string;
  title: string;
  description: string;
  yields: string;
  ingredients: string[];
  directions: string[];
  tips: string[];
}
Enter fullscreen mode Exit fullscreen mode

Layout Component

Let's create a Layout component that will be shared across all pages.

Create components/Layout.tsx in your project root:

// components/Layout.tsx
import Header from '../components/Header';
import Meta from '../components/Meta';

type Props = {
  children: React.ReactNode;
  pageTitle?: string;
};

const Layout: React.FC<Props> = ({ children, pageTitle }: Props) => {
  return (
    <>
      <Meta pageTitle={pageTitle} />

      <div className="max-w-prose mx-auto px-4">
        <Header />
        <main className="pt-4 pb-12">{children}</main>
      </div>
    </>
  );
};

export default Layout;
Enter fullscreen mode Exit fullscreen mode

Header Component

Create components/Header.tsx:

// components/Header.tsx
import Link from 'next/link';

import { SITE_NAME } from '../utils/constants';

const Header: React.FC = () => {
  return (
    <header className="py-2">
      <Link href="/">
        <a className="text-2xl font-bold text-green-500">{SITE_NAME}</a>
      </Link>
    </header>
  );
};

export default Header;
Enter fullscreen mode Exit fullscreen mode

Separating each part as a component makes it easier to manage.

Meta Component

Create components/Meta.tsx:

// components/Meta.tsx
import Head from 'next/head';
import { useRouter } from 'next/router';

import { SITE_URL, SITE_NAME, TWITTER_USERNAME } from '../utils/constants';

type Props = {
  pageTitle?: string;
};

const meta = {
  description: `${SITE_NAME} provides awesome food recipes.`,
  ogImagePath: '/assets/card-image.webp',
};

const Meta: React.FC<Props> = ({ pageTitle }: Props) => {
  const router = useRouter();
  const ogUrl = SITE_URL + router.asPath;
  const ogType = router.pathname === '/' ? 'website' : 'article';
  const ogTitle = pageTitle ? pageTitle : 'Awesome food recipes';
  const ogImage = SITE_URL + meta.ogImagePath;

  return (
    <Head>
      <title>{`${pageTitle} | ${SITE_NAME}`}</title>
      <link
        rel="apple-touch-icon"
        sizes="180x180"
        href="/favicon/apple-touch-icon.png"
      />
      <link
        rel="icon"
        type="image/png"
        sizes="32x32"
        href="/favicon/favicon-32x32.png"
      />
      <link
        rel="icon"
        type="image/png"
        sizes="16x16"
        href="/favicon/favicon-16x16.png"
      />
      <link rel="manifest" href="/favicon/site.webmanifest" />
      <link
        rel="mask-icon"
        href="/favicon/safari-pinned-tab.svg"
        color="#5bbad5"
      />
      <link rel="shortcut icon" href="/favicon/favicon.ico" />
      <meta name="msapplication-TileColor" content="#00a300" />
      <meta name="msapplication-config" content="/favicon/browserconfig.xml" />
      <meta name="theme-color" content="#fff" />
      <link rel="alternate" type="application/rss+xml" href="/feed.xml" />
      <meta name="description" content={meta.description} key="description" />
      <meta property="og:url" content={ogUrl} />
      <meta property="og:type" content={ogType} />
      <meta property="og:site_name" content={SITE_NAME} />
      <meta property="og:title" content={ogTitle} />
      <meta
        property="og:description"
        content={meta.description}
        key="ogDescription"
      />
      <meta property="og:image" content={ogImage} key="ogImage" />
      <meta name="twitter:card" content="summary_large_image" />
      <meta name="twitter:site" content={TWITTER_USERNAME} />
    </Head>
  );
};

export default Meta;
Enter fullscreen mode Exit fullscreen mode

For tags that have different content on each page and need to be overwritten, such as description and og: image, you can use the key property to avoid duplicate tags.

Thumbnail Component

Next.js has a built-in Image Component and Automatic Image Optimization since version 10.

Create components/Thumbnail.tsx:

// components/Thumbnail.tsx
import Image from 'next/image';
import Link from 'next/link';

type Props = {
  title: string;
  src: string;
  slug?: string;
};

const Thumbnail: React.FC<Props> = ({ title, src, slug }: Props) => {
  const image = (
    <Image
      src={src}
      alt={`Cover Image for ${title}`}
      width={1280}
      height={720}
    />
  );
  return (
    <>
      {slug ? (
        <Link href={`/posts/${slug}`}>
          <a aria-label={title}>{image}</a>
        </Link>
      ) : (
        image
      )}
    </>
  );
};

export default Thumbnail;
Enter fullscreen mode Exit fullscreen mode

You can download and use the static files in my repo for testing.

Create MDX Documents

You can use the sample files in my repo for testing.

Create a directory called _posts in your project root and add files to it.

Now, start the dev server and see the result on your browser:

Post List Page

We haven't created a content page yet, so clicking on any recipe link should bring up a 404 page. Now let's create it.

Create a Post Content Page

In Next.js, you can create dynamic routes by using square brackets.

Create pages/posts/[slug].tsx:

// pages/posts/[slug].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import { serialize } from 'next-mdx-remote/serialize';
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote';

import Directions from '../../components/Directions';
import Ingredients from '../../components/Ingredients';
import Layout from '../../components/Layout';
import Thumbnail from '../../components/Thumbnail';
import { IPost } from '../../types/post';
import { SITE_URL } from '../../utils/constants';
import { getPost, getAllPosts } from '../../utils/mdxUtils';

type Props = {
  source: MDXRemoteSerializeResult;
  frontMatter: Omit<IPost, 'slug'>;
};

const components = {
  Ingredients,
  Directions,
  Tips: dynamic(() => import('../../components/Tips')),
};

const PostPage: React.FC<Props> = ({ source, frontMatter }: Props) => {
  const ogImage = SITE_URL + frontMatter.thumbnail;

  return (
    <Layout pageTitle={frontMatter.title}>
      <Head>
        <meta
          name="description"
          content={frontMatter.description}
          key="description"
        />
        <meta
          property="og:description"
          content={frontMatter.description}
          key="ogDescription"
        />
        <meta property="og:image" content={ogImage} key="ogImage" />
      </Head>

      <article className="prose prose-green">
        <div className="mb-4">
          <Thumbnail title={frontMatter.title} src={frontMatter.thumbnail} />
        </div>

        <h1>{frontMatter.title}</h1>

        <p className="font-bold">yield: {frontMatter.yields}</p>

        <p>{frontMatter.description}</p>

        <MDXRemote {...source} components={components} />
      </article>
    </Layout>
  );
};

export default PostPage;

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const { content, data } = getPost(params?.slug as string);

  const mdxSource = await serialize(content, { scope: data });

  return {
    props: {
      source: mdxSource,
      frontMatter: data,
    },
  };
};

export const getStaticPaths: GetStaticPaths = async () => {
  const posts = getAllPosts(['slug']);

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

  return {
    paths,
    fallback: false,
  };
};
Enter fullscreen mode Exit fullscreen mode

This page uses the serialize function and the MDXRemote component provided by next-mdx-remote. serialize is used in getStaticProps because it is intended to be executed on the server side. On the other hand, <MDXRemote /> is intended to be executed on the client side. The MDX contents are loaded using these two.

If your dynamic route has getStaticProps, you need to add getStaticPaths to define a list of paths.

The optional parameters in the second argument of serialize can be easily understood by looking at the MDX files. Open _posts/how-to-make-stock-from-kelp-and-bonito-flakes.mdx:

MDX File

By specifying the scope parameter of serialize, the values of the front matter can be passed to each component as props.

If you have additional information, just write it anywhere you like with Markdown syntax.

Now let's create the components used in the MDX files.

Ingredients Component

Create components/Ingredients.tsx:

// components/Ingredients.tsx
type Props = {
  ingredients: string[];
};

const Ingredients: React.FC<Props> = ({ ingredients }: Props) => {
  return (
    <>
      <h2>Ingredients</h2>
      <ul>
        {ingredients.map((ingredient, index) => (
          <li key={index}>{ingredient}</li>
        ))}
      </ul>
    </>
  );
};

export default Ingredients;
Enter fullscreen mode Exit fullscreen mode

Directions Component

Create components/Directions.tsx:

// components/Directions.tsx
type Props = {
  directions: string[];
};

const Directions: React.FC<Props> = ({ directions }: Props) => {
  return (
    <>
      <h2>Directions</h2>
      <ol>
        {directions.map((direction, index) => (
          <li key={index}>{direction}</li>
        ))}
      </ol>
    </>
  );
};

export default Directions;
Enter fullscreen mode Exit fullscreen mode

Tips Component

Create components/Tips.tsx:

// components/Tips.tsx
type Props = {
  tips: string[];
};

const Tips: React.FC<Props> = ({ tips }: Props) => {
  return (
    <>
      <h2>Tips</h2>
      <ul>
        {tips.map((tip, index) => (
          <li key={index}>{tip}</li>
        ))}
      </ul>
    </>
  );
};

export default Tips;
Enter fullscreen mode Exit fullscreen mode

Now go to your browser and click on the recipe links. The content of each page should have been displayed perfectly.

🎉🎉🎉

Conclusion

In this tutorial, we created a simple blog with Next.js and MDX. Next time, I'll write about how to add dark mode to this blog, so stay tuned.

The source code for this tutorial can be found on GitHub.

Discussion (0)