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
then run dev server:
npm run devStep 2: Add Tailwind CSS
Run the following command inside your project directory:
npx astro add tailwindThis 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 mdxAfter 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/newsletterandsrc/content/authors. Only content collections are allowed inside thesrc/contentdirectory. 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.
Then define a Collection by adding a src/content/config.ts file.
The
src/content/config.tsfile 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 devWe 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-slugHere 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-dtsI 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 sitemapMore 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 buildStep: 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.