brianchitester.comPostsPolymath Profiles

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:

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

  1. Push your code to GitHub
  2. Connect your repository to Vercel
  3. Deploy automatically!

Step 12: Customization Ideas

Add More Features

  1. Newsletter Signup: Integrate with ConvertKit or Mailchimp
  2. Comments: Add Giscus or Disqus comments
  3. Analytics: Google Analytics or Plausible
  4. Image Optimization: Next.js Image component
  5. Reading Progress: Progress bar for long posts
  6. Related Posts: Suggest similar content
  7. Author Pages: Multiple author support
  8. 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:

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.

2025 © Brian Chitester.