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-blog
Install Dependencies
npm install nextra@^4.2.17 nextra-theme-blog@^4.2.17
npm install -D pagefind@^1.3.0
Update 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.tsx
Step 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 dev
Visit http://localhost:3000
to see your blog!
Build for Production
npm run build
npm start
Deploy 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.