| name | content-mdx |
| description | Manage portfolio content with MDX, Contentlayer, or local JSON for projects, blog posts, and dynamic content. |
| author | Jaivish Chauhan @ GDG SSIT |
| version | 1.0.0 |
| url | https://github.com/JaivishChauhan/vibecoding-starter |
Content Management with MDX
Core Philosophy
Content should be version-controlled, type-safe, and developer-friendly. We use MDX for rich content (blog posts), JSON/TypeScript for structured data (projects), and optionally Contentlayer for the best DX.
Content Architecture
Project Structure
├── content/
│ ├── projects/
│ │ ├── project-1.mdx
│ │ ├── project-2.mdx
│ │ └── project-3.mdx
│ ├── blog/
│ │ ├── getting-started-with-nextjs.mdx
│ │ └── mastering-tailwind.mdx
│ └── data/
│ ├── experience.json
│ ├── skills.json
│ └── testimonials.json
├── lib/
│ ├── content.ts # Content utilities
│ └── mdx.ts # MDX processing
└── app/
├── projects/[slug]/page.tsx
└── blog/[slug]/page.tsx
Option 1: Contentlayer (Recommended)
Installation
npm install contentlayer next-contentlayer date-fns
Configuration
import { defineDocumentType, makeSource } from "contentlayer/source-files";
import remarkGfm from "remark-gfm";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
export const Project = defineDocumentType(() => ({
name: "Project",
filePathPattern: "projects/**/*.mdx",
contentType: "mdx",
fields: {
title: { type: "string", required: true },
description: { type: "string", required: true },
date: { type: "date", required: true },
image: { type: "string", required: true },
tags: { type: "list", of: { type: "string" }, required: true },
demoUrl: { type: "string" },
githubUrl: { type: "string" },
featured: { type: "boolean", default: false },
},
computedFields: {
slug: {
type: "string",
resolve: (doc) => doc._raw.flattenedPath.replace("projects/", ""),
},
url: {
type: "string",
resolve: (doc) =>
`/projects/${doc._raw.flattenedPath.replace("projects/", "")}`,
},
},
}));
export const Post = defineDocumentType(() => ({
name: "Post",
filePathPattern: "blog/**/*.mdx",
contentType: "mdx",
fields: {
title: { type: "string", required: true },
description: { type: "string", required: true },
date: { type: "date", required: true },
image: { type: "string" },
tags: { type: "list", of: { type: "string" }, default: [] },
published: { type: "boolean", default: true },
author: { type: "string", default: "John Doe" },
},
computedFields: {
slug: {
type: "string",
resolve: (doc) => doc._raw.flattenedPath.replace("blog/", ""),
},
url: {
type: "string",
resolve: (doc) => `/blog/${doc._raw.flattenedPath.replace("blog/", "")}`,
},
readingTime: {
type: "string",
resolve: (doc) => {
const wordsPerMinute = 200;
const words = doc.body.raw.split(/\s+/).length;
const minutes = Math.ceil(words / wordsPerMinute);
return `${minutes} min read`;
},
},
},
}));
export default makeSource({
contentDirPath: "content",
documentTypes: [Project, Post],
mdx: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug,
[
rehypePrettyCode,
{
theme: "github-dark",
onVisitLine(node: any) {
if (node.children.length === 0) {
node.children = [{ type: "text", value: " " }];
}
},
},
],
[
rehypeAutolinkHeadings,
{
properties: {
className: ["anchor"],
ariaLabel: "Link to section",
},
},
],
],
},
});
Next.js Config
const { withContentlayer } = require("next-contentlayer");
const nextConfig = {
};
module.exports = withContentlayer(nextConfig);
Content Files
---
title: "E-Commerce Platform"
description: "A full-featured e-commerce solution with real-time inventory"
date: 2024-01-15
image: /images/projects/ecommerce.jpg
tags: ["Next.js", "TypeScript", "Prisma", "Stripe"]
demoUrl: https://demo.example.com
githubUrl: https://github.com/johndoe/ecommerce
featured: true
---
## Overview
This project demonstrates a modern e-commerce platform built with Next.js 14.
## Features
- **Real-time inventory tracking** - WebSocket integration
- **Stripe payments** - Secure checkout flow
- **Admin dashboard** - Manage products and orders
## Technical Details
```tsx
// Example code block with syntax highlighting
export async function getProducts() {
return await db.product.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
});
}
```
Gallery
<ProjectGallery images={[
"/images/projects/ecommerce-1.jpg",
"/images/projects/ecommerce-2.jpg",
]} />
### Using Content
```tsx
// app/projects/page.tsx
import { allProjects } from 'contentlayer/generated';
import { compareDesc } from 'date-fns';
export default function ProjectsPage() {
const projects = allProjects
.filter((p) => p.featured)
.sort((a, b) => compareDesc(new Date(a.date), new Date(b.date)));
return (
<div className="grid gap-8 md:grid-cols-2">
{projects.map((project) => (
<ProjectCard key={project.slug} project={project} />
))}
</div>
);
}
import { allProjects } from "contentlayer/generated";
import { notFound } from "next/navigation";
import { useMDXComponent } from "next-contentlayer/hooks";
import { mdxComponents } from "@/components/mdx";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
return allProjects.map((project) => ({
slug: project.slug,
}));
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
const project = allProjects.find((p) => p.slug === slug);
if (!project) return { title: "Not Found" };
return {
title: project.title,
description: project.description,
};
}
export default async function ProjectPage({ params }: Props) {
const { slug } = await params;
const project = allProjects.find((p) => p.slug === slug);
if (!project) notFound();
const MDXContent = useMDXComponent(project.body.code);
return (
<article className="prose prose-invert max-w-none">
<h1>{project.title}</h1>
<MDXContent components={mdxComponents} />
</article>
);
}
Option 2: next-mdx-remote (Simpler)
Installation
npm install next-mdx-remote gray-matter
Content Utilities
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { compileMDX } from "next-mdx-remote/rsc";
import { mdxComponents } from "@/components/mdx";
const contentDir = path.join(process.cwd(), "content");
export interface ProjectFrontmatter {
title: string;
description: string;
date: string;
image: string;
tags: string[];
demoUrl?: string;
githubUrl?: string;
featured?: boolean;
}
export async function getProjectBySlug(slug: string) {
const filePath = path.join(contentDir, "projects", `${slug}.mdx`);
if (!fs.existsSync(filePath)) return null;
const source = fs.readFileSync(filePath, "utf8");
const { content, frontmatter } = await compileMDX<ProjectFrontmatter>({
source,
components: mdxComponents,
options: {
parseFrontmatter: true,
},
});
return {
content,
frontmatter,
slug,
};
}
export async function getAllProjects() {
const projectsDir = path.join(contentDir, "projects");
const files = fs.readdirSync(projectsDir).filter((f) => f.endsWith(".mdx"));
const projects = await Promise.all(
files.map(async (file) => {
const slug = file.replace(".mdx", "");
const filePath = path.join(projectsDir, file);
const source = fs.readFileSync(filePath, "utf8");
const { data } = matter(source);
return {
slug,
...(data as ProjectFrontmatter),
};
}),
);
return projects.sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
}
Using in Pages
import { getProjectBySlug, getAllProjects } from "@/lib/mdx";
import { notFound } from "next/navigation";
export async function generateStaticParams() {
const projects = await getAllProjects();
return projects.map((p) => ({ slug: p.slug }));
}
export default async function ProjectPage({ params }: Props) {
const { slug } = await params;
const project = await getProjectBySlug(slug);
if (!project) notFound();
return (
<article className="prose prose-invert">
<h1>{project.frontmatter.title}</h1>
{project.content}
</article>
);
}
Option 3: Local JSON/TypeScript
Type-Safe Content
export interface Project {
id: string;
slug: string;
title: string;
description: string;
longDescription: string;
image: string;
tags: string[];
date: string;
demoUrl?: string;
githubUrl?: string;
featured: boolean;
}
export const projects: Project[] = [
{
id: "1",
slug: "ecommerce-platform",
title: "E-Commerce Platform",
description: "A full-featured e-commerce solution",
longDescription: `
Built with Next.js 14, this platform features real-time inventory,
Stripe payments, and a comprehensive admin dashboard.
`,
image: "/images/projects/ecommerce.jpg",
tags: ["Next.js", "TypeScript", "Prisma", "Stripe"],
date: "2024-01-15",
demoUrl: "https://demo.example.com",
githubUrl: "https://github.com/johndoe/ecommerce",
featured: true,
},
];
export function getProjectBySlug(slug: string) {
return projects.find((p) => p.slug === slug) ?? null;
}
export function getFeaturedProjects() {
return projects
.filter((p) => p.featured)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
export function getAllProjects() {
return [...projects].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
}
Experience Data
export interface Experience {
id: string;
title: string;
company: string;
location: string;
period: string;
description: string;
achievements: string[];
skills: string[];
}
export const experiences: Experience[] = [
{
id: "1",
title: "Senior Frontend Developer",
company: "Tech Startup",
location: "San Francisco, CA",
period: "Jan 2023 - Present",
description: "Lead frontend development for a B2B SaaS platform.",
achievements: [
"Architected component library used across 5 products",
"Reduced bundle size by 40% through code splitting",
"Mentored team of 4 junior developers",
],
skills: ["React", "TypeScript", "Next.js", "Tailwind"],
},
];
MDX Components
Custom Components
import Image from "next/image";
import Link from "next/link";
import { cn } from "@/lib/utils";
function MDXImage({
src,
alt,
width = 800,
height = 400,
className,
}: {
src: string;
alt: string;
width?: number;
height?: number;
className?: string;
}) {
return (
<figure className="my-8">
<Image
src={src}
alt={alt}
width={width}
height={height}
className={cn("rounded-lg", className)}
/>
{alt && (
<figcaption className="mt-2 text-center text-sm text-zinc-500">
{alt}
</figcaption>
)}
</figure>
);
}
function MDXLink({
href,
children,
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
const isInternal = href?.startsWith("/") || href?.startsWith("#");
if (isInternal) {
return (
<Link href={href!} {...props}>
{children}
</Link>
);
}
return (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
);
}
function Callout({
type = "info",
title,
children,
}: {
type?: "info" | "warning" | "error" | "success";
title?: string;
children: React.ReactNode;
}) {
const styles = {
info: "border-blue-500/20 bg-blue-500/10 text-blue-200",
warning: "border-yellow-500/20 bg-yellow-500/10 text-yellow-200",
error: "border-red-500/20 bg-red-500/10 text-red-200",
success: "border-green-500/20 bg-green-500/10 text-green-200",
};
return (
<div className={cn("my-6 rounded-lg border p-4", styles[type])}>
{title && <p className="mb-2 font-bold">{title}</p>}
{children}
</div>
);
}
function ProjectGallery({ images }: { images: string[] }) {
return (
<div className="my-8 grid gap-4 sm:grid-cols-2">
{images.map((src, index) => (
<Image
key={src}
src={src}
alt={`Project screenshot ${index + 1}`}
width={600}
height={400}
className="rounded-lg"
/>
))}
</div>
);
}
function CodeBlock({
children,
className,
}: {
children: string;
className?: string;
}) {
return (
<div className="group relative">
<pre
className={cn("overflow-x-auto rounded-lg bg-zinc-900 p-4", className)}
>
<code>{children}</code>
</pre>
<button
className="absolute right-2 top-2 rounded bg-zinc-700 px-2 py-1 text-xs opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => navigator.clipboard.writeText(children)}
>
Copy
</button>
</div>
);
}
export const mdxComponents = {
img: MDXImage,
Image: MDXImage,
a: MDXLink,
Callout,
ProjectGallery,
pre: CodeBlock,
h1: (props: any) => <h1 className="mt-8 text-4xl font-bold" {...props} />,
h2: (props: any) => <h2 className="mt-8 text-2xl font-bold" {...props} />,
h3: (props: any) => <h3 className="mt-6 text-xl font-bold" {...props} />,
p: (props: any) => <p className="my-4 leading-relaxed" {...props} />,
ul: (props: any) => <ul className="my-4 list-disc pl-6" {...props} />,
ol: (props: any) => <ol className="my-4 list-decimal pl-6" {...props} />,
blockquote: (props: any) => (
<blockquote
className="my-4 border-l-4 border-brand-500 pl-4 italic"
{...props}
/>
),
};
Using in MDX
---
title: My Blog Post
---
# Introduction
This is a paragraph with a [link to projects](/projects).
<Callout type="info" title="Pro Tip">
Use MDX components to enhance your content!
</Callout>
<Image src="/images/demo.jpg" alt="Demo screenshot" />
<ProjectGallery images={["/images/1.jpg", "/images/2.jpg"]} />
Typography Styling
Tailwind Typography Plugin
npm install @tailwindcss/typography
module.exports = {
plugins: [require("@tailwindcss/typography")],
};
Custom Prose Styles
.prose {
--tw-prose-body: theme("colors.zinc.300");
--tw-prose-headings: theme("colors.white");
--tw-prose-links: theme("colors.brand.400");
--tw-prose-bold: theme("colors.white");
--tw-prose-code: theme("colors.brand.400");
--tw-prose-pre-bg: theme("colors.zinc.900");
--tw-prose-quotes: theme("colors.zinc.400");
--tw-prose-quote-borders: theme("colors.brand.500");
}
.prose pre {
@apply border border-zinc-800;
}
.prose code:not(pre code) {
@apply rounded bg-zinc-800 px-1.5 py-0.5 text-sm;
}
.prose a {
@apply no-underline hover:underline;
}
.prose img {
@apply rounded-lg;
}
Usage
<article className="prose prose-lg prose-invert max-w-none">
<MDXContent components={mdxComponents} />
</article>
Site Configuration
Centralized Config
export const siteConfig = {
name: "John Doe",
title: "Full-Stack Developer",
description: "Building beautiful web experiences",
url: "https://johndoe.com",
ogImage: "/og-image.png",
links: {
github: "https://github.com/johndoe",
linkedin: "https://linkedin.com/in/johndoe",
twitter: "https://twitter.com/johndoe",
email: "hello@johndoe.com",
},
navigation: [
{ label: "About", href: "#about" },
{ label: "Projects", href: "#projects" },
{ label: "Experience", href: "#experience" },
{ label: "Contact", href: "#contact" },
],
skills: {
frontend: ["React", "Next.js", "TypeScript", "Tailwind CSS"],
backend: ["Node.js", "PostgreSQL", "Prisma", "Redis"],
tools: ["Git", "Docker", "AWS", "Vercel"],
},
};
export type SiteConfig = typeof siteConfig;
Usage
import { siteConfig } from "@/lib/config";
export default function Footer() {
return (
<footer>
<p>
© {new Date().getFullYear()} {siteConfig.name}
</p>
<div className="flex gap-4">
<a href={siteConfig.links.github}>GitHub</a>
<a href={siteConfig.links.linkedin}>LinkedIn</a>
</div>
</footer>
);
}
Content Best Practices
- Use TypeScript types for all content schemas
- Validate frontmatter with Zod or Contentlayer
- Generate static paths for all content pages
- Add computed fields (reading time, slugs, URLs)
- Keep content and code separate in
/content directory
- Use ISR for content that might update frequently
- Optimize images in MDX with custom Image component