Next.js Project Setup Guide
A practical guide to setting up Next.js with TypeScript, Tailwind CSS, shadcn/ui, theme switching, and MDX support
Next.js Project Setup Guide
A step-by-step guide to set up a modern Next.js project with all the essential features.
1. Create Next.js App
Option 1: Interactive Setup
npx create-next-app@latest
Follow the prompts to configure your project.
Option 2: With Command-Line Options (Recommended)
Example with TypeScript, Tailwind CSS, App Router, Turbopack:
npx create-next-app@latest my-front --ts --tailwind --app --turbopack --no-linter
2. Install shadcn/ui
Initialize shadcn/ui in your project:
npx shadcn@latest init
Add components individually or all at once:
npx shadcn@latest add
Use the toggle option to select all components when prompted.
3. Add Framer Motion (Optional)
For smooth animations and transitions:
npm install framer-motion
4. Theme Switching with next-themes
Install next-themes for theme management:
npm install next-themes
Create Theme Provider
Create components/theme-provider.tsx:
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes";
import { motion } from "framer-motion";
import { flushSync } from "react-dom";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
export const ThemeSwitcher = () => {
const { resolvedTheme, setTheme } = useTheme();
const [isAnimating, setIsAnimating] = React.useState(false);
const maskId = React.useId();
const buttonRef = React.useRef<HTMLButtonElement>(null);
const handleThemeChange = async () => {
// Fallback if View Transitions API or button ref isn't available
if (!document.startViewTransition || !buttonRef.current) {
setTheme(resolvedTheme === "dark" ? "light" : "dark");
return;
}
// Respect reduced motion
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
if (prefersReducedMotion) {
setTheme(resolvedTheme === "dark" ? "light" : "dark");
return;
}
setIsAnimating(true);
// Start the view transition and wait for it to be ready
const transition = document.startViewTransition(() => {
flushSync(() => {
setTheme(resolvedTheme === "dark" ? "light" : "dark");
});
});
await transition.ready;
// Button geometry
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
// Radius to cover the viewport
const right = window.innerWidth - left;
const bottom = window.innerHeight - top;
const maxRadius = Math.hypot(Math.max(left, right), Math.max(top, bottom));
// Circular reveal
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
}
);
setTimeout(() => setIsAnimating(false), 600);
};
const isDark = resolvedTheme === "dark";
return (
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<button
ref={buttonRef}
onClick={handleThemeChange}
className="relative overflow-visible bg-transparent border-none m-0 flex items-center justify-center cursor-pointer"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
<motion.svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
className="w-6 h-6"
animate={{
rotate: isAnimating ? [0, 180] : 0,
scale: isAnimating ? [1, 0.8, 1] : 1,
}}
transition={{ duration: 0.6, ease: "easeInOut" }}
>
<defs>
<mask id={`moon-mask-${maskId}`}>
<rect x="0" y="0" width="24" height="24" fill="white" />
<circle cx="18" cy="6" r="6.5" fill="black" />
</mask>
</defs>
{/* Sun */}
<motion.g
animate={{ scale: isDark ? 0.5 : 1 }}
transition={{ duration: 0.3 }}
>
{/* Center circle */}
<circle cx="12" cy="12" r="4" fill="currentColor" />
{/* Sun rays */}
{[
{ cx: 12, cy: 2 },
{ cx: 19.07, cy: 4.93 },
{ cx: 22, cy: 12 },
{ cx: 19.07, cy: 19.07 },
{ cx: 12, cy: 22 },
{ cx: 4.93, cy: 19.07 },
{ cx: 2, cy: 12 },
{ cx: 4.93, cy: 4.93 },
].map((dot, i) => (
<motion.circle
key={i}
cx={dot.cx}
cy={dot.cy}
r="1.5"
fill="currentColor"
animate={{ scale: isDark ? 0 : 1 }}
transition={{
delay: isAnimating && !isDark ? i * 0.05 : 0,
duration: 0.2,
}}
style={{ transformOrigin: `${dot.cx}px ${dot.cy}px` }}
/>
))}
</motion.g>
{/* Moon */}
<motion.circle
cx="12"
cy="12"
r="7"
fill="currentColor"
mask={`url(#moon-mask-${maskId})`}
animate={{ scale: isDark ? 1 : 0.5 }}
transition={{ duration: 0.3 }}
/>
</motion.svg>
</button>
</motion.div>
);
};
Wrap Your App
Update app/layout.tsx to include the ThemeProvider:
import { ThemeProvider } from "@/components/theme-provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
5. Setup MDX Support
MDX allows you to write JSX in your markdown files.
Install Dependencies
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
npm install next-mdx-remote remark-gfm rehype-mdx-code-props react-syntax-highlighter @types/react-syntax-highlighter
Configure next.config.ts
Update your next.config.ts:
import createMDX from "@next/mdx";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
};
const withMDX = createMDX({
extension: /\.(md|mdx)$/,
options: {
remarkPlugins: [["remark-gfm", { strict: true, throwOnError: true }]],
rehypePlugins: [],
},
});
export default withMDX(nextConfig);
Create components
Create components/mdx-image.tsx in your project:
"use client";
import { useState, createContext, useContext, useEffect } from "react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogClose,
DialogTitle,
} from "@/components/ui/dialog";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselApi,
} from "@/components/ui/carousel";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { Button } from "@/components/ui/button";
// Context to manage all images in MDX content
interface ImageGalleryContextType {
images: Array<{ src: string; alt: string }>;
addImage: (image: { src: string; alt: string }) => void;
openGallery: (index: number) => void;
}
const ImageGalleryContext = createContext<ImageGalleryContextType | null>(null);
export function ImageGalleryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [images, setImages] = useState<Array<{ src: string; alt: string }>>([]);
const [isOpen, setIsOpen] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [api, setApi] = useState<CarouselApi>();
const { theme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const addImage = (image: { src: string; alt: string }) => {
setImages((prev) => {
// Avoid duplicates
const exists = prev.some((img) => img.src === image.src);
if (exists) return prev;
return [...prev, image];
});
};
const openGallery = (index: number) => {
setCurrentIndex(index);
setIsOpen(true);
};
useEffect(() => {
if (!api) return;
api.scrollTo(currentIndex, true);
}, [api, currentIndex, isOpen]);
// Keyboard navigation
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "Escape":
setIsOpen(false);
break;
case "ArrowLeft":
e.preventDefault();
api?.scrollPrev();
break;
case "ArrowRight":
e.preventDefault();
api?.scrollNext();
break;
case "Home":
e.preventDefault();
api?.scrollTo(0);
break;
case "End":
e.preventDefault();
api?.scrollTo(images.length - 1);
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, api, images.length]);
const handlePrevious = () => {
api?.scrollPrev();
};
const handleNext = () => {
api?.scrollNext();
};
useEffect(() => {
if (!api) return;
const onSelect = () => {
setCurrentIndex(api.selectedScrollSnap());
};
api.on("select", onSelect);
return () => {
api.off("select", onSelect);
};
}, [api]);
return (
<ImageGalleryContext.Provider value={{ images, addImage, openGallery }}>
{children}
<Dialog
open={isOpen}
onOpenChange={setIsOpen}
aria-describedby="image-gallery-title"
>
<DialogTitle id="image-gallery-title" hidden>
Image Gallery
</DialogTitle>
<DialogContent
className={cn(
"max-w-[98vw]! w-[98vw]! h-[98vh]! p-0 border-none overflow-hidden transition-colors",
isDark ? "bg-black/95" : "bg-white/95"
)}
showCloseButton={false}
>
<div className="relative w-full h-full flex flex-col">
{/* Close button */}
<Button
variant="ghost"
size="icon"
className={cn(
"absolute top-4 right-4 z-50 rounded-full transition-all duration-200 hover:scale-110 hover:rotate-90",
isDark
? "text-white hover:bg-white/20 hover:text-white"
: "text-zinc-800 hover:bg-zinc-200 hover:text-zinc-900"
)}
onClick={() => setIsOpen(false)}
>
<X className="h-6 w-6" />
</Button>
{/* Main carousel */}
<div className="flex-1 flex items-center justify-center px-16 py-8 pb-4 min-h-0 overflow-hidden">
<Carousel setApi={setApi} className="w-full h-full max-h-full">
<CarouselContent className="h-full ml-0 items-center">
{images.map((image, index) => (
<CarouselItem
key={index}
className="pl-0 basis-full flex items-center justify-center"
style={{ height: "calc(98vh - 200px)" }}
>
<div className="relative w-full h-full flex items-center justify-center">
<div className="relative max-w-full max-h-full w-full h-full">
<Image
src={image.src}
alt={image.alt}
fill
className="object-contain border-2 border-zinc-200 dark:border-zinc-700 rounded-lg"
sizes="98vw"
priority={index === currentIndex}
/>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
{/* Navigation panels with buttons */}
{images.length > 1 && (
<>
{/* Left navigation panel with button */}
{api?.canScrollPrev() && (
<button
onClick={handlePrevious}
className="absolute left-0 top-0 bottom-0 w-1/3 z-10 cursor-pointer group/prev"
aria-label="Previous image"
>
{/* Gradient overlay */}
<div
className={cn(
"absolute inset-y-0 left-0 w-40 transition-all duration-300 opacity-0 group-hover/prev:opacity-100 pointer-events-none rounded-l-lg",
isDark
? "bg-linear-to-r from-gray-900/80 to-transparent"
: "bg-linear-to-r from-gray-500/30 to-transparent"
)}
/>
{/* Navigation button indicator */}
<div
className={cn(
"absolute left-4 top-1/2 -translate-y-1/2 rounded-full h-14 w-14",
"flex items-center justify-center",
"transition-all duration-300 ease-out",
"group-hover/prev:scale-110 group-hover/prev:shadow-lg",
isDark
? "text-white bg-white/10 group-hover/prev:bg-white/20 backdrop-blur-sm border border-white/20"
: "text-zinc-800 bg-zinc-100/80 group-hover/prev:bg-zinc-200 backdrop-blur-sm border border-zinc-300"
)}
>
<ChevronLeft
className={cn(
"h-8 w-8 transition-transform duration-300",
"group-hover/prev:-translate-x-1"
)}
/>
</div>
</button>
)}
{/* Right navigation panel with button */}
{api?.canScrollNext() && (
<button
onClick={handleNext}
className="absolute right-0 top-0 bottom-0 w-1/3 z-10 cursor-pointer group/next"
aria-label="Next image"
>
{/* Gradient overlay */}
<div
className={cn(
"absolute inset-y-0 right-0 w-40 transition-all duration-300 opacity-0 group-hover/next:opacity-100 pointer-events-none rounded-r-lg",
isDark
? "bg-linear-to-l from-gray-900/80 to-transparent"
: "bg-linear-to-l from-gray-500/30 to-transparent"
)}
/>
{/* Navigation button indicator */}
<div
className={cn(
"absolute right-4 top-1/2 -translate-y-1/2 rounded-full h-14 w-14",
"flex items-center justify-center",
"transition-all duration-300 ease-out",
"group-hover/next:scale-110 group-hover/next:shadow-lg",
isDark
? "text-white bg-white/10 group-hover/next:bg-white/20 backdrop-blur-sm border border-white/20"
: "text-zinc-800 bg-zinc-100/80 group-hover/next:bg-zinc-200 backdrop-blur-sm border border-zinc-300"
)}
>
<ChevronRight
className={cn(
"h-8 w-8 transition-transform duration-300",
"group-hover/next:translate-x-1"
)}
/>
</div>
</button>
)}
</>
)}
</Carousel>
</div>
{/* Thumbnails */}
{images.length > 1 && (
<div className="px-8 pb-8 pt-4 shrink-0 border-t border-zinc-200 dark:border-zinc-700">
<div className="flex gap-2 justify-center overflow-x-auto pb-2">
{images.map((image, index) => (
<button
key={index}
onClick={() => api?.scrollTo(index)}
className={cn(
"relative shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all duration-300",
"hover:scale-105 active:scale-95",
currentIndex === index
? isDark
? "border-white scale-110 shadow-lg shadow-white/20"
: "border-zinc-800 scale-110 shadow-lg shadow-zinc-800/20"
: isDark
? "border-white/30 hover:border-white/60"
: "border-zinc-300 hover:border-zinc-600"
)}
>
<Image
src={image.src}
alt={image.alt}
fill
className="object-cover"
sizes="80px"
/>
</button>
))}
</div>
</div>
)}
{/* Image counter */}
{images.length > 1 && (
<div
className={cn(
"absolute top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-full text-sm z-10 backdrop-blur-sm transition-colors",
isDark
? "text-white bg-black/60 border border-white/20"
: "text-zinc-800 bg-white/80 border border-zinc-300"
)}
>
{currentIndex + 1} / {images.length}
</div>
)}
</div>
</DialogContent>
</Dialog>
</ImageGalleryContext.Provider>
);
}
export function MDXImage({ src, alt }: { src: string; alt?: string }) {
const context = useContext(ImageGalleryContext);
const imageAlt = alt || "Project image";
useEffect(() => {
if (context && src) {
context.addImage({ src, alt: imageAlt });
}
}, [context, src, imageAlt]);
const handleClick = () => {
if (!context) return;
const index = context.images.findIndex((img) => img.src === src);
if (index !== -1) {
context.openGallery(index);
}
};
return (
<div className="flex justify-center">
<button
onClick={handleClick}
className="relative cursor-pointer group rounded-lg border-2 border-zinc-200 dark:border-zinc-700 transition-all duration-300 hover:scale-[1.02] hover:border-zinc-300 dark:hover:border-zinc-600 hover:shadow-lg active:scale-[0.98] overflow-hidden"
>
<Image
src={src}
alt={imageAlt}
width={0}
height={0}
sizes="100vw"
className="rounded-lg transition-transform duration-300 w-full h-auto"
style={{ width: "100%", height: "auto" }}
/>
{/* Overlay hint */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 dark:group-hover:bg-white/10 transition-colors duration-300 flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 transition-all duration-300 bg-black/70 dark:bg-white/80 text-white dark:text-zinc-900 px-4 py-2 rounded-full text-sm font-medium backdrop-blur-sm">
Click to enlarge
</div>
</div>
</button>
</div>
);
}
Create components/mdx-syntax-highlighter.tsx in your project root:
"use client";
import { useState, useEffect, Suspense, useRef } from "react";
import { Check, Clipboard, Copy, ChevronDown, ChevronUp } from "lucide-react";
import dynamic from "next/dynamic";
import { Button } from "../ui/button";
// Dynamically import the heavy syntax highlighter
const SyntaxHighlighter = dynamic(
() => import("react-syntax-highlighter").then((mod) => mod.Prism),
{
loading: () => (
<div className="bg-gray-900 rounded-lg p-4 animate-pulse">
<div className="h-4 bg-gray-700 rounded mb-2" />
<div className="h-4 bg-gray-700 rounded mb-2 w-3/4" />
<div className="h-4 bg-gray-700 rounded w-1/2" />
</div>
),
}
);
function CodeSkeleton() {
return (
<div className="bg-gray-900 rounded-lg p-4 animate-pulse">
<div className="h-4 bg-gray-700 rounded mb-2" />
<div className="h-4 bg-gray-700 rounded mb-2 w-3/4" />
<div className="h-4 bg-gray-700 rounded w-1/2" />
</div>
);
}
export function Code({
language,
children,
filename,
}: {
language: string;
children: string;
filename?: string;
}) {
const [copied, setCopied] = useState(false);
const [copiedFilename, setCopiedFilename] = useState(false);
const [style, setStyle] = useState<any>(null);
const [isExpanded, setIsExpanded] = useState(false);
const [isHighlighted, setIsHighlighted] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const scrollPositionRef = useRef<number>(0);
// Ensure children is a string and count the number of lines in the code
const codeString =
typeof children === "string" ? children : String(children || "");
const lineCount = codeString.split("\n").length;
const shouldCollapse = lineCount > 10;
useEffect(() => {
// Dynamically load the style
import("react-syntax-highlighter/dist/cjs/styles/prism").then((mod) => {
setStyle(mod.oneDark);
});
}, []);
useEffect(() => {
if (copied) {
const timer = setTimeout(() => {
setCopied(false);
}, 1500);
return () => clearTimeout(timer);
}
}, [copied]);
useEffect(() => {
if (copiedFilename) {
const timer = setTimeout(() => {
setCopiedFilename(false);
}, 1500);
return () => clearTimeout(timer);
}
}, [copiedFilename]);
const copyCode = () => {
navigator.clipboard.writeText(codeString);
setCopied(true);
};
const copyFilename = () => {
if (filename) {
navigator.clipboard.writeText(filename);
setCopiedFilename(true);
}
};
const toggleExpand = () => {
if (!isExpanded) {
// Before expanding, save current scroll position
scrollPositionRef.current = window.scrollY;
} else {
// When collapsing, scroll back to saved position and highlight
setTimeout(() => {
window.scrollTo({
top: scrollPositionRef.current,
behavior: "smooth",
});
// Trigger highlight after scroll completes
setTimeout(() => {
setIsHighlighted(true);
}, 500); // Wait for smooth scroll to finish
}, 50); // Small delay to ensure DOM is updated
}
setIsExpanded(!isExpanded);
};
// Remove highlight after animation
useEffect(() => {
if (isHighlighted) {
const timer = setTimeout(() => {
setIsHighlighted(false);
}, 2000); // Highlight for 2 seconds
return () => clearTimeout(timer);
}
}, [isHighlighted]);
// Get the code to display (first 10 lines if collapsed)
const displayCode =
shouldCollapse && !isExpanded
? codeString.split("\n").slice(0, 10).join("\n")
: codeString;
return (
<div
ref={containerRef}
className={`flex flex-col relative rounded-lg overflow-hidden border transition-all duration-300 ${
isHighlighted
? "border-blue-500 shadow-[0_0_20px_rgba(59,130,246,0.5)] animate-pulse"
: "border-gray-800"
}`}
>
{/* Header with filename and copy buttons */}
<div className="flex items-center justify-between bg-[#282c34] px-4 py-2 border-b border-gray-700">
<div className="flex items-center gap-2 flex-1 min-w-0">
{filename && (
<>
<span className="text-sm text-gray-300 font-mono truncate">
{filename}
</span>
<Button
size="sm"
variant="ghost"
onClick={copyFilename}
className="h-6 w-6 p-0 cursor-pointer shrink-0"
>
{copiedFilename ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Clipboard className="h-3 w-3" />
)}
<span className="sr-only">Copy filename</span>
</Button>
</>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={copyCode}
className="h-6 w-6 p-0 cursor-pointer shrink-0"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
<span className="sr-only">Copy code</span>
</Button>
</div>
<div className="relative">
<div
className={`overflow-hidden transition-all duration-300 ease-in-out ${
shouldCollapse && !isExpanded ? "max-h-[400px]" : "max-h-none"
}`}
>
<Suspense fallback={<CodeSkeleton />}>
{style && (
<SyntaxHighlighter
customStyle={{
fontFamily: "var(--font-mono)",
margin: 0,
borderRadius: 0,
}}
codeTagProps={{ style: { fontFamily: "inherit" } }}
language={language}
style={style}
>
{displayCode}
</SyntaxHighlighter>
)}
</Suspense>
</div>
{/* Gradient overlay when collapsed */}
{shouldCollapse && !isExpanded && (
<div className="absolute bottom-0 left-0 right-0 h-24 bg-linear-to-t from-[#282c34] to-transparent pointer-events-none" />
)}
</div>
{/* Toggle button */}
{shouldCollapse && (
<div className="border-t border-gray-700 bg-[#282c34]">
<Button
size="sm"
variant="ghost"
onClick={toggleExpand}
className="w-full h-8 text-xs text-gray-400 hover:text-gray-200 hover:bg-gray-700/50"
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Show less
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show {lineCount - 10} more lines
</>
)}
</Button>
</div>
)}
</div>
);
}
Create mdx-components.tsx in your project root:
import { Code } from "@/components/mdx-syntax-highlighter";
import { MDXImage, ImageGalleryProvider } from "@/components/mdx-image";
import { MDXRemote } from "next-mdx-remote/rsc";
import { MDXComponents } from "mdx/types";
import remarkGfm from "remark-gfm";
import rehypeMdxCodeProps from "rehype-mdx-code-props";
const components = {
pre: ({ children, filename, ...props }: any) => {
const code = children.props.children;
const language = children.props.className?.replace("language-", "") || "";
return (
<Code language={language} filename={filename}>
{code}
</Code>
);
},
code: ({ children, className }: any) => {
// If it has a className, it's part of a code block, return as is
if (className) {
return <code className={className}>{children}</code>;
}
// Otherwise it's inline code, style it
return (
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-sm font-mono text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100 before:content-none after:content-none">
{children}
</code>
);
},
a: ({ href, children, ...props }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
),
img: ({ src, alt }: any) => <MDXImage src={src} alt={alt} />,
h1: ({ children }: any) => {
const id = String(children)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
return <h1 id={id}>{children}</h1>;
},
h2: ({ children }: any) => {
const id = String(children)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
return <h2 id={id}>{children}</h2>;
},
h3: ({ children }: any) => {
const id = String(children)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
return <h3 id={id}>{children}</h3>;
},
};
export function MDXWrapper({ content }: { content: string }) {
return (
<ImageGalleryProvider>
<article className="lg:col-span-2 px-4 lg:px-0 prose prose-headings:mt-6 prose-headings:font-semibold prose-headings:text-black prose-h1:text-3xl prose-h2:text-2xl prose-h3:text-xl prose-h4:text-lg prose-h5:text-base prose-h6:text-sm prose-pre:p-0 prose-pre:bg-transparent prose-code:text-sm prose-code:font-mono prose-code:before:content-none prose-code:after:content-none prose-table:border-collapse prose-th:border prose-th:border-zinc-300 prose-th:bg-zinc-100 prose-th:px-4 prose-th:py-2 prose-td:border prose-td:border-zinc-300 prose-td:px-4 prose-td:py-2 prose-img:my-0 prose-img:mx-0 dark:prose-headings:text-white dark:prose-invert dark:prose-th:border-zinc-700 dark:prose-th:bg-zinc-800 dark:prose-td:border-zinc-700">
<MDXRemote
source={content}
components={components}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeMdxCodeProps],
},
}}
/>
</article>
</ImageGalleryProvider>
);
}
export function useMDXComponents(): MDXComponents {
return components;
}
6. MDX Setup Options
Option A: Static MDX Pages
Create MDX files directly in your app directory:
app/
├── blog/
│ └── page.mdx
├── layout.tsx
└── page.tsx
Create app/blog/page.mdx:
# My Blog Post
This is **bold** and this is _italic_.
- List item 1
- List item 2
- List item 3
Option B: Dynamic MDX with Slugs
For dynamic blog posts with slug-based routing:
app/
├── blog/
│ └── [slug]/
│ └── page.tsx
content/
└── posts/
├── first-post.mdx
└── second-post.mdx
Create app/blog/[slug]/page.tsx:
import fs from "fs";
import path from "path";
import { MDXWrapper } from "@/mdx-components";
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// Read MDX file
const filePath = path.join(process.cwd(), "content/posts", `${slug}.mdx`);
const source = fs.readFileSync(filePath, "utf8");
return <MDXWrapper content={source} />;
}
export async function generateStaticParams() {
const postsDirectory = path.join(process.cwd(), "content/posts");
const filenames = fs.readdirSync(postsDirectory);
return filenames.map((filename) => ({
slug: filename.replace(/\.mdx$/, ""),
}));
}
Create content/posts/first-post.mdx:
---
title: "My First Post"
date: "2025-11-05"
---
# My First Post
Welcome to my **amazing** blog post!
## Features
- Easy to write
- Supports JSX
- TypeScript ready
7. Run Your Project
Start the development server:
npm run dev
Your app will be available at http://localhost:3000
Summary
You now have a fully configured Next.js project with:
✅ TypeScript and Tailwind CSS
✅ shadcn/ui components
✅ Dark mode with next-themes
✅ Smooth animations with framer-motion
✅ MDX support for both static and dynamic content
Happy coding! 🚀