Static pages are pre-built at deploy time, so there's no server processing each request. GitHub Pages is free hosting. Next.js handles the static export, and Tailwind CSS handles styling. Here's how to wire it all together.
What we'll cover
- Setting up the project
- Tailwind CSS configuration
- Configuring Next.js for static export
- Configuring GitHub Pages deployment
Step 1: Setting up the project
First, I recommend pinning a specific Node.js version so the project behaves consistently across machines. Install nvm from the official page and run:
echo "v20.12.1" > .nvmrc
nvm install
Next, create a new Next.js project:
npx create-next-app@latest
Follow the prompts:
✔ What is your project named? … my-website
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … No
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … No
Next.js creates the project structure and includes Tailwind CSS if you selected it above. Move into the project folder:
cd my-website
Then install the extra dependencies:
npm install markdown-to-jsx gray-matter
npm install @tailwindcss/typography -D
Step 2: Tailwind CSS configuration
In tailwind.config.ts, add the typography plugin and point the content path at the src directory:
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
},
plugins: [require("@tailwindcss/typography")],
};
export default config;
src/app/globals.css also needs Tailwind's base, components, and utilities:
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
In postcss.config.mjs, add the Tailwind plugin:
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
In src/app/layout.tsx, import globals.css:
import "./globals.css";
Step 3: Configure Next.js for static export
-
In
next.config.mjs, add the following to produce a static export:/** @type {import('next').NextConfig} */ const nextConfig = { basePath: "", output: "export", reactStrictMode: true, images: { unoptimized: true, }, trailingSlash: true, skipTrailingSlashRedirect: true, // Optional: Change the output directory from `out` to another name such as `dist` // distDir: 'dist', }; export default nextConfig; -
Create a
_postsfolder in the project root for your Markdown files. Each file needs front matter at the top. Here's an examplelorem-ipsum.md:--- title: "Lorem Ipsum" excerpt: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Praesent elementum facilisis leo vel fringilla est ullamcorper eget. At imperdiet dui accumsan sit amet nulla facilities morbi tempus." date: "1970-01-01T01:00:00.000Z" --- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Praesent elementum facilisis leo vel fringilla est ullamcorper eget. At imperdiet dui accumsan sit amet nulla facilities morbi tempus. -
Create a
postfolder undersrc/app, then a[slug]folder inside it with apage.tsxfile. This gives you dynamic routing for URLs likehttp://localhost:3000/post/lorem-ipsum, wherelorem-ipsumis the Markdown filename without the.mdextension. -
Use
generateMetadataandgenerateStaticParamsinsrc/app/post/[slug]/page.tsxto read the Markdown files and output static pages:import fs from "fs"; import matter from "gray-matter"; import { notFound } from "next/navigation"; import { Metadata } from "next"; import { join } from "path"; import Markdown from "markdown-to-jsx"; export type Post = { slug: string; title: string; date: string; excerpt: string; content: string; }; type Params = { params: { slug: string; }; }; const postsDirectory = join(process.cwd(), "_posts"); function getPostSlugs(): string[] { return fs.readdirSync(postsDirectory); } function getPostBySlug(slug: string): Post { const realSlug = slug.replace(/\.md$/, ""); const fullPath = join(postsDirectory, `${realSlug}.md`); const fileContents = fs.readFileSync(fullPath, "utf8"); const { data, content } = matter(fileContents); return { ...data, slug: realSlug, content } as Post; } function getAllPosts(): Post[] { const slugs = getPostSlugs(); const posts = slugs .map((slug) => getPostBySlug(slug)) // Sort posts by date in descending order .sort((post1, post2) => (post1.date > post2.date ? -1 : 1)); return posts; } const PostPage = ({ params }: Params) => { const post = getPostBySlug(params.slug); if (!post) { return notFound(); } return ( <div> <div> <h1>{post.title}</h1> <div>{post.date}</div> </div> <article className="prose lg:prose-xl"> <Markdown>{post.content || ""}</Markdown> </article> </div> ); }; export function generateMetadata({ params }: Params): Metadata { const post = getPostBySlug(params.slug); if (!post) { return notFound(); } const title = `${post.title} | My Website`; return { title, openGraph: { title, }, }; } export async function generateStaticParams() { const posts = getAllPosts(); return posts.map((post) => ({ slug: post.slug, })); } export default PostPage;
The @tailwindcss/typography plugin adds default styles for rendered HTML: headings, paragraphs, lists, code blocks. Applying the prose class to the wrapper div activates those styles for the Markdown output.
- Modify
src/app/page.tsxto list all post titles on the home page:
import fs from "fs";
import matter from "gray-matter";
import Link from "next/link";
import { join } from "path";
export type Post = {
slug: string;
title: string;
date: string;
excerpt: string;
content: string;
};
const postsDirectory = join(process.cwd(), "_posts");
function getPostSlugs(): string[] {
return fs.readdirSync(postsDirectory);
}
function getPostBySlug(slug: string): Post {
const realSlug = slug.replace(/\.md$/, "");
const fullPath = join(postsDirectory, `${realSlug}.md`);
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(fileContents);
return { ...data, slug: realSlug, content } as Post;
}
function getAllPosts(): Post[] {
const slugs = getPostSlugs();
const posts = slugs
.map((slug) => getPostBySlug(slug))
// Sort posts by date in descending order
.sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
return posts;
}
const Home = () => {
const allPosts = getAllPosts();
const allPostPreviews = allPosts.map((post) => (
<div>
<div>{post.date}</div>
<Link as={`/post/${post.slug}`} href="/post/[slug]">
<h2>
{post.title}
</h2>
</Link>
<p>{post.excerpt}</p>
</div>
));
return (
<main>
<div>
{allPostPreviews}
</div>
</main>
);
};
export default Home;
Step 4: Configuring GitHub Pages deployment
-
Create
.github/workflows/deploy.yml:name: Deploy Next.js site to Pages on: push: branches: ["main"] workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write concurrency: group: "pages" cancel-in-progress: false jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Detect package manager id: detect-package-manager run: | if [ -f "${{ github.workspace }}/yarn.lock" ]; then echo "manager=yarn" >> $GITHUB_OUTPUT echo "command=install" >> $GITHUB_OUTPUT echo "runner=yarn" >> $GITHUB_OUTPUT exit 0 elif [ -f "${{ github.workspace }}/package.json" ]; then echo "manager=npm" >> $GITHUB_OUTPUT echo "command=ci" >> $GITHUB_OUTPUT echo "runner=npx --no-install" >> $GITHUB_OUTPUT exit 0 else echo "Unable to determine package manager" exit 1 fi - name: Setup Node uses: actions/setup-node@v4 with: node-version: "20" cache: ${{ steps.detect-package-manager.outputs.manager }} - name: Setup Pages uses: actions/configure-pages@v5 with: static_site_generator: next - name: Restore cache uses: actions/cache@v4 with: path: | .next/cache key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} restore-keys: | ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}- - name: Install dependencies run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} - name: Build with Next.js run: ${{ steps.detect-package-manager.outputs.runner }} next build - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./out # Deployment job deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 -
Create an SSH deploy key and add it to the repository's Deploy Keys settings.
-
Commit and push. The workflow runs automatically on every push to
main. -
Enable GitHub Pages in your repository settings:
- Go to Settings > Pages.
- Under Build and deployment, set Source to GitHub Actions.
-
Once the first deployment completes, the URL for your site appears under Settings > Pages.
Conclusion
You now have a static site built with Next.js, Tailwind CSS, and Markdown, deployed automatically via GitHub Actions. Pagination and tag filtering both work by filtering or slicing the array getAllPosts() returns.
