Published on
👁️

Tailwind-Nextjs-Starter 블로그 사용하기

Authors
  • avatar
    Name
    River
    Twitter

tailwind-nextjs-starter-blog 기본 구조

  • 기술 블로그로 기존엔 Jekyll Chripy 블로그를 사용했지만, 익숙하지 않는 Ruby 기반의 Jekyll 블로그는 혼자 커스텀하는데 한계가 있었다.

  • 프로젝트를 하면서 많이 경험한 Tailwind CSS와 React로 자유로운 커스텀을 할 예정이다.

  • tailwind-nextjs-starter-blog

  • 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.js
    export 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.js
    async 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.tsx
    import 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.tsx
        import { 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