Next.js로 Tailwind 블로그 구축하기
- Date
- Adam Wathan
우리 팀은 만드는 모든 것에 블로그 포스트를 작성해야 한다고 믿습니다. 진행하는 모든 프로젝트에 대해 간단한 발표 포스트를 작성하도록 강제하는 것은 내재된 품질 검사 역할을 하며, 세상에 공개할 준비가 되기 전까지 프로젝트를 ‘완료’라고 선언하지 않도록 합니다.
문제는 오늘까지 그런 포스트를 게시할 곳이 실제로 없었다는 점이었습니다!
플랫폼 선택
우리는 개발자 팀이기 때문에 당연히 기성 솔루션을 사용하자고 설득할 수 없었고, Next.js를 사용해 간단하고 커스텀한 것을 만들기로 결정했습니다.
Next.js에는 좋은 점이 많지만, 우리가 이를 선택한 주된 이유는 MDX를 잘 지원하기 때문입니다. MDX는 우리가 글을 작성할 때 사용하고 싶은 포맷이었습니다.
# My first MDX post
MDX는 정말 멋진 작성 포맷입니다.
마크다운 안에 React 컴포넌트를 직접 삽입할 수 있기 때문이죠:
<MyComponent myProp={5} />
얼마나 멋진가요?
MDX는 일반 마크다운과 달리 콘텐츠 안에 React 컴포넌트를 직접 삽입할 수 있어 매우 흥미롭습니다. 이는 글쓰기에서 아이디어를 전달하는 방식에 많은 가능성을 열어줍니다. 이미지, 동영상, 코드 블록에만 의존하는 대신, 인터랙티브 데모를 만들어 콘텐츠의 두 단락 사이에 바로 삽입할 수 있습니다. 마크다운으로 작성하는 편의성도 잃지 않으면서 말이죠.
우리는 올해 말쯤 Tailwind CSS 문서 사이트를 재설계하고 다시 구축할 계획입니다. 인터랙티브 컴포넌트를 삽입할 수 있다는 점은 프레임워크가 어떻게 작동하는지 가르치는 데 큰 차이를 만듭니다. 그래서 작은 블로그 사이트를 테스트 프로젝트로 사용하는 것이 매우 합리적이라고 판단했습니다.
콘텐츠 구성하기
처음에는 pages
디렉토리에 직접 MDX 문서로 글을 작성했습니다. 하지만 시간이 지나면서 거의 모든 글에 관련된 에셋이 필요하다는 것을 깨달았습니다. 예를 들어, 최소한 Open Graph 이미지가 필요했습니다.
이러한 에셋을 다른 폴더에 저장하는 것은 다소 지저분하게 느껴졌기 때문에, 대신 pages
디렉토리 안에 각 글마다 폴더를 만들고, 글 내용을 index.mdx
파일에 넣기로 결정했습니다.
public/
src/
├── components/
├── css/
├── img/
└── pages/
├── building-the-tailwindcss-blog/
│ ├── index.mdx
│ └── card.jpeg
├── introducing-linting-for-tailwindcss-intellisense/
│ ├── index.mdx
│ ├── css.png
│ ├── html.png
│ └── card.jpeg
├── _app.js
├── _document.js
└── index.js
next.config.js
package.json
postcss.config.js
README.md
tailwind.config.js
이렇게 하면 해당 글과 관련된 모든 에셋을 같은 폴더에 함께 위치시킬 수 있고, webpack의 file-loader를 활용하여 글 안에서 직접 에셋을 가져올 수 있습니다.
Metadata
각 게시물에 대한 메타데이터는 MDX 파일 상단에서 내보내는 meta
객체에 저장합니다:
import { bradlc } from '@/authors'
import openGraphImage from './card.jpeg'
export const meta = {
title: 'Tailwind CSS IntelliSense 린팅 기능 소개',
description: `오늘 우리는 Visual Studio Code용 Tailwind CSS IntelliSense 확장의 새 버전을 출시합니다. 이 버전은 CSS와 마크업 모두에 Tailwind 전용 린팅 기능을 추가합니다.`,
date: '2020-06-23T18:52:03Z',
authors: [bradlc],
image: openGraphImage,
discussion: 'https://github.com/tailwindcss/tailwindcss/discussions/1956',
}
// 게시물 내용은 여기에 작성
여기서 게시물 제목(실제 게시물 페이지의 h1
과 페이지 제목에 사용), 설명(Open Graph 미리보기용), 게시 날짜, 작성자, Open Graph 이미지, 그리고 게시물에 대한 GitHub Discussions 스레드 링크를 정의합니다.
모든 작성자 데이터는 별도의 파일에 저장하며, 각 팀원의 이름, 트위터 핸들, 아바타를 포함합니다.
import adamwathanAvatar from './img/adamwathan.jpg'
import bradlcAvatar from './img/bradlc.jpg'
import steveschogerAvatar from './img/steveschoger.jpg'
export const adamwathan = {
name: 'Adam Wathan',
twitter: '@adamwathan',
avatar: adamwathanAvatar,
}
export const bradlc = {
name: 'Brad Cornes',
twitter: '@bradlc',
avatar: bradlcAvatar,
}
export const steveschoger = {
name: 'Steve Schoger',
twitter: '@steveschoger',
avatar: steveschogerAvatar,
}
작성자 객체를 게시물에 직접 가져오는 방식의 장점은, 필요할 때 쉽게 작성자를 추가할 수 있다는 점입니다:
export const meta = {
title: '팀에 속하지 않은 게스트 작성자의 예시 게시물',
authors: [
{
name: 'Simon Vrachliotis',
twitter: '@simonswiss',
avatar: 'https://pbs.twimg.com/profile_images/1160929863/n510426211_274341_6220_400x400.jpg',
},
],
// ...
}
이 방식은 작성자 정보를 중앙 집중식으로 관리하면서도 유연성을 유지할 수 있게 해줍니다.
게시물 미리보기 표시
홈페이지에서 각 게시물의 미리보기를 표시하고 싶었는데, 이게 생각보다 까다로운 문제로 드러났다.
기본적으로 우리가 원했던 것은 Next.js의 getStaticProps
기능을 사용해 빌드 시점에 모든 게시물 목록을 가져오고, 필요한 정보를 추출한 후 실제 페이지 컴포넌트에 전달해 렌더링하는 것이었다.
문제는 모든 페이지를 실제로 불러오지 않고 이 작업을 수행하고 싶었다는 점이다. 모든 페이지를 불러오면 홈페이지 번들에 전체 사이트의 모든 블로그 게시물이 포함되어 불필요하게 큰 번들이 만들어질 수 있다. 지금은 게시물이 몇 개밖에 없어서 큰 문제가 아니지만, 수십 개 또는 수백 개의 게시물이 생기면 낭비되는 바이트가 상당할 것이다.
여러 가지 방법을 시도해봤지만, 결국 webpack의 resourceQuery 기능과 커스텀 로더 몇 개를 조합해 각 블로그 게시물을 두 가지 형식으로 불러오는 방법을 선택했다.
- 전체 게시물: 게시물 페이지에서 사용
- 게시물 미리보기: 홈페이지에 필요한 최소한의 데이터만 불러옴
이렇게 설정하면 개별 게시물을 불러올 때 ?preview
쿼리를 추가하면 전체 게시물 내용 대신 메타데이터와 미리보기 요약만 포함된 훨씬 작은 버전을 얻을 수 있다.
다음은 그 커스텀 로더의 일부다:
{
resourceQuery: /preview/,
use: [
...mdx,
createLoader(function (src) {
if (src.includes('<!--more-->')) {
const [preview] = src.split('<!--more-->')
return this.callback(null, preview)
}
const [preview] = src.split('<!--/excerpt-->')
return this.callback(null, preview.replace('<!--excerpt-->', ''))
}),
],
},
이 로더를 사용하면 각 게시물의 요약을 <!--more-->
태그를 소개 문단 뒤에 붙이거나, <!--excerpt-->
와 <!--/excerpt-->
태그로 감싸서 게시물 내용과 완전히 독립적인 요약을 작성할 수 있다.
const meta = {
// ...
}
이 부분은 게시물의 시작 부분으로, 홈페이지에 표시하고 싶은 내용입니다.
<!--more-->
이 부분 이후의 내용은 해당 게시물을 실제로 볼 때만 번들에 포함됩니다.
이 문제를 우아하게 해결하는 것은 꽤 어려웠지만, 결국 미리보기와 실제 게시물 내용을 별도의 파일로 나누지 않고 하나의 파일로 유지할 수 있는 해결책을 찾아내는 것은 멋진 경험이었다.
다음/이전 글 링크 생성하기
이 간단한 사이트를 구축하면서 마지막으로 해결해야 했던 문제는 개별 글을 볼 때마다 다음 글과 이전 글 링크를 포함하는 것이었습니다.
핵심적으로 우리가 해야 할 일은 모든 글을 불러오고(가급적 빌드 시점에), 그 목록에서 현재 글을 찾은 다음, 이전 글과 다음 글을 가져와 페이지 컴포넌트에 props로 전달하는 것이었습니다.
이 작업은 예상보다 어려웠는데, MDX가 현재 getStaticProps
를 일반적으로 사용하는 방식으로 지원하지 않기 때문입니다. MDX 파일에서 직접 내보낼 수 없고, 대신 별도의 파일에 코드를 저장한 다음 거기서 다시 내보내야 합니다.
홈페이지에서 글 미리보기를 가져올 때 이 추가 코드를 로드하고 싶지 않았고, 모든 글에서 이 코드를 반복하고 싶지도 않았기 때문에, 우리는 커스텀 로더를 사용해 각 글의 시작 부분에 이 내보내기를 추가하기로 결정했습니다:
{
use: [
...mdx,
createLoader(function (src) {
const content = [
'import Post from "@/components/Post"',
'export { getStaticProps } from "@/getStaticProps"',
src,
'export default (props) => <Post meta={meta} {...props} />',
].join('\n')
if (content.includes('<!--more-->')) {
return this.callback(null, content.split('<!--more-->').join('\n'))
}
return this.callback(null, content.replace(/<!--excerpt-->.*<!--\/excerpt-->/s, ''))
}),
],
}
또한 이 커스텀 로더를 사용해 실제로 Post
컴포넌트에 정적 props를 전달해야 했기 때문에, 위에서 본 추가 내보내기도 덧붙였습니다.
하지만 이것이 유일한 문제는 아니었습니다. getStaticProps
는 현재 렌더링 중인 페이지에 대한 정보를 제공하지 않기 때문에, 다음 글과 이전 글을 결정할 때 어떤 글을 보고 있는지 알 수 없었습니다. 이 문제는 해결 가능할 것 같지만, 시간 제약으로 인해 빌드 시점보다는 클라이언트 측에서 더 많은 작업을 하기로 결정했습니다. 그래서 현재 라우트를 확인해 필요한 링크를 파악할 수 있었습니다.
getStaticProps
에서 모든 글을 불러오고, 각 글의 URL과 제목만 포함된 가벼운 객체로 매핑했습니다:
import getAllPostPreviews from '@/getAllPostPreviews'
export async function getStaticProps() {
return {
props: {
posts: getAllPostPreviews().map((post) => ({
title: post.module.meta.title,
link: post.link.substr(1),
})),
},
}
}
그런 다음 실제 Post
레이아웃 컴포넌트에서 현재 라우트를 사용해 다음 글과 이전 글을 결정했습니다:
export default function Post({ meta, children, posts }) {
const router = useRouter()
const postIndex = posts.findIndex((post) => post.link === router.pathname)
const previous = posts[postIndex + 1]
const next = posts[postIndex - 1]
// ...
}
이 방법은 현재로서는 잘 작동하지만, 장기적으로는 전체 글 목록 대신 getStaticProps
에서 다음 글과 이전 글만 로드할 수 있는 더 간단한 해결책을 찾고 싶습니다.
Hashicorp에서 만든 Next MDX Remote라는 흥미로운 라이브러리가 있습니다. 이 라이브러리는 MDX 파일을 데이터 소스처럼 다룰 수 있게 해주며, 동적 슬러그 기반 라우팅으로 전환할 수 있게 해줍니다. 이를 통해 getStaticProps
에서 현재 경로명에 접근할 수 있고 더 많은 기능을 활용할 수 있을 것입니다.
마무리
전체적으로 Next.js로 이 작은 사이트를 만드는 것은 재미있는 학습 경험이었습니다. 간단해 보이는 것들이 이런 도구들을 사용하면 얼마나 복잡해지는지 항상 놀랍지만, Next.js의 미래에 대해 매우 낙관적이고 앞으로 몇 달 안에 tailwindcss.com의 다음 버전을 만들기를 기대하고 있습니다.
이 블로그의 코드베이스를 확인하거나 위에서 언급한 것들을 단순화하기 위해 풀 리퀘스트를 제출하고 싶다면, GitHub 저장소를 확인해 보세요.
이 글에 대해 이야기하고 싶으신가요? GitHub에서 토론하기 →