Build a Lightning-Fast Blog with Next.js and MDX in 2026

Static blogs built on Next.js with MDX occupy a sweet spot that very few other stacks match: the authoring flexibility of Markdown, the full power of React components inside content, and a deployment model that produces pre-rendered HTML with zero runtime overhead. For IT consultants, documentation sites, and portfolio blogs — especially those targeting Indian markets where mobile performance on 4G matters — this combination consistently produces PageSpeed scores in the high 90s or perfect 100.

The catch is that the tooling landscape shifted significantly over the past two years. Contentlayer, which was the community's preferred solution for typed MDX content in Next.js, was abandoned by its maintainer in 2024. Many tutorials still reference it, which leads developers into a setup that works initially but has no upgrade path. This guide uses the current recommended tools and covers the full pipeline from scaffolding to deployment.

Everything here targets Next.js 15 with the App Router. The Pages Router approach is deliberately not covered — if you are building something new in 2026, App Router is the correct default.

Project Setup and Dependency Decisions

Start with create-next-app using TypeScript. The --typescript flag is not optional for a production blog — frontmatter types and MDX component prop types will save you hours of debugging as the content library grows.

npx create-next-app@latest my-blog \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"

Before installing any MDX library, understand the three options available in 2026 and what each one commits you to.

Option A: @next/mdx (Vercel-maintained, lowest overhead)

This is Next.js's own MDX integration. It compiles .mdx files directly as React components and works natively with the App Router's file-based routing. The downside: frontmatter parsing is not built in — you handle that yourself with gray-matter, and generating an index of all posts for your blog listing page requires a build-time script that reads the filesystem. It is the right choice for small blogs (under 50 posts) where you want minimal abstraction.

Option B: Velite (recommended for structured content)

Velite is the actively-maintained successor to Contentlayer. It defines a schema for your content using Zod, validates frontmatter at build time, generates TypeScript types automatically, and outputs a JSON file that Next.js can import. The developer experience is nearly identical to Contentlayer but with an active maintainer and first-class support for Next.js 15.

Option C: Direct filesystem reads with remark/rehype

This approach reads .mdx files at build time using Node's fs module, parses frontmatter with gray-matter, and compiles MDX with next-mdx-remote. It offers the most control and no abstraction layer, but requires you to write and maintain your own content index logic. Useful when you need unusual content sources (Git history as metadata, external CMS fallback, etc.).

For the rest of this guide, the Velite approach is used since it reflects what most new production blogs should reach for in 2026.

Configuring Velite for Typed MDX Content

Install Velite and the MDX compilation dependencies:

npm install velite
npm install -D @types/node

Create velite.config.ts at the project root. This file defines the schema for your blog posts — what frontmatter fields exist, their types, and any computed fields (like the URL slug derived from the filename).

import { defineConfig, defineCollection, s } from "velite";

const posts = defineCollection({
  name: "Post",
  pattern: "blog/**/*.mdx",
  schema: s
    .object({
      title: s.string().max(99),
      description: s.string().max(200),
      date: s.isodate(),
      updated: s.isodate().optional(),
      tags: s.array(s.string()).default([]),
      draft: s.boolean().default(false),
    })
    .transform((data, { meta }) => ({
      ...data,
      slug: meta.path
        .replace(/^blog\//, "")
        .replace(/\.mdx$/, ""),
      url: `/blog/${meta.path
        .replace(/^blog\//, "")
        .replace(/\.mdx$/, "")}`,
    })),
});

export default defineConfig({
  root: "content",
  output: {
    data: ".velite",
    assets: "public/static",
  },
  collections: { posts },
});

Then update next.config.ts to run Velite as part of the Next.js build process:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    turbo: {},
  },
};

// Velite integration
const withVelite = (config: NextConfig) => {
  return {
    ...config,
    webpack(webpackConfig: unknown, options: unknown) {
      // @ts-expect-error velite plugin
      webpackConfig.plugins.push(new VeliteWebpackPlugin());
      if (typeof config.webpack === "function") {
        return config.webpack(webpackConfig, options);
      }
      return webpackConfig;
    },
  };
};

export default withVelite(nextConfig);

Dynamic Routing with generateStaticParams

The App Router's file-based routing maps directly to the content structure Velite produces. Create src/app/blog/[...slug]/page.tsx to handle all blog post URLs.

import { notFound } from "next/navigation";
import { posts } from "@/.velite";
import { MDXContent } from "@/components/mdx-content";
import type { Metadata } from "next";

interface PostPageProps {
  params: Promise<{ slug: string[] }>;
}

// Pre-render all blog posts at build time
export async function generateStaticParams() {
  return posts
    .filter((post) => !post.draft)
    .map((post) => ({
      slug: post.slug.split("/"),
    }));
}

export async function generateMetadata(
  { params }: PostPageProps
): Promise<Metadata> {
  const { slug } = await params;
  const post = posts.find((p) => p.slug === slug.join("/"));
  if (!post) return {};
  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      type: "article",
      publishedTime: post.date,
      modifiedTime: post.updated,
    },
  };
}

export default async function PostPage({ params }: PostPageProps) {
  const { slug } = await params;
  const post = posts.find((p) => p.slug === slug.join("/"));
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <time dateTime={post.date}>{new Date(post.date).toLocaleDateString("en-IN")}</time>
      <MDXContent code={post.content} />
    </article>
  );
}

The generateStaticParams function tells Next.js to pre-render every blog post at build time, producing static HTML files. No server is needed at runtime. For an IT consultant portfolio deployed on Vercel or Firebase Hosting, this means every post loads from a CDN edge node — typically under 200ms TTFB from Indian cities.

Syntax Highlighting: Why Shiki Wins in 2026

Two years ago, Prism.js and Highlight.js were the default choices for syntax highlighting in MDX blogs. Both are client-side libraries — they ship JavaScript to the browser and highlight code after the page loads, which hurts PageSpeed scores and causes layout shift.

Shiki takes a fundamentally different approach: it runs entirely at build time using the same TextMate grammar engine that VS Code uses. The output is plain HTML with inline styles or CSS custom properties. Zero JavaScript ships to the browser for syntax highlighting. The colour accuracy is also noticeably better because Shiki uses the actual VS Code grammar files rather than simplified regex patterns.

Install Shiki and the rehype plugin for MDX:

npm install rehype-pretty-code shiki

Wire it into Velite's MDX pipeline in velite.config.ts:

import rehypePrettyCode from "rehype-pretty-code";

// Inside defineCollection schema options:
mdx: {
  rehypePlugins: [
    [
      rehypePrettyCode,
      {
        theme: "github-dark-dimmed",
        keepBackground: true,
        defaultLang: "plaintext",
      },
    ],
  ],
},

One practical note for Indian developer audiences: the github-dark-dimmed theme renders well on both OLED mobile screens (common in mid-range Indian phones like the Redmi Note series) and on desktop monitors. Avoid pure black themes (#000000 backgrounds) as they can cause halo effects on AMOLED displays.

Images, Fonts, and Hitting 100 PageSpeed

The next/image component handles WebP conversion, responsive srcsets, and lazy loading automatically. Inside MDX files, you cannot use JSX directly unless you configure custom MDX components. The cleaner approach is to import next/image as a custom MDX component that replaces the default img element.

// src/components/mdx-content.tsx
import * as runtime from "react/jsx-runtime";
import { evaluate } from "@mdx-js/mdx";
import Image from "next/image";

const mdxComponents = {
  img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
    <Image
      src={props.src as string}
      alt={props.alt ?? ""}
      width={800}
      height={450}
      className="rounded-lg"
      loading="lazy"
    />
  ),
  pre: (props: React.HTMLAttributes<HTMLPreElement>) => (
    <pre {...props} className="overflow-x-auto rounded-md p-4" />
  ),
};

export function MDXContent({ code }: { code: string }) {
  const { default: MDXComponent } = evaluate(code, {
    ...runtime,
    components: mdxComponents,
  });
  return <MDXComponent />;
}

For fonts, use next/font/google rather than loading Google Fonts via a <link> tag. Next.js downloads the font files at build time and self-hosts them, eliminating the third-party DNS lookup that costs 150-300ms on mobile networks.

// src/app/layout.tsx
import { Inter } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

For a static MDX blog with self-hosted fonts and build-time syntax highlighting, a PageSpeed score of 100 on desktop and 95+ on mobile is routinely achievable. The remaining mobile gap is almost always the Largest Contentful Paint image — ensure your hero/featured images are sized at 800×450px and served in WebP format.

RSS Feed and Sitemap Generation

An RSS feed is non-negotiable for a technical blog in 2026. A meaningful portion of developer readers subscribe via RSS readers (Feedly, NetNewsWire, Reeder). Generate the feed as a route handler in the App Router:

// src/app/feed.xml/route.ts
import { posts } from "@/.velite";

export async function GET() {
  const publishedPosts = posts
    .filter((p) => !p.draft)
    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
    .slice(0, 20);

  const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Rajesh R Nair — IT Consultant Blog</title>
    <link>https://rajeshrnair.com</link>
    <description>Technical articles on web development, AI, and digital marketing.</description>
    <atom:link href="https://rajeshrnair.com/feed.xml" rel="self" type="application/rss+xml"/>
    ${publishedPosts
      .map(
        (p) => `<item>
      <title><![CDATA[${p.title}]]></title>
      <link>https://rajeshrnair.com${p.url}</link>
      <guid isPermaLink="true">https://rajeshrnair.com${p.url}</guid>
      <pubDate>${new Date(p.date).toUTCString()}</pubDate>
      <description><![CDATA[${p.description}]]></description>
    </item>`
      )
      .join("\n    ")}
  </channel>
</rss>`;

  return new Response(rss, {
    headers: {
      "Content-Type": "application/xml; charset=utf-8",
      "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
    },
  });
}

For sitemaps, Next.js 15 has a built-in convention: create src/app/sitemap.ts and export a default function returning an array of URL objects. Next.js generates /sitemap.xml automatically at build time without any additional package.

// src/app/sitemap.ts
import { posts } from "@/.velite";
import type { MetadataRoute } from "next";

export default function sitemap(): MetadataRoute.Sitemap {
  const postEntries = posts
    .filter((p) => !p.draft)
    .map((p) => ({
      url: `https://rajeshrnair.com${p.url}`,
      lastModified: new Date(p.updated ?? p.date),
      changeFrequency: "monthly" as const,
      priority: 0.7,
    }));

  return [
    { url: "https://rajeshrnair.com", lastModified: new Date(), priority: 1 },
    { url: "https://rajeshrnair.com/blog", lastModified: new Date(), priority: 0.9 },
    ...postEntries,
  ];
}

Practical Use Cases: IT Consultant Portfolios in Kerala

For IT consultants based in Thiruvananthapuram, Kochi, or Kozhikode building portfolio sites, the Next.js MDX stack offers a specific advantage over WordPress or Ghost: the entire site is a Git repository. Content lives in .mdx files, committed alongside code. Version history, rollback, and collaborative editing via pull requests come for free without any plugin or hosted service.

A typical structure for a Kerala-based IT consultant's portfolio blog would separate content by service area — /blog/web-development/, /blog/seo/, /blog/ai/ — with each MDX file containing frontmatter that controls sidebar widgets and related content. Because Velite validates frontmatter at build time, broken internal links or missing required fields fail the build rather than silently producing a malformed page.

Documentation sites for SaaS products built by Kerala-based development teams are another strong fit. MDX supports embedding interactive React components — live code examples, API parameter tables, version switchers — inside documentation pages, which pure Markdown cannot do. The build-time static generation means documentation pages load fast on the 4G connections common in tier-2 Kerala cities like Kollam, Thrissur, and Palakkad.

Frequently Asked Questions

Should I use Contentlayer or Velite for MDX content management in 2026?

Contentlayer is deprecated as of 2024 and has no active maintainer. Do not start new projects with it. Velite is the recommended replacement — it offers a near-identical schema-first API, generates TypeScript types from your frontmatter definitions, and supports Next.js 15 natively. For smaller blogs, reading MDX files directly with @next/mdx plus gray-matter works well without any additional abstraction layer.

How do I add search to a Next.js MDX blog without a database?

Pagefind is the most practical option. It runs as a post-build step, crawls your static HTML output, and produces a pre-built index that ships as static assets. Client-side the bundle is around 12 KB, loads lazily, and returns results with highlighted excerpts without any API call. Run npx pagefind --site out after your Next.js static export, then import the Pagefind UI component into your search page.

Can I host a Next.js MDX blog for free?

Vercel's Hobby plan is free for personal projects, with zero-config Next.js deployments and Image Optimization included — the 100 GB bandwidth cap is rarely hit for portfolio-scale blogs. Cloudflare Pages is equally strong: unlimited bandwidth on the free tier and edge locations in Mumbai and other Indian cities. For a static export (output: 'export'), both platforms work identically. Vercel is the easier setup; Cloudflare Pages is better if you eventually want Workers-based dynamic routes without a Vercel Pro plan.