- Published on
- •👁️
Tailwind-Nextjs-Starter 블로그 사용하기
- Authors

- Name
- River
tailwind-nextjs-starter-blog 기본 구조
기술 블로그로 기존엔 Jekyll Chripy 블로그를 사용했지만, 익숙하지 않는 Ruby 기반의 Jekyll 블로그는 혼자 커스텀하는데 한계가 있었다.
프로젝트를 하면서 많이 경험한 Tailwind CSS와 React로 자유로운 커스텀을 할 예정이다.
Starter의 구조는 다음과 같다.
기본구조├── app/ # Next.js 13+ App Router │ ├── layout.tsx # 전체 앱 레이아웃 (메타데이터, 폰트, 테마 설정) │ ├── page.tsx # 홈페이지 (최신 블로그 포스트 목록) │ ├── not-found.tsx # 404 에러 페이지 │ ├── robots.ts # 검색 엔진 크롤링 설정 (robots.txt) │ ├── seo.tsx # SEO 메타데이터 생성 유틸리티 │ ├── sitemap.ts # 사이트맵 생성 (sitemap.xml) │ ├── theme-providers.tsx # 테마 설정 컴포넌트 │ ├── tag-data.json # 태그 데이터 JSON 파일 │ │ │ ├── blog/ # 블로그 관련 페이지 │ │ ├── page.tsx # 블로그 메인 페이지 │ │ ├── [slug]/page.tsx # 개별 블로그 포스트 페이지 │ │ └── page/[page]/page.tsx # 블로그 페이지네이션 │ │ │ ├── tags/ # 태그 관련 페이지 │ │ ├── page.tsx # 태그 목록 페이지 │ │ ├── [tag]/page.tsx # 특정 태그의 포스트 목록 │ │ └── [tag]/page/[page]/page.tsx # 태그별 포스트 페이지네이션 │ │ │ ├── projects/ # 프로젝트 관련 페이지 │ │ └── page.tsx # 프로젝트 목록 페이지 │ │ │ ├── about/ # 소개 페이지 │ │ └── page.tsx # 저자 소개 페이지 │ │ │ └── api/ # API 라우트 │ └── newsletter/route.ts # 뉴스레터 구독 API 엔드포인트 │ ├── components/ # 재사용 가능한 UI 컴포넌트 │ ├── Card.tsx # 프로젝트 카드 컴포넌트 │ ├── Comments.tsx # 댓글 기능 컴포넌트 │ ├── Footer.tsx # 푸터 컴포넌트 │ ├── Header.tsx # 헤더 컴포넌트 │ ├── Image.tsx # Next.js Image 래퍼 컴포넌트 │ ├── LayoutWrapper.tsx # 페이지 레이아웃 래퍼 │ ├── Link.tsx # Next.js Link 래퍼 컴포넌트 │ ├── MDXComponents.tsx # MDX 사용 컴포넌트 정의 │ ├── MobileNav.tsx # 모바일 네비게이션 컴포넌트 │ ├── PageTitle.tsx # 페이지 제목 스타일링 컴포넌트 │ ├── ScrollTopAndComment.tsx # 페이지 상단/댓글로 이동 버튼 │ ├── SearchButton.tsx # 검색 버튼 컴포넌트 │ ├── SectionContainer.tsx # 콘텐츠 최대 너비 제한 컴포넌트 │ ├── TableWrapper.tsx # 테이블 스타일링 래퍼 │ ├── Tag.tsx # 태그 표시 컴포넌트 │ └── ThemeSwitch.tsx # 테마 전환 버튼 컴포넌트 │ ├── layouts/ # 페이지 레이아웃 템플릿 │ ├── AuthorLayout.tsx # 저자 페이지 레이아웃 │ ├── ListLayout.tsx # 포스트 목록 레이아웃 │ ├── ListLayoutWithTags.tsx # 태그가 있는 포스트 목록 레이아웃 │ ├── PostBanner.tsx # 배너 이미지가 있는 포스트 레이아웃 │ ├── PostLayout.tsx # 기본 포스트 레이아웃 │ └── PostSimple.tsx # 간단한 포스트 레이아웃 │ ├── data/ # 정적 데이터 │ ├── headerNavLinks.ts # 헤더 네비게이션 링크 정의 │ ├── projectsData.js # 프로젝트 정보 데이터 │ ├── siteMetadata.js # 사이트 메타데이터 │ └── authors/ # 저자 정보 MDX 파일들 │ ├── css/ # 스타일 │ ├── tailwind.css # Tailwind CSS 설정 │ └── prism.css # 코드 구문 강조 스타일 │ ├── blog/ # 블로그 포스트 MDX 파일들 │ ├── static/ # 정적 파일들 │ └── images/ # 이미지 파일들 │ ├── contentlayer.config.ts # 콘텐츠 스키마 정의 및 MDX 처리 설정 ├── next.config.js # Next.js 설정 ├── tailwind.config.js # Tailwind CSS 설정 ├── tsconfig.json # TypeScript 설정 └── package.json # 패키지 의존성 및 스크립트
카테고리 만들기
아쉽게도 이 블로그에는 카테고리를 설정하는 부분이 없다.
카테고리 페이지 설정
추가/변경├── contentlayer.config.ts # Blog 타입에 categories 필드 추가, 카테고리 데이터 생성 로직 추가 │ ├── components/ │ └── CategoryDisplay.tsx # 새로 생성할 카테고리 표시 컴포넌트 │ ├── layouts/ │ └── PostLayout.tsx # 카테고리 표시 섹션 추가 │ ├── app/ │ ├── category-data.json # 새로 생성할 카테고리 데이터 파일 │ └── categories/ # 카테고리 페이지들 │ ├── page.tsx # 카테고리 목록 페이지 │ └── [...slug]/page.tsx # 카테고리별 포스트 목록 페이지 │ └── data/ └── headerNavLinks.ts # 카테고리 링크 추가
contentlayer.config.js
defineDocumentType()수정contentlayer.config.jsexport const Blog = defineDocumentType(() => ({ name: 'Blog', filePathPattern: 'blog/**/*.mdx', contentType: 'mdx', fields: { title: { type: 'string', required: true }, date: { type: 'date', required: true }, tags: { type: 'list', of: { type: 'string' }, default: [] }, + categories: { type: 'list', of: { type: 'string' } }, lastmod: { type: 'date' }, draft: { type: 'boolean' }, summary: { type: 'string' }, images: { type: 'json' }, authors: { type: 'list', of: { type: 'string' } }, layout: { type: 'string' }, bibliography: { type: 'string' }, canonicalUrl: { type: 'string' }, }, computedFields: { ...computedFields, structuredData: { type: 'json', resolve: (doc) => ({ '@context': 'https://schema.org', '@type': 'BlogPosting', headline: doc.title, datePublished: doc.date, dateModified: doc.lastmod || doc.date, description: doc.summary, image: doc.images ? doc.images[0] : siteMetadata.socialBanner, url: `${siteMetadata.siteUrl}/${doc._raw.flattenedPath}`, }), }, }, }))
createCategoryData()생성contentlayer.config.jsasync function createCategoryData(allBlogs) { const categoryCount: Record<string, number> = {} allBlogs.forEach((file) => { if (file.categories && (!isProduction || file.draft !== true)) { file.categories.forEach((category) => { const parts = Array.isArray(category) ? category : category.split('/').map(c => c.trim()) if (parts[0]) { const mainCat = parts[0] if (mainCat in categoryCount) { categoryCount[mainCat] += 1 } else { categoryCount[mainCat] = 1 } if (parts[1]) { const subCat = `${mainCat}/${parts[1]}` if (subCat in categoryCount) { categoryCount[subCat] += 1 } else { categoryCount[subCat] = 1 } if (parts[2]) { const leafCat = `${mainCat}/${parts[1]}/${parts[2]}` if (leafCat in categoryCount) { categoryCount[leafCat] += 1 } else { categoryCount[leafCat] = 1 } } } } }) } }) const formatted = await prettier.format(JSON.stringify(categoryCount, null, 2), { parser: 'json' }) writeFileSync('./app/category-data.json', formatted) }
onSuccess()수정contentlayer.config.js}, onSuccess: async (importData) => { const { allBlogs } = await importData() createTagCount(allBlogs) createSearchIndex(allBlogs) + createCategoryData(allBlogs) }, })
카테고리 표시 컴포넌트 만들기
components/CategoryDisplay.tsx파일을 생성CategoryDisplay.tsximport Link from './Link' interface CategoryDisplayProps { categories: string[] | string[][] } const CategoryDisplay = ({ categories }: CategoryDisplayProps) => { if (!categories || !Array.isArray(categories) || categories.length === 0) return null return ( <div className="flex flex-wrap"> {categories.map((category, index) => { const parts = Array.isArray(category) ? category : category.split('/').map(c => c.trim()) const [main, sub, leaf] = parts return ( <div key={index} className="mr-3 text-sm font-medium text-primary-500"> <Link href={`/categories/${encodeURIComponent(main)}`} className="hover:text-primary-600 dark:hover:text-primary-400" > {main} </Link> {sub && ( <> <span className="mx-1">/</span> <Link href={`/categories/${encodeURIComponent(main)}/${encodeURIComponent(sub)}`} className="hover:text-primary-600 dark:hover:text-primary-400" > {sub} </Link> </> )} {leaf && ( <> <span className="mx-1">/</span> <Link href={`/categories/${encodeURIComponent(main)}/${encodeURIComponent(sub)}/${encodeURIComponent(leaf)}`} className="hover:text-primary-600 dark:hover:text-primary-400" > {leaf} </Link> </> )} </div> ) })} </div> ) } export default CategoryDisplay
게시글 Layout에 카테고리 표시 추가하기
layouts/PostLayout.tsx파일 수정PostLayout.tsximport { ReactNode } from 'react' import { CoreContent } from 'pliny/utils/contentlayer' import type { Blog, Authors } from 'contentlayer/generated' import Comments from '@/components/Comments' import Link from '@/components/Link' import PageTitle from '@/components/PageTitle' import SectionContainer from '@/components/SectionContainer' import Image from '@/components/Image' import Tag from '@/components/Tag' import siteMetadata from '@/data/siteMetadata' import ScrollTopAndComment from '@/components/ScrollTopAndComment' + import CategoryDisplay from '@/components/CategoryDisplay' ... <footer> <div className="divide-gray-200 text-sm leading-5 font-medium xl:col-start-1 xl:row-start-2 xl:divide-y dark:divide-gray-700"> + {content.categories && ( + <div className="py-4 xl:py-8"> + <h2 className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400"> + 카테고리 + </h2> + <CategoryDisplay categories={content.categories} /> + </div> + )} {tags && ( <div className="py-4 xl:py-8"> <h2 className="text-xs tracking-wide text-gray-500 uppercase dark:text-gray-400"> Tags </h2> <div className="flex flex-wrap"> {tags.map((tag) => ( <Tag key={tag} text={tag} /> ))} </div> </div> )}
카테고리 페이지 구현
app/categories폴더 생성 후app/categories/page.tsx파일을 생성app/categories/page.tsx