Next.js 블로그 만들기

Next.js로 블로그 만들기

이전에 스벨트킷으로 블로그 만들어보기 에서 svelte kit으로 블로그를 만들었었다.

그때는 svelte 공부겸 블로그를 만들었지만, 업무에서 주로 사용하는 스택인 react와 next.js를 사용하여 블로그를 만들어보려고 한다.

시작


npx create-next-app <project-name>

next.js 프로젝트를 생성한다.

라우팅


next-blog-route

app 폴더 구조는 위와 같다.

  • root layout을 만들어 Header, Footer를 구성한다.
  • page/[page]는 블로그의 메인이며 페이지네이션을 위해 동적으로 구성하였다.
  • blog/[category]/[slug]는 카테고리별로 블로그를 구분하고, slug를 통해 각 post를 구분한다.

블로그 포스트

root layout을 만들었으니, 이제 블로그 포스트를 만들어보자.

root 디렉토리에 _posts폴더를 만들고 그 안에 markdown 파일을 만든다.

npm install gray-matter

gray-matter는 markdown 파일의 front matter를 파싱하는 라이브러리이다.

import path from 'path';
import * as fs from 'fs';
import matter from 'gray-matter';
import { getSerialize } from '@/utils/sirialize';

export const POSTS_PATH = path.join(process.cwd(), '_posts');

export const postFilePaths = fs.readdirSync(POSTS_PATH).filter((path) => /\.mdx?$/.test(path));

export const getAllPost = (
  category?: string,
  paging: { page: string; limit?: string } = {
    page: '1',
    limit: '10'
  }
) => {
  let total = postFilePaths.length;
  let posts = postFilePaths
    .map((filePath) => {
      const source = fs.readFileSync(path.join(POSTS_PATH, filePath));
      const { content, data } = matter(source);
      const slug = filePath.replace(/\.mdx?$/, '');

      return {
        content,
        data,
        slug
      };
    })
    .sort((a, b) => {
      return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
    });

  if (category) {
    posts = posts.filter((post) => post.data.category === category);
    total = posts.length;
  }

  if (!paging.page) {
    paging.page = '1';
    paging.limit = '10';
  }

  if (paging.limit === '-1') {
    return {
      posts,
      total
    };
  }

  const { page = '1', limit = '10' } = paging;
  const offset = (+page - 1) * +limit;

  posts = posts.slice(offset, offset + +limit);

  return {
    posts,
    total
  };
};

file path를 통해 _posts폴더 내 markdown 파일을 읽어오고, gray-matter를 통해 front matter를 파싱한다.

메인과 blog 페이지를 구성하기위해 getAllPost 함수를 만들었고, 각각의 블로그들은 getPost 함수를 통해 가져온다.

export const getPost = async (slug: string) => {
  const allPost = getAllPost(undefined, {
    page: '1',
    limit: '-1'
  });

  const index = allPost.posts.findIndex((post) => post.slug === slug);
  const prevPost = allPost.posts[index + 1];
  const nextPost = allPost.posts[index - 1];

  const source = fs.readFileSync(path.join(POSTS_PATH, `${slug}.mdx`));

  const { content, data } = matter(source);
  const mdx = await getSerialize(content, data);

  return {
    mdx,
    content,
    data,
    slug,
    prevPost: {
      slug: prevPost?.slug,
      title: prevPost?.data.title,
      category: prevPost?.data.category
    },
    nextPost: {
      slug: nextPost?.slug,
      title: nextPost?.data.title,
      category: nextPost?.data.category
    }
  };
};
npm i next-mdx-remote

next-mdx-remote는 mdx 파일을 html로 변환해주는 라이브러리이다.

위 코드에서 getSerialize 함수는 mdx 파일을 html로 변환해주는 함수이다.

html로 변환하는데 있어 remark와 rehype를 사용할 수 있다.

import { serialize } from 'next-mdx-remote/serialize';
import remarkGfm from 'remark-gfm';
import rehypePrism from 'rehype-prism-plus';

export const getSerialize = async (content: string, data: any) => {
  return await serialize(content, {
    mdxOptions: {
      remarkPlugins: [remarkGfm],
      rehypePlugins: [rehypePrism]
    },
    scope: data
  });
};
<MDXRemote {...source} components={components} />

위에서 변환된 html을 MDXRemote 컴포넌트를 통해 렌더링한다.

카테고리


카테고리 별로 필터하기위해 카테고리도 따로 받아와야 했다. 각각의 post들을 읽고 중복되지 않은 카테고리들만 가져오는 함수를 만들었다.

import { getAllPost } from '@/utils/getPost';

export const getCategories = (): {
  title: string;
  count: number;
}[] => {
  const posts = getAllPost(undefined, { page: '1', limit: '-1' });

  let uniqueCategories: any = {};

  posts.posts.forEach((post) => {
    if (uniqueCategories.hasOwnProperty(post.data.category)) {
      uniqueCategories[post.data.category].count += 1;
    } else {
      uniqueCategories[post.data.category] = {
        title: post.data.category,
        count: 1
      };
    }
  });

  return Object.values(uniqueCategories).sort((a, b) => a.title > b.title);
};

페이지


위에서 구성한 함수들을 페이지에서 사용해보자.

export const generateStaticParams = async () => {
  const posts = getAllPost(undefined, {
    page: '1',
    limit: '-1'
  });

  return Array.from({ length: Math.ceil(posts.total / 10) }, (_, i) => {
    return {
      page: (i + 1).toString()
    };
  });
};

const Page = ({ params }: { params: { page: string } }) => {
  return <PostListContainer page={params.page} />;
};

export default Page;
// blog/[category]/[slug].tsx
export const generateStaticParams = async () => {
  const posts = getAllPost(undefined, {
    page: '1',
    limit: '-1'
  });

  return posts.posts.map((post) => {
    return {
      slug: post.slug,
      category: post.data.category
    };
  });
};

app 라우트에서 SSG를 사용하기 위해 generateStaticParams 함수가 필요했다. PostListContainer 컴포넌트를 통해 각 page들을 렌더링 하였고, generateStaticParams 함수를 통해 페이지네이션을 위한 params들을 생성하였다.

SEO


seo를 위해 meta 태그와 sitemap, robots를 생성했다.

next 13 에서는 간단하게 위 세가지를 구성할 수 있었다.

먼저 sitemap.ts를 app 폴더에 만들고

import { getAllPost } from '@/utils/getPost';
import { MetadataRoute } from 'next';

const defaultUrl = process.env.NEXT_PUBLIC_BASE_URL;
const pageRoutes = [`${defaultUrl}`, `${defaultUrl}/blog`, `${defaultUrl}/about`];

export default function sitemap(): MetadataRoute.Sitemap {
  const posts = getAllPost(undefined, {
    page: '1',
    limit: '-1'
  });

  const defaultRoutes: MetadataRoute.Sitemap = pageRoutes.map((route) => {
    return {
      url: route,
      lastModified: new Date().toISOString(),
      changeFrequency: 'weekly',
      priority: 1
    };
  });

  const postRoutes: MetadataRoute.Sitemap = posts.posts.map((post) => {
    return {
      url: `${defaultUrl}/blog/${post.data.category}/${post.slug}`,
      lastModified: new Date(post.data.date).toISOString(),
      changeFrequency: 'weekly',
      priority: 1
    };
  });

  return [...defaultRoutes, ...postRoutes];
}

처럼 함수를 만들면 sitemap이 생성된다.

robots도 마찬가지로 app 폴더에 robots.ts를 만들고

import { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/'
    },
    sitemap: 'https://wonbeenna.github.io/sitemap.xml'
  };
}

위와 같이 작성하면 된다.

metadata는 각 페이지에서 generateMetadata 함수를 통해 생성하면 된다.

export const generateMetadata = async ({ params }: { params: { page: string } }) => {
  return {
    title: `Been blog - ${params.page}`,
    openGraph: {
      ...defaultOpenGraph,
      title: `Been blog - ${params.page}`,
      description: `Been dev-note - ${params.page}`,
      url: `${process.env.NEXT_PUBLIC_BASE_URL}/page/${params.page}`
    }
  };
};

댓글


댓글은 giscus를 사용했다.

giscus는 github Discussions으로 댓글로 사용할 수 있게 해주는 라이브러리이다.

npm i @giscus/react

giscus

위 문서에 쉽게 설명되어 있으니 생략!