Back to blog
nextjs, setup, guide17 min read

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! 🚀