Next.jsMarkdownSEOfrontmatterstructured dataApp Routerweb developmentdynamic metadatastatic site generationblog

How to Update Metadata Dynamically in a Next.js App from a Blog Markdown File

Jonathan Koch

Learn how to dynamically update metadata in a Next.js App Router project using Markdown files. This guide walks you through building a scalable blog with SEO-friendly content and structured data.

How to Update Metadata Dynamically in a Next.js App from a Blog Markdown File

Welcome to Ingenuity Africa

In this article, we'll explore how to dynamically update metadata in a Next.js app using Markdown files. These Markdown files will serve as the content source for blog posts.


File Structure Overview

To get started, it's important to organize your project in a clean and scalable way. Below is a recommended file structure for your Next.js application:

/app
  └── /blog
      ├── page.js
      └── /[slug]
          └── page.js

/components
  └── BlogPostStructuredData.jsx

/content
  └── my-first-blog.md

/lib
  └── posts.js

Explanation:

  • /app/blog/page.js: This is the main blog listing page.
  • /app/blog/[slug]/page.js: This dynamic route handles individual blog post pages based on the slug.
  • /components/BlogPostStructuredData.jsx: This component is responsible for injecting dynamic metadata into each blog post (e.g., SEO tags, Open Graph, etc.).
  • /content/my-first-blog.md: A sample Markdown file that holds the blog post content and frontmatter metadata (like title, description, date, etc.).
  • /lib/posts.js – Utilities for reading and parsing Markdown content.

Blog Listing Page (app/blog/page.js)

This component renders all blog posts in a responsive layout using metadata from Markdown files.


import Link from 'next/link';
import { getAllPosts } from '@/lib/posts';

export const metadata = {
  title: 'Your Blog',
  description: 'Insights on tech, business, and digital innovation.',
};

export default async function BlogPage() {
  const posts = await getAllPosts();

  return (
    <main className="min-h-screen p-6 bg-slate-900 text-white">
      <h1 className="text-3xl font-bold mb-6">Your Blog</h1>

      {posts.length === 0 ? (
        <p className="text-gray-400">No blog posts available.</p>
      ) : (
        <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
          {posts.map(({ frontMatter, slug }) => (
            <Link
              key={slug}
              href={`/blog/${slug}`}
              className="block p-4 border border-slate-700 rounded hover:border-purple-500 transition"
            >
              <h2 className="text-xl font-semibold text-purple-300 mb-2">
                {frontMatter.title}
              </h2>
              <p className="text-sm text-gray-400 mb-2">{frontMatter.date}</p>
              <p className="text-gray-300 text-sm">{frontMatter.description}</p>
            </Link>
          ))}
        </div>
      )}
    </main>
  );
}

This BlogPage component is a simple blog listing page built with Next.js using the App Router. It imports a getAllPosts function that reads blog content—typically stored as Markdown files—and fetches metadata like title, date, and description. The page then renders these posts in a responsive grid layout using Tailwind CSS. If no posts are available, a fallback message is shown. Each post is displayed as a clickable card using next/link, directing users to the individual blog page based on its slug. The metadata export at the top sets the page’s SEO title and description.


Dynamic Blog Page (app/blog/[slug]/page.js)

Each individual blog post is rendered based on its slug. It uses the generateStaticParams function to generate static pages at build time.


import { getPostBySlug, getPostSlugs } from '@/lib/posts';
import { compileMDX } from 'next-mdx-remote/rsc';
import { notFound } from 'next/navigation';
import Link from 'next/link';

export async function generateStaticParams() {
  const slugs = await getPostSlugs();
  return slugs.map((slug) => ({ slug }));
}

export default async function BlogPostPage({ params }) {
  const { slug } = params;
  const post = await getPostBySlug(slug);

  if (!post) return notFound();

  const { content } = await compileMDX({
    source: post.content,
    components: {},
  });

  return (
    <main className="max-w-3xl mx-auto p-6 text-white bg-slate-900 min-h-screen">
      <Link href="/blog" className="text-purple-400 underline mb-4 block">
        ← Back to Blog
      </Link>

      <h1 className="text-3xl font-bold mb-2">{post.frontMatter.title}</h1>
      <p className="text-gray-400 text-sm mb-6">{post.frontMatter.date}</p>

      <article className="prose prose-invert">{content}</article>
    </main>
  );
}

File: app/blog/[slug]/page.js

This code defines a dynamic blog post page using Next.js App Router features. The generateStaticParams function fetches all available post slugs to pre-generate static pages at build time. The BlogPostPage component fetches the post data based on the slug parameter, returning a 404 page if no post is found. It compiles the post's MDX content to React components and renders the post with a styled layout, including a link to return back to the blog list. The design uses Tailwind CSS for consistent styling.


Dynamic Blog Page (app/blog/[slug]/page.js)

Each individual blog post is rendered based on its slug. It uses the generateStaticParams function to generate static pages at build time.


import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';

const contentDir = path.join(process.cwd(), 'content');

export async function getAllMarkdownFiles(dirPath = contentDir) {
    try {
        const entries = await fs.readdir(dirPath, { withFileTypes: true });
        const files = [];
        
        for (const entry of entries) {
            const fullPath = path.join(dirPath, entry.name);
            
            if (entry.isDirectory()) {
                const nestedFiles = await getAllMarkdownFiles(fullPath);
                files.push(...nestedFiles);
            } else if (entry.isFile() && fullPath.endsWith('.md')) {
                files.push(fullPath);
            }
        }
        
        return files;
    } catch (error) {
        console.error(`Error reading directory ${dirPath}:`, error);
        return [];
    }
}

export async function getAllPosts() {
    try {
        const filePaths = await getAllMarkdownFiles();
        const posts = [];
        
        for (const filePath of filePaths) {
            try {
                const fileContent = await fs.readFile(filePath, 'utf-8');
                const { data, content } = matter(fileContent);
                const slug = path.basename(filePath, '.md');
                
                posts.push({
                    slug,
                    frontMatter: data,
                    content,
                    filePath
                });
            } catch (error) {
                console.error(`Error processing file ${filePath}:`, error);
            }
        }
        
        return posts;
    } catch (error) {
        console.error('Error getting all posts:', error);
        return [];
    }
}

export async function getPostSlugs() {
    try {
        const posts = await getAllPosts();
        return posts.map(post => post.slug);
    } catch (error) {
        console.error('Error getting post slugs:', error);
        return [];
    }
}

export async function getPostSlugsFast() {
    try {
        const filePaths = await getAllMarkdownFiles();
        return filePaths.map(filePath => path.basename(filePath, '.md'));
    } catch (error) {
        console.error('Error getting post slugs (fast method):', error);
        return [];
    }
}

export async function getPostBySlug(slug) {
    try {
        const posts = await getAllPosts();
        return posts.find(post => post.slug === slug) || null;
    } catch (error) {
        console.error(`Error finding post with slug ${slug}:`, error);
        return null;
    }
}

export async function getPostBySlugFast(slug) {
    try {
        const filePaths = await getAllMarkdownFiles();
        const targetPath = filePaths.find(path =>
            path.endsWith(`/${slug}.md`) || path.endsWith(`\\${slug}.md`)
        );
        
        if (!targetPath) return null;
        
        const fileContent = await fs.readFile(targetPath, 'utf-8');
        const { data, content } = matter(fileContent);
        
        return {
            slug,
            frontMatter: data,
            content
        };
    } catch (error) {
        console.error(`Error loading post ${slug}:`, error);
        return null;
    }
}

These helper functions power the blog listing and dynamic route generation in your /app directory.

The libs/posts.js module does the heavy lifting:

  • ✅ Reads all Markdown files from the /content folder.

  • ✅ Parses front matter using gray-matter.

  • ✅ Compiles content with compileMDX().

  • ✅ Exposes helper functions like:

  • getAllPosts() — returns all blog post data

  • getPostBySlug() — fetch a post by its slug

  • getPostSlugs() — get all slugs for dynamic routing

  • getPostBySlugFast() — optimized single-file fetch


Structured Data for SEO (components/BlogPostStructuredData.jsx)

Next, in components/BlogStructuredData.jsx, we will insert the following code:


'use client';
import React from 'react';

const BlogPostStructuredData = ({
    title,
    date,
    description,
    author,
    image,
    url 
}) => {
    const structuredData = {
        "@context": "https://schema.org",
        "@type": "BlogPosting",
        "headline": title,
        "datePublished": date,
        "description": description,
        "author": {
            "@type": "Person",
            "name": author
        },
        "image": image ? new URL(image, 'https://www.yourblog.com').href : undefined,
        "publisher": {
            "@type": "Organization",
            "name": "Your Business Name",
            "url": "https://www.yourblog.com",
            "logo": {
                "@type": "ImageObject",
                "url": "https://www.yourblog.com/yourLogo.png"
            }
        },
        "mainEntityOfPage": {
            "@type": "WebPage",
            "@id": url
        },
        "inLanguage": "en"
    };

    return (
        <script
            type="application/ld+json"
            dangerouslySetInnerHTML={{
                __html: JSON.stringify(structuredData, null, 2),
            }}
        />
    );
};

export default BlogPostStructuredData;

📁 File: components/BlogPostStructuredData.jsx

This React component, BlogPostStructuredData, generates and injects JSON-LD structured data for a blog post to improve SEO and help search engines understand the content better. It accepts props like title, date, description, author, image, and url to build a structured data object following the Schema.org BlogPosting specification. The image URL is resolved relative to the blog’s base URL if provided. The component returns a <script> tag with the structured data serialized as a JSON string inside dangerouslySetInnerHTML, which Next.js will insert into the HTML head. This structured data helps enhance search result listings with rich snippets such as article titles, authors, publish dates, and images.


Markdown Blog File Example (/content/my-first-blog.md)

Now what you can do is, in content/my-first-blog.md, create a structure that matches the one below:


title: "My First Blog"
date: "2025-07-16"
description: "This is my first blog"
author: "Your name"
tags: ["first-blog", "getting started"]
image: "/someImage.jpg"

This is my first blog!

The markdown content above contains front matter (the metadata at the top enclosed in triple dashes or just listed before the body) and the main body content.

When the code processes this markdown file (with gray-matter), it extracts the front matter fields such as title, date, description, author, tags, and image into an object called frontMatter. The remaining part, which is the actual blog content ("This is my first blog!"), is stored as the markdown body or content.

Functions like getPostBySlug read the markdown file, parse it, and return both:

frontMatter — the metadata used for titles, dates, and other information,

content — the markdown text that will be compiled and rendered on the page.

This separation allows the blog page component to use the metadata for headers, SEO, and structured data, while rendering the markdown content as the post body.


Putting It All Together

By combining Markdown files, Next.js App Router, gray-matter, and structured metadata, you've created a powerful, scalable blog architecture that separates content, logic, and presentation.

Here’s how the system works together:

Content Flow

Markdown Files (/content/*.md)

  • Contain both the blog content and front matter metadata (title, date, description, etc.).

  • Parsed using gray-matter to separate metadata (frontMatter) from body (content).

  • Helper Functions (/lib/posts.js)

  • getAllPosts() and getPostBySlug() read and parse the Markdown files.

  • getPostSlugs() supports dynamic route generation in [slug]/page.js.

  • These utilities ensure your app stays dynamic and data-driven.

  • Blog Listing Page (/app/blog/page.js)

  • Uses getAllPosts() to render a grid of posts with metadata like title and description.

  • Each post links to a dynamic route based on the slug.

  • Dynamic Blog Post Page (/app/blog/[slug]/page.js)

  • Uses getPostBySlug() to fetch post content and metadata.

  • Compiles Markdown to JSX with compileMDX().

  • Renders a full post page including the header, date, and main article content.

  • Structured Data Component (/components/BlogPostStructuredData.jsx)

  • Injects SEO-enhancing JSON-LD metadata dynamically based on the post’s front matter.

  • Helps search engines generate rich previews for your articles (title, author, image, etc.).

Why This Setup Is Effective

  • Content is decoupled from logic, allowing non-developers to write blog posts in Markdown.

  • Dynamic routing ensures each blog has its own unique URL and static page generation.

  • Metadata is centralized in front matter, used for:

  • Rendering on the page

  • SEO <head> tags

  • Structured data injection

  • Scales easily: Add more Markdown files — no extra code needed.

Bottom Line

With this setup, your Next.js app becomes a Markdown-powered CMS — fast, flexible, SEO-friendly, and perfect for developers and content creators alike. Just drop a .md file in /content, and everything else — routes, metadata, SEO — is handled automatically.


, Team Ingenuity Africa