Next.js 블로그 만들기

Next.js App Router와 MDX로 블로그를 만들고, SEO와 AEO까지 붙인 기록

2026-08 수정

이전에 스벨트킷으로 블로그 만들어보기 에서 svelte kit으로 블로그를 만들었었다.

그때는 svelte 공부겸 블로그를 만들었지만, 업무에서 주로 사용하는 스택인 react와 next.js를 사용하여 블로그를 만들어보려고 한다.

처음 쓸 때는 gray-matter로 프론트매터를 파싱하고 next-mdx-remote로 직렬화하는 구조였는데, 이후 구조를 몇 번 바꿨다. 코드가 실제 구현과 달라 헷갈릴 것 같아 지금 돌아가는 코드 기준으로 다시 정리했다.

시작


npx create-next-app <project-name>

next.js 프로젝트를 생성한다. GitHub Pages에 올릴 거라 정적 익스포트로 설정했다.

// next.config.js
const createMDX = require('@next/mdx')({
  options: {
    remarkPlugins: ['remark-gfm'],
    rehypePlugins: ['rehype-prism-plus']
  }
});

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export',
  pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'],
  reactCompiler: true,
  images: {
    loader: 'custom',
    loaderFile: './src/lib/my-image-loader.ts'
  }
};

module.exports = createMDX(nextConfig);

output: 'export'를 쓰면 서버가 필요한 기능은 못 쓰지만, 빌드 결과가 그냥 정적 파일이라 GitHub Pages에 그대로 올릴 수 있다.

라우팅


App Router를 쓰고, 라우트 그룹으로 영역을 나눴다.

src/app
├── layout.tsx              루트 레이아웃 (Header, Footer, 테마)
├── (home)/page.tsx         메인
├── (blog)/blog/page.tsx    글 목록
├── (blog)/blog/[slug]/     글 상세
├── (photo)/photo/page.tsx  사진
├── about/page.tsx
├── search/page.tsx
├── sitemap.ts
├── robots.ts
├── feed.xml/route.ts
└── llms.txt/route.ts

처음에는 blog/[category]/[slug]로 카테고리를 URL에 넣었는데, 글을 옮길 때마다 주소가 바뀌는 게 싫어서 blog/[slug]로 단순화했다. 카테고리는 폴더 구조로만 관리한다.

글은 MDX 파일 그대로


gray-matter로 프론트매터를 파싱하는 대신, MDX가 값을 export 할 수 있다는 점을 이용한다. 라이브러리 없이 메타데이터를 그대로 가져올 수 있다.

src/_posts/<category>/<slug>/<slug>.mdx
export const metadata = {
  title: 'BFCache',
  date: '2025-11-10',
  description: '브라우저의 Back-Forward Cache에 대해',
  category: 'javaScript',
  titleImage: '/assets/postIcon/javascript.webp'
};

## Back-Forward Cache(BFCache)
본문...

이렇게 두면 import만으로 메타데이터와 컴포넌트를 함께 얻는다.

const postModule = await import(`@/_posts/${category}/${slug}/${slug}.mdx`);

postModule.metadata; // { title, date, description, ... }
postModule.default;  // 렌더링할 컴포넌트

디렉토리를 훑어 category/slug 인덱스를 만들고, 그 인덱스로 메타데이터를 모은다. 빌드 중 여러 번 호출되므로 결과는 모듈 스코프에 캐싱했다.

// utils/getPosts.ts
const CONTENTS_ROOT_DIRECTORY_PATH = path.join(process.cwd(), 'src', '_posts');

let cachedPostMetaList: ContentPostMeta[] | null = null;

const buildPostMetaIndex = async (): Promise<ContentPostMeta[]> => {
  if (cachedPostMetaList !== null) {
    return cachedPostMetaList;
  }

  const categoryAndSlugList = await buildCategoryAndSlugIndex();
  const postMetaList: ContentPostMeta[] = [];

  for (const { category, slug } of categoryAndSlugList) {
    const postModule = (await import(`@/_posts/${category}/${slug}/${slug}.mdx`)) as ContentPostModule;

    if (!postModule.metadata) {
      throw new Error(`"${slug}.mdx" 에 metadata가 없음.`);
    }

    postMetaList.push({ category, slug, metadata: postModule.metadata });
  }

  postMetaList.sort((first, second) => {
    return parseDateToTimestamp(second.metadata.date) - parseDateToTimestamp(first.metadata.date);
  });

  cachedPostMetaList = postMetaList;

  return postMetaList;
};

slug로 카테고리를 역추적해야 해서 slug는 전체에서 유일해야 한다. 실수로 겹치면 빌드가 조용히 이상해지는 대신 바로 죽도록 검사를 넣어뒀다.

export const getAllSlugs = async (): Promise<string[]> => {
  const slugList = (await buildCategoryAndSlugIndex()).map(({ slug }) => slug);

  if (new Set(slugList).size !== slugList.length) {
    throw new Error('slug 값 중복. slug는 모든 category에서 유일해야함.');
  }

  return slugList;
};

이전 글·다음 글은 정렬된 인덱스에서 앞뒤를 집으면 된다.

export const importPostModuleBySlug = async (slug: string) => {
  const category = await findCategoryBySlug(slug);
  const postModule = await import(`@/_posts/${category}/${slug}/${slug}.mdx`);
  const postMetaList = await buildPostMetaIndex();

  const currentIndex = postMetaList.findIndex((meta) => meta.slug === slug);

  return {
    ...postModule,
    prevPost: postMetaList[currentIndex + 1],
    nextPost: postMetaList[currentIndex - 1]
  };
};

페이지


정적 익스포트라 generateStaticParams로 모든 slug를 미리 만들어야 한다.

// app/(blog)/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const slugList = await getAllSlugs();

  return slugList.map((slug) => ({ slug }));
}

export default async function Page({ params }: PageProps) {
  const { slug } = await params;
  const { default: Post, metadata, prevPost, nextPost } = await importPostModuleBySlug(slug);

  return (
    <>
      <WaveBanner title={metadata.title} type="post" description={metadata.description} date={metadata.date} />
      <BlogDetailContainer prevPost={prevPost} nextPost={nextPost}>
        <Post />
      </BlogDetailContainer>
    </>
  );
}

next-mdx-remote로 직렬화하던 과정이 통째로 사라졌다. MDX가 이미 컴포넌트라 <Post />로 그냥 그리면 된다.

MDX 안에서 공통으로 쓸 컴포넌트는 mdx-components.tsx에 등록해두면 import 없이 쓸 수 있다.

// mdx-components.tsx
import type { MDXComponents } from 'mdx/types';
import DemoBox from '@/components/mdx/DemoBox';

const components: MDXComponents = {
  DemoBox
};

export function useMDXComponents(): MDXComponents {
  return components;
}

SEO


sitemap.ts, robots.ts를 app 폴더에 두면 Next가 알아서 파일을 만들어 준다.

// app/sitemap.ts
export const dynamic = 'force-static';

// /search 는 사이트 내부 검색 UI 라 색인 대상이 아니다.
// 우선순위를 전부 1로 두면 의미가 없어서 문서 성격에 맞춰 차등을 뒀다.
const pageRoutes = [
  { path: '', priority: 1, changeFrequency: 'daily' },
  { path: '/blog', priority: 0.9, changeFrequency: 'daily' },
  { path: '/photo', priority: 0.6, changeFrequency: 'weekly' },
  { path: '/about', priority: 0.5, changeFrequency: 'monthly' }
];

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const { posts } = await getAllPost({ limit: -1 });

  return [
    ...pageRoutes.map((route) => ({
      url: `${defaultUrl}${route.path}`,
      lastModified,
      changeFrequency: route.changeFrequency,
      priority: route.priority
    })),
    ...posts.map((post) => ({
      url: `${defaultUrl}/blog/${post.slug}`,
      lastModified: new Date(post.data.date).toISOString(),
      changeFrequency: 'monthly' as const,
      priority: 0.8
    }))
  ];
}

robots.ts에서 한 가지 조심할 게 있었다. 정적 익스포트 산출물에는 라우터가 쓰는 RSC 페이로드 .txt 파일이 500개 넘게 같이 올라간다. 본문과 중복되는 데다 사람이 읽을 것도 아니라 크롤링에서 뺐다.

// app/robots.ts
export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: ['/', '/llms.txt'],
        disallow: '/*.txt$'
      }
    ],
    sitemap: `${BASE_URL}/sitemap.xml`,
    host: BASE_URL
  };
}

여기서 /_next/는 절대 막으면 안 된다. CSS와 JS가 들어 있어서 차단하면 Googlebot이 페이지를 렌더링하지 못해 색인 품질이 떨어진다. 처음에 무심코 넣었다가 뺐다.

메타데이터

페이지마다 반복되는 부분을 buildMetadata로 묶었다.

// utils/metadata.ts
export const buildMetadata = ({ title, description, path, ogImage, publishedTime, faviconPath }) => {
  const url = `${BASE_URL}${path}`;
  const imageUrl = `${BASE_URL}${ogImage ?? DEFAULT_OG_IMAGE}`;

  return {
    ...defaultMetadata,
    title: `${title} | ${SITE_NAME}`,
    // 검색 결과 요약은 155자 안팎에서 잘리므로 브랜드 접두사를 붙이지 않는다
    description,
    alternates: { canonical: url },
    openGraph: {
      ...defaultOpenGraph,
      title,
      description,
      url,
      // 글은 website 가 아니라 article 이어야 발행일·저자가 함께 인식된다
      type: publishedTime ? 'article' : 'website',
      ...(publishedTime && {
        publishedTime: new Date(publishedTime).toISOString(),
        authors: [AUTHOR_NAME]
      }),
      images: [{ url: imageUrl, width: 1200, height: 630, alt: title }]
    },
    twitter: { card: 'summary_large_image', title, description, images: [imageUrl] }
  };
};

처음에는 제목·설명 앞에 전부 Been blog -, Been dev-note -를 붙였는데, 검색 결과에서 앞부분을 브랜드명이 다 먹어버려서 뺐다. 제목도 제목 | Been blog 순서로 바꿔 실제 제목이 먼저 보이게 했다.

OG 이미지

원래는 카테고리 아이콘을 og:image로 그대로 썼는데 문제가 많았다.

  • 실제 크기가 128~512px라 twitter:card=summary_large_image의 최소 요건(300×157)에 미달
  • 11개 글은 SVG를 쓰고 있었는데 대부분의 소셜 스크래퍼는 SVG를 렌더하지 못한다
  • .webp 확장자인데 내용은 PNG/JPEG인 파일이 10개

그래서 빌드 전에 sharp로 글마다 1200×630 카드를 미리 만든다. SVG로 레이아웃을 짜서 래스터화하는 방식이라 한글도 잘 나온다.

// scripts/build-og-images.mjs
const svg = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">
  <rect width="1200" height="630" fill="#1a1a1a"/>
  <rect width="1200" height="10" fill="url(#bar)"/>
  <text font-size="58" font-weight="700" fill="#f6f7f9">${titleTspans}</text>
</svg>`);

await sharp(svg).composite(layers).png().toFile(outFile);
"scripts": {
  "build": "node scripts/build-og-images.mjs && next build"
}

구조화 데이터

검색 결과에 날짜와 저자가 노출되려면 JSON-LD가 필요하다. 서버 컴포넌트에서 script 태그로 내보내면 클라이언트 번들에 들어가지 않는다.

const JsonLd = ({ data }: { data: Record<string, unknown> }) => (
  <script
    type="application/ld+json"
    dangerouslySetInnerHTML={{ __html: JSON.stringify(data).replace(/</g, '\\u003c') }}
  />
);

글에는 BlogPostingBreadcrumbList를, 메인에는 WebSite를 붙였다.

export const buildBlogPostingSchema = ({ title, description, slug, date, category }) => ({
  '@context': 'https://schema.org',
  '@type': 'BlogPosting',
  headline: title,
  description,
  url: `${BASE_URL}/blog/${slug}`,
  image: [`${BASE_URL}/assets/og/${slug}.png`],
  datePublished: new Date(date).toISOString(),
  inLanguage: 'ko-KR',
  articleSection: category,
  author: { '@type': 'Person', name: AUTHOR_NAME, url: `${BASE_URL}/about` }
});

h1

페이지마다 h1이 여러 개였다. 메인에 3개, 글 목록에는 **내용이 빈 h1**까지 있었다. 섹션 제목 컴포넌트의 기본 태그를 h2로 바꾸고, 제목이 없으면 아예 렌더하지 않도록 했다.

const Title = ({ title, className, as: Tag = 'h2', ...rest }: TitleProps) => {
  if (!title) {
    return null;
  }

  return <Tag id={`${title}-heading`} className={cn(...)} {...rest}>{title}</Tag>;
};

AEO


검색엔진 말고 답변 엔진(ChatGPT, Perplexity, Google AI Overviews 같은)에 대한 대비도 해뒀다. 검색엔진은 순위를 매겨 링크를 주지만, 답변 엔진은 내용을 읽고 요약해서 답을 만들고 출처를 단다. 그래서 신경 쓸 지점이 조금 다르다.

  • 검색엔진은 페이지를 렌더링하지만, 답변 엔진 크롤러는 HTML 텍스트를 그대로 읽는 경우가 많다
  • 링크가 아니라 인용이 목적이라, 무슨 글인지 짧고 정확하게 알려주는 게 중요하다

llms.txt

llmstxt.org 규약을 따르는 요약 파일을 만들었다. 사이트 전체를 훑게 두는 대신 무슨 글이 어디에 있는지 한 파일에 정리해 두는 것이다.

// app/llms.txt/route.ts
export const dynamic = 'force-static';

export async function GET() {
  const { posts } = await getAllPost({ limit: -1 });

  // 카테고리로 묶어서 제목·설명·날짜를 한 줄씩
  const sections = [...grouped.entries()].map(([category, items]) => {
    const lines = items
      .map((post) => `- [${post.data.title}](${BASE_URL}/blog/${post.slug}): ${post.data.description} (${post.data.date})`)
      .join('\n');

    return `## ${category}\n\n${lines}`;
  });

  const body = `# ${SITE_NAME}\n\n> ${SITE_DESCRIPTION}\n\n${sections.join('\n\n')}\n`;

  return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}

여기서 앞서 넣은 Disallow: /*.txt$llms.txt가 그대로 걸린다. robots 규칙은 더 긴 경로가 우선하므로 명시적으로 먼저 허용해 줘야 한다.

allow: ['/', '/llms.txt'],
disallow: '/*.txt$'

AI 크롤러

기본값도 허용이지만, 자기 UA를 따로 찾는 봇이 있어서 명시해 뒀다. 학습·인용을 원치 않으면 이 목록을 disallow로 바꾸면 된다.

const ANSWER_ENGINE_BOTS = [
  'GPTBot', 'OAI-SearchBot', 'ChatGPT-User',
  'ClaudeBot', 'Claude-User', 'anthropic-ai',
  'PerplexityBot', 'Perplexity-User',
  'Google-Extended', 'Applebot-Extended', 'CCBot'
];

rules: [
  { userAgent: '*', allow: ['/', '/llms.txt'], disallow: '/*.txt$' },
  { userAgent: ANSWER_ENGINE_BOTS, allow: ['/', '/llms.txt'], disallow: '/*.txt$' }
]

그 밖에

답변 엔진이 인용하기 좋게 만드는 건 결국 글 자체의 구조다.

  • 글마다 description을 한 문장으로 정확하게 쓴다. llms.txt와 메타 설명에 그대로 쓰인다
  • 제목 계층(h1 하나 → h2h3)을 지킨다. 문단만 이어붙인 글은 어느 부분이 답인지 집기 어렵다
  • 표와 목록을 쓴다. 비교·정리는 산문보다 표가 인용하기 좋다
  • 날짜와 저자를 JSON-LD로 명시한다. 최신성 판단에 쓰인다

RSS


개발 블로그는 피드 구독 유입이 있어서 feed.xml도 만들었다. 정적 익스포트에서도 force-static 라우트 핸들러면 빌드 타임에 파일로 떨어진다.

// app/feed.xml/route.ts
export const dynamic = 'force-static';

export async function GET() {
  const { posts } = await getAllPost({ limit: -1 });

  const items = posts.map(({ slug, data }) => `    <item>
      <title>${escapeXml(data.title)}</title>
      <link>${BASE_URL}/blog/${slug}</link>
      <guid isPermaLink="true">${BASE_URL}/blog/${slug}</guid>
      <pubDate>${new Date(data.date).toUTCString()}</pubDate>
    </item>`).join('\n');

  return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } });
}

피드 리더가 자동으로 찾을 수 있게 레이아웃에도 걸어준다.

alternates: {
  canonical: process.env.NEXT_PUBLIC_BASE_URL,
  types: { 'application/rss+xml': `${process.env.NEXT_PUBLIC_BASE_URL}/feed.xml` }
}

댓글


댓글은 giscus를 사용했다.

giscus는 github Discussions으로 댓글로 사용할 수 있게 해주는 라이브러리이다.

npm i @giscus/react

giscus

위 문서에 쉽게 설명되어 있으니 생략!