ionicons-v5-a Back
How to build a static lightweight MDX blog with Astro and Tailwind CSS: Step-by-step guide
astro

12 min read

How to build a static lightweight MDX blog with Astro and Tailwind CSS: Step-by-step guide

A full, step-by-step guide for developers seeking to implement a portfolio or blog publication with Astro.


After implementing my portfolio and blog in Next.js, I realized that Next.js may not be the most suitable choice for static site rendering, especially for content-driven websites (Why I migrated my blog publication from Next.js to Astro). My primary goal was to create a clean, lightweight website with zero JavaScript by default. Before starting with Astro, I highly recommend reading the main concepts of Astro.

Disclaimer

This article assumes you have programming skills, and it won’t cover the basics such as how to install Node.js, etc.

Step 1: Create Astro project

npm create astro@latest

Astro create project wizzard
Astro create project wizzard

then run dev server:

npm run dev

Step 2: Add Tailwind CSS

Run the following command inside your project directory:

npx astro add tailwind

This will add Tailwing to your project and will generate a minimal ./tailwind.config.mjs file.

Let’s also install and configure @tailwindcss/typography (we will need it for our blog posts typography styles).

npm install -D @tailwindcss/typography
// tailwind.config.mjs
/** @type {import('tailwindcss').Config} */
export default {
  theme: {
    // ...
  },
  plugins: [
   require('@tailwindcss/typography'),
    // ^^^
  ],
}

Let’s make sure Tailwind works. Go to src/pages/index.astro remove everything and add this code:

---
import Layout from '../layouts/Layout.astro';
---
 
<Layout title="Welcome to Astro.">
  <main class="text-red-500">HELLO WORLD</main>
</Layout>

As soon as we see the red text HELLO WORLD - Tailwind successfuly added.

Step 3: Add @astro/mdx

npx astro add mdx

After adding mdx to our project let’s render a test markdown page. But before that we need to configure Content Collections

Step 4: Configuring Astro Content Collections

A content collection is any top-level directory inside the reserved src/content project directory, such as src/content/newsletter and src/content/authors. Only content collections are allowed inside the src/content directory. This directory cannot be used for anything else.

Let’s start from configuring content collection (in our case it will be a blog) by creating blog folder inside content directory.

Blog Content Collection example
Blog Content Collection example

Then define a Collection by adding a src/content/config.ts file.

The src/content/config.ts file is optional. However, choosing not to define your collections will disable some of their best features like frontmatter schema validation or automatic TypeScript typings.

// src/content/config.ts
 
import { defineCollection, z } from 'astro:content';
 
const postsCollection = defineCollection({
  type: 'content',
  schema: ({ image }) =>
   // using zod to define type-safe frontmatter of our mdx files
   // astro will generate types definitions for our project so we can use them in templates
   // also it will check every newly created frontmatter in the content/blog directory
    z.object({
      title: z.string(),
      tags: z.array(z.string()),
      cover: image(),
      date: z.coerce.date(),
      excerpt: z.string(),
    }),
});
 
// This key should match your collection directory name in "src/content"
export const collections = {
  blog: postsCollection,
};

To generate types we need to re-run our dev server

npm run dev

We are done with configuring Astro Collections let’s move forward and switch to templates creation where we can query and render our content.

Step 5: Rendering our first blog post

Let’s add some content to our my-first-blog-post.mdx file.

---
title: "How to build a static lightweight MDX blog with Astro and Tailwind CSS: Step-by-step guide"
date: 2024-02-02
excerpt: A full, step-by-step guide for developers seeking to implement a portfolio or blog publication with Astro.
tags: [astro]
cover: ./astro-tw.png
---
 
Hey! My first blog post is here.

Astro uses file paths as routes so to render our first page as http://localhost/blog/my-first-blog-post we need src/pages/blog/[slug].astro file. This file will be our template where we will query our content and apply styles.

---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import { Image } from 'astro:assets';
import type { CollectionEntry } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
 
interface Props {
  post: CollectionEntry<'blog'>;
}
 
export async function getStaticPaths() {
  const blogPosts = await getCollection('blog');
  return blogPosts.map((post) => ({
    params: { slug: post.slug },
    props: { post },
  }));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
 
<Layout title="test-post">
  <div>
    <article>
      <Image
        src={post.data.cover}
        alt={post.data.title}
        class="object-cover object-center !m-0 aspect-square block"
        width={600}
        height={600}
      />
      <h1 class="md:!text-5xl md:!leading-[1.2]">{post.data.title}</h1>
      <p class="lead">{post.data.excerpt}</p>
      <div>
        <Content />
      </div>
    </article>
  </div>
</Layout>

By visiting http://localhost:4322/blog/my-first-blog-post we can see our first post.

Step 6: Rendering a list of blog posts

Let’s assume our blog posts will be on the main page of our publication. So let’s add some code to the src/pages/index.astro:

---
import { getCollection } from 'astro:content';
import Layout from '../layouts/Layout.astro';
 
const allPosts = await getCollection('blog');
 
---
 
<Layout title="Welcome to Astro.">
  <main>
    {allPosts.map((post) => <a href={'/blog/' +post.slug}>{post.data.title}</a>)}
  </main>
</Layout>

By visiting http://localhost:4322/ we can see our list of posts which are clickable.

Step 7: Design and Typography

In this tutorial I won’t cover the design aspects by using Tailwind CSS. But let’s see how easy it is to implement the typography styles with @tailwindcss/typography:

npm install -D @tailwindcss/typography
/** @type {import('tailwindcss').Config} */
export default {
  theme: {
    // ...
  },
  plugins: [
   require('@tailwindcss/typography'),
    // ...
  ],
}

Now we can just add a class prose to our content for it to look just nice:

<Layout title="test-post">
  <div>
    <article class="prose">
      <Image
        src={post.data.cover}
        alt={post.data.title}
        class="object-cover object-center !m-0 aspect-square block"
        width={600}
        height={600}
      />
      <h1 class="md:!text-5xl md:!leading-[1.2]">{post.data.title}</h1>
      <p class="lead">{post.data.excerpt}</p>
      <div>
        <Content />
      </div>
    </article>
  </div>
</Layout>

Step 8: Configuring MDX add-ons

While converting our MDX files to HTML we might need some features like code highlighting or headings with id.

@astrojs/mdx allows us to use rehype plugins inside to achieve this goal.

npm i rehype-pretty-code rehype-slug

Here is an example of how to add code highlighting and slugify headings:

import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
 
// https://astro.build/config
export default defineConfig({
  site: 'https://www.kozhuhds.com',
  integrations: [
    tailwind(),
    mdx({
      syntaxHighlight: false,
      rehypePlugins: [
        /**
         * Adds ids to headings
         */
        rehypeSlug,
        [
          /**
           * Enhances code blocks with syntax highlighting, line numbers,
           * titles, and allows highlighting specific lines and words
           */
 
          rehypePrettyCode,
          {
            theme: 'github-dark',
          },
        ],
      ],
    }),
  ],
});

Now you will have ids in the headings and ability to write code snippets in your MDX files.

Step 9: Metadata

The custom Metadata component with meta tags example and generating og:image via @vercel/og available in the article: Generating static Open Graph (OG) images in Astro using @vercel/og

Step 10: Structured data

Structured data is really important peace of our publication for search engines to understand the content better.

Let’s start from installing schema types:

npm i schema-dts

I use a separate file for storing structured data pieces for re-using:

// structuredData.ts
import { type Article, type Person, type WebSite, type WithContext } from 'schema-dts';
import avatar from '../public/kd.png';
import type { CollectionEntry } from 'astro:content';
 
export const blogWebsite: WithContext<WebSite> = {
  '@context': 'https://schema.org',
  '@type': 'WebSite',
  url: `${import.meta.env.SITE}/blog/`,
  name: 'Dzmitry Kozhukh blog',
  description: 'Frontend insights',
  inLanguage: 'en_US',
};
 
export const mainWebsite: WithContext<WebSite> = {
  '@context': 'https://schema.org',
  '@type': 'WebSite',
  url: import.meta.env.SITE,
  name: 'Dzmitry Kozhukh - Personal page',
  description: "Dzmitry Kozhukh's contact page, portfolio and blog",
  inLanguage: 'en_US',
};
 
export const personSchema: WithContext<Person> = {
  '@context': 'https://schema.org',
  '@type': 'Person',
  name: 'Dzmitry Kozhukh',
  url: 'https://kozhuhds.com',
  image: `${import.meta.env.SITE}${avatar.src}`,
  sameAs: [
    'https://www.facebook.com/kozhuhds',
    'https://www.instagram.com/kozhuhds/',
    'https://www.linkedin.com/in/kozhuhds/',
  ],
  jobTitle: 'Front-end developer',
  worksFor: {
    '@type': 'Organization',
    name: 'Grafana',
    url: 'https://grafana.com',
  },
};
 
export function getArticleSchema(post: CollectionEntry<'blog'>) {
  const articleStructuredData: WithContext<Article> = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.data.title,
    url: `${import.meta.env.SITE}/blog/${post.slug}/`,
    image: {
      '@type': 'ImageObject',
      url: `${import.meta.env.SITE}${post.data.cover.src}/`,
    },
    description: post.data.excerpt,
    datePublished: post.data.date.toString(),
    publisher: {
      '@type': 'Person',
      name: 'Dzmitry Kozhukh',
      url: import.meta.env.SITE,
      image: import.meta.env.SITE + avatar.src,
    },
    author: {
      '@type': 'Person',
      name: 'Dzmitry Kozhukh',
      url: import.meta.env.SITE,
      image: import.meta.env.SITE + avatar.src,
    },
  };
  return articleStructuredData;
}

Now let’s return to our template for a blog post (src/pages/blog/[slug].astro):

---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import { Image } from 'astro:assets';
import type { CollectionEntry } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
import { getArticleSchema } from '../../structuredData';
import { blogWebsite } from '../../structuredData';
 
interface Props {
  post: CollectionEntry<'blog'>;
}
 
const articleStructuredData = getArticleSchema(post);
 
const breadcrumbsStructuredData = {
  '@context': 'https://schema.org',
  '@type': 'BreadcrumbList',
  itemListElement: [
    {
      '@type': 'ListItem',
      position: 1,
      name: 'Blog',
      item: `${import.meta.env.SITE}/blog/`,
    },
    {
      '@type': 'ListItem',
      position: 2,
      name: post.data.title,
      item: `${import.meta.env.SITE}/blog/${post.slug}/`,
    },
  ],
};
 
const jsonLd = {
  '@context': 'https://schema.org',
  '@graph': [articleStructuredData, breadcrumbsStructuredData, blogWebsite],
};
 
...
---
 
<Layout title="test-post">
  <script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
  ...
</Layout>

Step 11: Sitemap

Adding a sitemap with Astro is just a single command:

npx astro add sitemap

More details about configuring the sitemap is here.

Step 12: Custom 404 page

// pages/404.astro
---
import Layout from '../layouts/Layout.astro';
 
const title = 'Frontend Blog - Not Found';
---
 
<Layout title={title}>
  <main>404 not found</main>
</Layout>

Custom 404 page can be useful when you want to render some recent or random blog posts so user can navigate from here.

Step 13: Build

Let’s build our publication and see how it looks file-wise.

npm run build

Step: 14: Deploying our publication

It’s up to you which tool to use for serving your static publication. I use vercel for my personal website. More details in the video.


Dzmitry Kozhukh

Written by Dzmitry Kozhukh

Frontend developer


Next