2026년 8월에 Next.js 16 기준으로 블로그를 개편하며, 블로그 내용도 업데이트했어요.

처음에는 Tistory에서 블로그를 운영했지만, 2025년 이후에는 GitHub Pages로 옮기며 새롭게 블로그를 시작했어요.

본 글에서는 기존 프로젝트를 Next.js 16과 App Router를 기준으로 다시 구성하면서, 현재 블로그와 같이 정적 페이지를 만들고, MDX를 읽고, 검색과 SEO 결과물을 생성한 뒤 GitHub Pages에 배포되는 흐름을 정리했어요.

GitHub Pages를 선택한 이유

블로그 플랫폼을 이용하면 서버와 배포를 신경 쓰지 않고 글에 집중할 수 있어요. 다만 화면 구성이나 URL, 글의 구조를 원하는 대로 바꾸기는 어려워요. 직접 서버를 운영하면 자유도는 높아지지만, 개인 블로그를 위해 비용과 운영 부담까지 감수해야 해요.

GitHub Pages는 두 방식의 중간에 있어요. 빌드 결과가 HTML, CSS, JavaScript 같은 정적 파일이라면 별도의 애플리케이션 서버 없이 배포할 수 있어요. 소스와 글의 변경 이력도 Git으로 함께 관리할 수 있고요. 디자인과 URL은 직접 정하면서 호스팅 구조는 단순하게 유지할 수 있다는 점이 이 블로그와 잘 맞았어요.

물론 제약도 분명해요. 요청할 때마다 서버에서 처리해야 하는 기능이나 cookies()를 사용하는 인증, Server Action, ISR은 정적 export와 함께 사용할 수 없어요. 새 글을 공개하거나 기존 글을 수정할 때도 사이트 전체를 다시 빌드해야 해요. 이 블로그는 읽기 중심이고 글이 자주 바뀌지 않기 때문에 이런 제약을 받아들였어요.

Next.js 16을 정적 사이트로 설정하기

Next.js 16에서는 output: "export"를 설정하면 next build가 정적 페이지를 생성해요. 예전에 사용하던 next export 명령은 더 이상 필요하지 않아요. 빌드가 끝나면 GitHub Pages에 배포할 파일이 out 디렉터리에 만들어져요.

현재 블로그의 핵심 설정은 다음과 같아요.

typescript
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
  pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
  images: {
    unoptimized: true,
  },
};
 
export default nextConfig;

trailingSlash: true를 사용하면 /articles/example/ 경로가 out/articles/example/index.html로 생성돼요. 정적 호스팅에서 디렉터리 기반 URL을 일관되게 유지할 수 있고, canonical과 내부 링크도 같은 규칙으로 맞추기 쉬워요.

기본 next/image 최적화에는 요청을 처리할 서버가 필요해요. 이 블로그는 미리 준비한 WebP 이미지를 그대로 제공하기 때문에 images.unoptimized를 사용해요. 외부 이미지 최적화 서비스를 연결한다면 custom loader를 사용하는 방법도 있어요.

정적 export가 지원하는 기능과 제한은 Next.js Static Exports 문서에서 확인할 수 있어요.

소스 파일이 build 엔진을 거쳐 정적 페이지와 검색, sitemap 구조로 나뉘는 과정

동적 route를 build 시점에 확정하기

글 상세 주소는 /articles/[slug]/처럼 동적 segment를 사용해요. GitHub Pages에는 요청 시점에 이 값을 처리할 서버가 없기 때문에 공개할 slug를 build 시점에 모두 알려줘야 해요.

현재 상세 route의 핵심만 남기면 다음과 같아요. 화면 layout과 metadata 생성 코드는 생략했어요.

typescript
export const dynamicParams = false;
 
export function generateStaticParams() {
  return getPublishedArticles("ko").map(({ slug }) => ({ slug }));
}
 
export default async function ArticlePage({ params }: PageProps<"/articles/[slug]">) {
  const { slug } = await params;
  const articleDocument = getArticleDocument(slug, "ko");
 
  if (!articleDocument?.metadata.isPublished) notFound();
 
  const { default: ArticleBody } = await import(`@/articles/${slug}/ko.mdx`);
  return <ArticleBody />;
}

generateStaticParams가 공개된 글의 slug를 반환하면 Next.js가 각 상세 페이지를 미리 만들어요. dynamicParams = false를 함께 설정하면 목록에 없는 slug는 404로 처리돼요. 번역 상세 route를 추가할 때도 locale과 slug 조합을 build 전에 확정해야 해요.

공개된 글 목록이 여러 정적 route로 생성되고 미등록 route가 제외되는 과정

자세한 동작은 generateStaticParams 문서에서 확인할 수 있어요.

MDX로 글 관리하기

글은 데이터베이스 대신 저장소 안의 MDX 파일로 관리해요. 글마다 디렉터리를 하나 만들고 번역 파일은 locale 이름으로 구분해요.

text
src/articles/
└── build-github-pages-blog-with-nextjs/
    ├── en.mdx
    ├── ja.mdx
    └── ko.mdx

한국어 원문만 있다면 ko.mdx만 둬요. 번역이 없는 영어와 일본어 상세 route는 만들지 않아요. 덕분에 검색 엔진과 사용자 모두 존재하지 않는 번역 페이지로 이동하지 않아요.

각 MDX 파일의 frontmatter에는 화면과 SEO에서 함께 사용하는 metadata를 작성해요.

yaml
id: article-001
slug: build-github-pages-blog-with-nextjs
locale: ko
category: frontend
topics: [nextjs, react]
legacyPaths:
  - /blog/nextjs/1/
title: Next.js 16으로 GitHub Pages 블로그 구축하기
description: 글의 핵심 내용을 한두 문장으로 정리해요.
publishedAt: 2025-01-07T20:13:19+09:00
modifiedAt: 2026-08-09T11:59:07+09:00
tags: [github-pages, static-export, mdx, seo]
isPublished: true
coverImage: /r/i/nextjs/1/thumbnail.webp

gray-matter가 frontmatter를 읽고 Zod schema가 필드와 category/topic의 관계를 검증해요. author를 생략하면 build 시점에 사이트의 기본 작성자가 들어가요. metadata가 잘못됐다면 배포 후가 아니라 build 과정에서 바로 오류가 발생해요.

@next/mdx와 remark, rehype 구성

Next.js 16의 App Router에서는 @next/mdx를 이용해 로컬 MDX를 React component처럼 가져올 수 있어요. 현재 블로그는 next-mdx-remote 대신 @next/mdx, @mdx-js/loader, @mdx-js/react를 사용해요. 본문 이미지와 code block의 공통 UI는 src/mdx-components.tsx에서 연결해요.

설정의 핵심 부분은 다음과 같아요.

typescript
import createMDX from "@next/mdx";
 
const withMDX = createMDX({
  options: {
    remarkPlugins: ["remark-frontmatter", "remark-gfm"],
    rehypePlugins: [
      ["rehype-pretty-code", { theme: "github-dark-default", keepBackground: false }],
      "rehype-slug",
      "rehype-autolink-headings",
    ],
  },
});

각 도구는 다음 역할을 맡아요.

  • remark-frontmatter는 MDX의 frontmatter 문법을 처리해요.
  • remark-gfm은 표, 취소선 등 GitHub Flavored Markdown 문법을 지원해요.
  • rehype-pretty-code와 Shiki는 build 시점에 code highlighting을 적용해요.
  • rehype-slugrehype-autolink-headings는 heading anchor를 만들어요.
  • 자체 rehype plugin은 heading 번호를 자동으로 붙여요.

본문의 heading은 ##부터 작성하고 번호는 직접 넣지 않아요. build 과정에서 번호와 anchor를 만들고, 같은 heading 정보로 TOC와 읽기 진행 표시를 구성해요. code block에는 언어 label과 복사 action이 공통 component로 적용되므로 글마다 같은 UI를 반복해서 작성할 필요가 없어요.

Next.js의 MDX 구성 방식은 공식 MDX 가이드를 기준으로 했어요.

현재 블로그의 URL 구조

새 구조에서는 일반 글을 article, 짧은 기록을 note로 구분해요. category는 큰 분야를, topic은 그 안의 세부 주제를 나타내요.

text
/articles/{slug}/
/categories/{category}/
/topics/{topic}/
/tags/{tag}/
/notes/{slug}/

한국어는 domain root를 사용하고 영어와 일본어에는 각각 /en/, /ja/ prefix를 붙여요. 모든 locale에서 같은 slug를 사용하므로 번역본의 관계도 명확하게 연결할 수 있어요. /blog/nextjs/1/처럼 이미 검색 엔진에 알려진 기존 주소는 새 canonical 주소로 이동하도록 호환 route를 남겨요.

URL을 자주 바꾸지 않기 위해 경로를 먼저 정한 뒤 글을 그 구조에 맞췄어요. 제목이나 category가 바뀌어도 article slug는 유지하고 canonical, sitemap, 내부 링크에는 최종 주소만 사용해요.

정적 환경에서 검색과 SEO 구성하기

정적 사이트에서도 검색과 SEO를 충분히 구성할 수 있어요. 다만 요청을 처리할 서버가 없으므로 build 결과와 브라우저 기능을 활용해야 해요.

Pagefind로 본문 검색하기

내부 검색에는 Pagefind를 사용해요. Next.js가 out 디렉터리를 만들면 Pagefind가 완성된 HTML에서 article과 note 본문만 읽어 index를 생성해요.

json
{
  "scripts": {
    "build": "next build && pnpm search:index && pnpm verify:links && pnpm verify:seo && pnpm verify:search",
    "search:index": "pagefind --site out"
  }
}

검색 engine과 index는 사용자가 검색어를 입력한 뒤에 불러와요. 초기 화면에 검색용 JavaScript를 미리 싣지 않으면서도 제목, 요약, category, topic, tag와 본문을 함께 검색할 수 있어요. 검색 결과를 별도 URL로 만들지 않기 때문에 canonical과 sitemap에도 영향을 주지 않아요.

metadata와 검색 엔진용 파일 만들기

article 상세 페이지는 frontmatter를 기준으로 title, description, canonical, Open Graph, Twitter Card를 만들어요. 페이지에는 BlogPosting JSON-LD와 breadcrumb 구조화 데이터도 추가해요. 번역본이 있을 때만 hreflang으로 locale 관계를 연결해요.

Next.js의 metadata route를 이용해 다음 파일도 정적으로 생성해요.

  • /sitemap.xml: 공개된 article, note, category, topic, tag, locale 홈을 안내해요.
  • /robots.txt: sitemap과 feed 주소를 검색봇에 알려줘요.
  • /feed.xml: Atom feed를 제공해요.
  • /rss.xml: RSS 2.0 feed를 제공해요.

lastmod에는 build 시각이 아니라 각 문서의 실제 modifiedAt을 사용해요. build 마지막에는 내부 링크, canonical, 구조화 데이터, sitemap, feed, Pagefind index를 검증해 누락된 정적 결과물을 배포 전에 찾도록 했어요.

GitHub Pages에 배포하기

pnpm build가 성공하면 out 디렉터리만 정적 호스팅에 올리면 돼요. GitHub 저장소의 Pages 배포 소스를 GitHub Actions로 지정하고, build 결과를 actions/upload-pages-artifact로 전달한 뒤 actions/deploy-pages로 배포해요.

소스 변경, build, 검증, artifact 업로드, GitHub Pages 배포로 이어지는 과정

핵심 흐름을 줄이면 다음과 같아요.

yaml
name: Deploy blog
 
on:
  push:
    branches: [main]
  workflow_dispatch:
 
permissions:
  contents: read
  pages: write
  id-token: write
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v6
        with:
          version: 10.23.0
          run_install: false
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: pnpm
      - uses: actions/configure-pages@v6
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-pages-artifact@v5
        with:
          path: ./out
          include-hidden-files: true
 
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v5

배포 job에는 pages: writeid-token: write 권한이 필요해요. build와 deploy를 분리하면 build가 성공한 경우에만 배포가 이어지도록 구성할 수 있어요. 세부 설정은 GitHub Pages custom workflow 문서를 참고하면 돼요.

이 구성이 잘 맞는 경우

Next.js 16의 Static Export와 GitHub Pages 조합이 모든 웹사이트에 적합한 것은 아니에요. 요청 시점의 인증과 데이터 처리, Server Action, ISR이 필요하다면 서버 런타임을 제공하는 호스팅이 더 나아요. 이미지 최적화도 직접 준비하거나 별도 서비스를 연결해야 해요.

반대로 읽기 중심의 개인 블로그에는 장점이 분명해요. 글과 UI를 한 저장소에서 관리하고, 모든 공개 경로를 build 시점에 검증하며, 별도 서버 없이 결과물을 오래 유지할 수 있어요. 현재 블로그는 필요한 동적 기능은 브라우저에서 처리하고 SEO에 필요한 결과는 정적 파일로 생성하는 방식을 선택했어요.

중요한 점은 Next.js를 사용했다는 사실보다 GitHub Pages의 제약을 먼저 이해하고 구조를 정한 데 있어요. 정적으로 만들 페이지와 build 후 생성할 검색 index를 분리하니 기능을 늘리면서도 배포 구조는 단순하게 유지할 수 있었어요.