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') }}
/>
);
글에는 BlogPosting과 BreadcrumbList를, 메인에는 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하나 →h2→h3)을 지킨다. 문단만 이어붙인 글은 어느 부분이 답인지 집기 어렵다 - 표와 목록을 쓴다. 비교·정리는 산문보다 표가 인용하기 좋다
- 날짜와 저자를 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
위 문서에 쉽게 설명되어 있으니 생략!