Next.js 블로그 만들기

Next.js로 블로그 만들기

2023-10-09

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

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

시작


npx create-next-app <project-name>

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

라우팅


next-blog-route

app 폴더 구조는 위와 같다.

블로그 포스트

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

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