Building a Modern Blog with Next.js, TypeScript, and Nextra 4
Overview
This tutorial will guide you through creating a feature-rich blog similar to this one, including:
- Nextra 4 with blog theme
- Next.js 15 with TypeScript
- MDX support for rich content
- TailwindCSS 4 for styling
- Tag system with filtering
- RSS feed generation
- Search functionality with Pagefind
- Dark/light theme switching
- Reading time calculation
- SEO optimization
Feel free to copy the content of this post and paste it into Cursor if you want to get started quickly, claude-4-sonnet can easily generate the code for you.
Step 1: Project Setup
Initialize the Project
npx create-next-app@latest my-blog --typescript --tailwind --app
cd my-blogInstall Dependencies
npm install nextra@^4.2.17 nextra-theme-blog@^4.2.17
npm install -D pagefind@^1.3.0Update package.json
{
"name": "my-blog",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "pagefind --site .next/server/app --output-path public/_pagefind",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.3.5",
"nextra": "^4.2.17",
"nextra-theme-blog": "^4.2.17",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.5",
"tailwindcss": "^4",
"typescript": "^5",
"pagefind": "^1.3.0"
}
}Step 2: Configure Next.js and Nextra
Create next.config.ts
import nextra from "nextra";
const withNextra = nextra({
defaultShowCopyCode: true,
readingTime: true,
});
export default withNextra({
reactStrictMode: true,
cleanDistDir: true,
});Update tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}Step 3: Set Up the File Structure
Create the following directory structure:
src/
├── app/
│ ├── components/
│ │ └── ActiveNavLink.jsx
│ ├── posts/
│ │ ├── get-posts.js
│ │ ├── page.jsx
│ │ └── [your-first-post]/
│ │ └── page.mdx
│ ├── rss.xml/
│ │ └── route.js
│ ├── tags/
│ │ └── [tag]/
│ │ └── page.jsx
│ ├── _meta.global.js
│ ├── globals.css
│ ├── layout.jsx
│ └── page.jsx
└── mdx-components.tsxStep 4: Create Core Components
Create src/app/layout.jsx
import { Footer, Layout, Navbar, ThemeSwitch } from "nextra-theme-blog";
import { Head, Search } from "nextra/components";
import { getPageMap } from "nextra/page-map";
import { ActiveNavLink } from "./components/ActiveNavLink";
import "nextra-theme-blog/style.css";
import "./globals.css";
export const metadata = {
title: "Your Blog Name",
description: "Your blog description",
};
export default async function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<Head backgroundColor={{ dark: "#0f172a", light: "#fefce8" }} />
<body>
<Layout>
<Navbar pageMap={await getPageMap()}>
<ThemeSwitch />
</Navbar>
{children}
<Footer>{new Date().getFullYear()} © Your Name.</Footer>
</Layout>
</body>
</html>
);
}Create src/app/components/ActiveNavLink.jsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export function ActiveNavLink({ href, children, ...props }) {
const pathname = usePathname();
const isActive = pathname === href;
return (
<Link
href={href}
className={isActive ? "nextra-nav-link active" : "nextra-nav-link"}
{...props}
>
{children}
</Link>
);
}Create src/app/_meta.global.js
export default {
index: {
type: "page",
},
posts: {
type: "page",
},
};Step 5: Set Up MDX Configuration
Create mdx-components.tsx
import { useMDXComponents as getBlogMDXComponents } from "nextra-theme-blog";
import type { MDXComponents } from "mdx/types";
const blogComponents = getBlogMDXComponents({
h1: ({ children }) => <h1>{children}</h1>,
DateFormatter: ({ date }) =>
`Last updated at ${date.toLocaleDateString("en", {
day: "numeric",
month: "long",
year: "numeric",
})}`,
});
export function useMDXComponents(components: MDXComponents) {
return {
...blogComponents,
...components,
};
}Step 6: Create Post Management System
Create src/app/posts/get-posts.js
import { normalizePages } from "nextra/normalize-pages";
import { getPageMap } from "nextra/page-map";
export async function getPosts() {
const { directories } = normalizePages({
list: await getPageMap("/posts"),
route: "/posts",
});
return directories
.filter((post) => post.name !== "index")
.sort(
(a, b) => new Date(b.frontMatter.date) - new Date(a.frontMatter.date)
);
}
export async function getTags() {
const posts = await getPosts();
const tags = posts.flatMap((post) => post.frontMatter.tags || []);
return tags;
}Step 7: Create Pages
Create src/app/page.jsx (Homepage)
import Link from "next/link";
import { getPosts } from "./posts/get-posts";
export const metadata = {
title: "Your Blog Name",
description: "Welcome to my blog about technology and life",
};
export default async function HomePage() {
const posts = await getPosts();
const recentPosts = posts.slice(0, 3);
return (
<div>
<div>
<h1>Your Name</h1>
<div>
<p>
Welcome to my blog! I write about technology, programming, and life.
</p>
</div>
<hr />
</div>
{/* Recent Posts Section */}
<div>
<h2>Recent Posts</h2>
<div>
{recentPosts.map((post) => (
<div key={post.route}>
<h3>
<Link href={post.route}>{post.frontMatter.title}</Link>
</h3>
{post.frontMatter.description && (
<p>{post.frontMatter.description}</p>
)}
<div>
{post.frontMatter.date && (
<time dateTime={post.frontMatter.date}>
{new Date(post.frontMatter.date).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
day: "numeric",
}
)}
</time>
)}
</div>
</div>
))}
</div>
<div style={{ margin: "32px 0" }}>
<Link href="/posts">View all posts →</Link>
</div>
</div>
</div>
);
}Create src/app/posts/page.jsx
import Link from "next/link";
import { PostCard } from "nextra-theme-blog";
import { getPosts, getTags } from "./get-posts";
export const metadata = {
title: "All Posts",
};
export default async function PostsPage() {
const tags = await getTags();
const posts = await getPosts();
const allTags = Object.create(null);
for (const tag of tags) {
allTags[tag] ??= 0;
allTags[tag] += 1;
}
return (
<div data-pagefind-ignore="all">
<h1>{metadata.title}</h1>
<div
className="not-prose"
style={{ display: "flex", flexWrap: "wrap", gap: ".5rem" }}
>
{Object.entries(allTags).map(([tag, count]) => (
<Link key={tag} href={`/tags/${tag}`} className="nextra-tag">
{tag} ({count})
</Link>
))}
</div>
{posts.map((post) => (
<PostCard key={post.route} post={post} />
))}
</div>
);
}Create src/app/tags/[tag]/page.jsx
import { PostCard } from "nextra-theme-blog";
import { getPosts, getTags } from "../../posts/get-posts";
export async function generateMetadata(props) {
const params = await props.params;
return {
title: `Posts Tagged with "${decodeURIComponent(params.tag)}"`,
};
}
export async function generateStaticParams() {
const allTags = await getTags();
return [...new Set(allTags)].map((tag) => ({ tag }));
}
export default async function TagPage(props) {
const params = await props.params;
const { title } = await generateMetadata({ params });
const posts = await getPosts();
return (
<>
<h1>{title}</h1>
{posts
.filter((post) =>
post.frontMatter.tags?.includes(decodeURIComponent(params.tag))
)
.map((post) => (
<PostCard key={post.route} post={post} />
))}
</>
);
}Step 8: Create RSS Feed
Create src/app/rss.xml/route.js
import { getPosts } from "../posts/get-posts.js";
const CONFIG = {
title: "Your Blog Name",
siteUrl: "https://yourdomain.com",
description: "Latest blog posts",
lang: "en-us",
};
export async function GET() {
const allPosts = await getPosts();
const posts = allPosts
.map(
(post) => ` <item>
<title>${post.frontMatter.title}</title>
<description>${post.frontMatter.description || ""}</description>
<link>${CONFIG.siteUrl}${post.route}</link>
<pubDate>${new Date(post.frontMatter.date).toUTCString()}</pubDate>
</item>`
)
.join("\n");
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>${CONFIG.title}</title>
<link>${CONFIG.siteUrl}</link>
<description>${CONFIG.description}</description>
<language>${CONFIG.lang}</language>
${posts}
</channel>
</rss>`;
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml",
},
});
}Step 9: Create Your First Blog Post
Create src/app/posts/my-first-post/page.mdx
---
title: My First Blog Post
date: 2024/01/15
description: Welcome to my new blog built with Next.js and Nextra!
tags: [web development, next.js, blogging]
author: Your Name
---
# My First Blog Post
Welcome to my new blog! This post demonstrates the power of MDX for creating rich, interactive content.
## Features
This blog includes:
- **MDX Support**: Write JSX directly in your markdown
- **Syntax Highlighting**: Beautiful code blocks
- **Tag System**: Organize posts by topic
- **RSS Feed**: Keep readers updated
- **Search**: Find content quickly
- **Dark Mode**: Easy on the eyes
## Code Example
Here's a simple React component:
```jsx
function HelloWorld() {
return <h1>Hello, World!</h1>;
}
```
## What's Next?
Stay tuned for more posts about web development, programming tips, and technology insights!Step 10: Add Custom Styling
Create src/app/globals.css
/* Override Nextra blog theme for custom styling */
/* Make prose content wider on desktop */
@media (min-width: 768px) {
.x\:prose {
max-width: 90ch !important; /* Increase from default ~65ch to 90ch */
}
}
/* Custom tag styling */
.nextra-tag {
@apply px-2 py-1 text-xs bg-gray-100 dark:bg-gray-800 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors;
}
.nextra-tag.active {
@apply bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200;
}
/* Custom navigation link styling */
.nextra-nav-link {
@apply px-3 py-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors;
}
.nextra-nav-link.active {
@apply bg-gray-100 dark:bg-gray-800 font-medium;
}Step 11: Run and Deploy
Development
npm run devVisit http://localhost:3000 to see your blog!
Build for Production
npm run build
npm startDeploy to Vercel
- Push your code to GitHub
- Connect your repository to Vercel
- Deploy automatically!
Step 12: Customization Ideas
Add More Features
- Newsletter Signup: Integrate with ConvertKit or Mailchimp
- Comments: Add Giscus or Disqus comments
- Analytics: Google Analytics or Plausible
- Image Optimization: Next.js Image component
- Reading Progress: Progress bar for long posts
- Related Posts: Suggest similar content
- Author Pages: Multiple author support
- Series: Group related posts
SEO Enhancements
// Add to your layout.jsx
export const metadata = {
title: {
default: "Your Blog Name",
template: "%s | Your Blog Name",
},
description: "Your blog description",
keywords: ["next.js", "blog", "typescript"],
authors: [{ name: "Your Name" }],
creator: "Your Name",
openGraph: {
type: "website",
locale: "en_US",
url: "https://yourdomain.com",
siteName: "Your Blog Name",
},
twitter: {
card: "summary_large_image",
creator: "@yourusername",
},
};Conclusion
You now have a fully functional blog with:
- Modern Next.js 15 + TypeScript setup
- Nextra 4 for easy MDX content management
- Tag system for content organization
- RSS feed for subscribers
- Search functionality
- Dark/light theme switching
- SEO optimization
- Mobile-responsive design
Your blog is ready for content creation and can be easily extended with additional features as your needs grow!
Happy blogging! To get started, you can copy the content of this post and paste it into Cursor.
2026 © Brian Chitester.