Dựng blog đơn giản với Nextjs

1 Tháng 6 năm 2022

9 phút đọcchưa có lượt xem

Mình clone blog này từ template : tailwind-nextjs-starter-blog. Source này ban đầu thì rất ok, nhưng qua một thời gian sử dụng mình gặp một số vấn đề sau:

  • Sử dụng quá nhiều lib. Các lib core bị outdated thì ko update dc vì dependencies quá nhiều
  • Phần code để build bài viết từ mdx quá phức tạp
  • Chỉ có 2 theme, light và dark.
  • Tối ưu SEO chưa tốt

Build from scratch rất vui!

  • Vui vì có vấn đề cần giải quyết
  • Vui vì học được kiến thức mới
  • Vui vì có cơ hội thử nghiệm nghịch phá

Vậy nên ngại gì mà không thử. Cùng lên kế hoạch cho petproject này của mình nhé :D.

Một trang blog thì nên có những gì?

Những tính năng cơ bản mà bất kì blog nào cũng cần có:

  • Bài viết
  • Tags (phân loại bài viết)
  • About (thông tin tác giả)

Tuy nhiên, đối với blog của mình. Vì được tự do sáng tạo mà không bị giới hạn nên mình nghĩ ra được vài tính năng khá thú vị như:

  • Trạng thái Spotify
  • Số lượt xem bài viết
  • Code snippet
  • Today I learn (lưu lại những gì mình học hàng ngày)
  • Bài viết tương tác được. VD: chèn code vào bài viết
  • ...

Và quan trọng là phải giải quyết được những vấn đề mà cái blog cũ mình gặp phải.

Giao diện nên trông như thế nào?

Mình chả biết gì về designer nên có vẻ đi trộm giao diện là giải pháp hợp lí nhất :)). Nguồn kham khảo mà mình chọn đó là dribble và behance. Nhưng chủ yếu mình giữ lại giao diện ở blog cũ vì nó khá minimalist.

Tiếp theo là chọn màu phù hợp, Rosé Pine bảng màu mình nhặt được trong 1 group chat IT. Bảng màu này tạo một cảm giác dễ chịu cho người dùng.

Rosé Pine

Cuối cùng là chọn font:

  • Barlow dùng cho tiêu đề
  • Barlow Condensed dùng cho nội dung
  • Consolas font chữ trong các đoạn code. Font mình xài trong editor

Chọn công nghệ gì?

Các công nghệ không thể không có:

  • Typescript trải nghiệm với javascript thật sự rất chán. Nhưng với typescript thì 🎆🎆
  • Markdown ngôn ngữ tuyệt vời để viết
  • MDX cho phép ta viết jsx trong file markdown
  • Next.js là bộ khung chủ đạo của website
  • Tailwindcss. Ngoài ra mình cũng sử dụng vài tool nằm trong hệ sinh thái của tailwindcss như: headless ui, heroicons, heropatterns...
  • Framer motion thư viện để tạo ra các animation
  • Render setup postgresql database chỉ bằng vài click. Và ORM prisma
  • sentry.io dùng để tracking website

Build Global Layout

Chi tiết

Layout của web có 3 phần:

  • Header: chứa tên website, navigation, theme switcher

  • Main: nội dung của page

  • Footer: Info tác giả, copyright

Đa số mình tái sử dụng lại design của web cũ, tuy nhiên có điều chỉnh một vài chổ như:

  • Sử dụng background Circuit Board từ Hero Patterns

  • Sử dụng nhiều theme khác nhau thay vì dark mode và light mode ở web cũ. Cách mình setup khá đơn giản, đó là ứng dụng CSS Variables:

    /* Định nghĩa 2 theme: */
    .theme-first {
        /*25 23 36 nghĩa là rgb(25,23,36) */
        --color-base: 25 23 36;
        --color-surface: 31 29 46;
        --color-text: 38 35 58;
    }
    
    .theme-second {
        --color-base: 250 244 237;
        --color-surface: 255 250 243;
        --color-text: 242 233 222;
    }
    
    /* Sử dụng color theme trong class: */
    .my-box {
        width: 100px;
        height: 100px;
        background: rgb(var(--color-base));
    }
    

    Để sử dụng theme, đơn giản ta chỉ cần gắn tên class của theme đó vào thẻ body. Ví dụ:

    <body class="theme-first">
        <div class="my-box"></div>
    </body>
    

    Vì mình sử dụng tailwindcss nên cần setup thêm một bước nữa, trong file tailwind.config.js:

    // Cần hàm này để có thể sử dụng class opacity-[value]
    function withOpacityValue(variable) {
        return ({ opacityValue }) => {
            if (opacityValue === undefined) {
                return `rgb(var(${variable}))`;
            }
            return `rgb(var(${variable}) / ${opacityValue})`;
        };
    }
    
    // Có bao nhiêu css varible trong theme thì ta định nghĩa bấy nhiêu đó:
    let themeColors = {
        base: withOpacityValue('--color-base'),
        surface: withOpacityValue('--color-surface'),
        text: withOpacityValue('--color-text'),
        //...
    };
    
    module.exports = {
        content: [
            './pages/**/*.{js,ts,jsx,tsx}',
            './components/**/*.{js,ts,jsx,tsx}',
        ],
        theme: {
            extend: {
                colors: themeColors,
            },
        },
        plugins: [],
    };
    

    Sử dụng:

    <button className="bg-base text-text p-3">    Hello World</button>
    

    Cuối cùng, sử dụng thư viện next-themes. Giúp việc chuyển đổi theme dễ dàng hơn, chỉ qua vài dòng code:

    import { useTheme } from 'next-themes';
    
    const ThemeChanger = () => {
        const { theme, setTheme } = useTheme();
    
        return (
            <div>
                The current theme is: {theme}
                <button onClick={() => setTheme('theme-first')}>
                    First Theme
                </button>
                <button onClick={() => setTheme('theme-second')}>
                    Second Theme
                </button>
            </div>
        );
    };
    

Kết quả:

Rosé Pine

Khá cool đúng không :3

Chi tiết các page các bạn có thể xem bằng cách dạo quanh blog này.

Setup Contentlayer

Contentlayer package giúp mình parse file mdx sang next.js một cách đơn giản. Các bạn có thể xem document để biết thêm chi tiết.

Mình để tất cả data của website vào một folder data, có cấu trúc như sau:

data:
  - post:
    - 2022:
      - post_a.mdx
      - post_b.mdx
    - 2021
    - 2020
  - snippet:
    - snippet_a.mdx
    - snippet_b.mdx
  - pages:
    - about.mdx
    - uses.mdx

Trong đó:

  • Folder post chứa các bài viết, có thể nested folder.
  • Folder snippet chứa các snippet code. Các đoạn code ngắn hữu ích.
  • Folder pages chứa các page.

Contentlayer sẽ tự động parse file mdx vào next.js. Sau đó ta có thể sử dụng một cách đơn giản, ví dụ file blog.tsx:

import { allPosts, Post } from 'contentlayer/generated';
import { pick } from 'contentlayer/utils';
import ListLayout from 'src/layouts/ListLayout';

export default function BlogPage({ posts }: { posts: Post[] }) {
    return <ListLayout posts={posts} name='Blog' />;
}

export async function getStaticProps() {
    // dùng hàm pick để loại bỏ các field không mong muốn, cải thiện thời gian load
    const posts = allPosts
        .filter((post) => post.draft !== true)
        .map((blog) => pick(blog, ['slug', 'title', 'summary', 'date', 'tags']))
        .sort((a, b) => moment(b.date).diff(moment(a.date)));

    return { props: { posts } };
}

Contentlayer cũng hỗ trợ 1 hook để chuyển từ mdx sang html là useMDXComponent. Ta cũng có thể custom các tag nữa. VD file /blog/[slug].tsx:

import { allPosts, Post } from 'contentlayer/generated';
import { useMDXComponent } from 'next-contentlayer/hooks';
import PostLayout from 'src/layouts/PostLayout';
import components from '../../components/MDXComponents';

export default function BlogDetailPage({ post }: { post: Post }) {
    const Component = useMDXComponent(post.body.code);
    return (
        <PostLayout post={post}>
            <Component
                components={{
                    ...components,
                }}
            />
        </PostLayout>
    );
}

export async function getStaticPaths() {
    return {
        paths: allPosts.map((p) => ({ params: { slug: p.slug } })),
        fallback: false,
    };
}

export async function getStaticProps({ params }: { params: { slug: string } }) {
    const post = allPosts.find((post) => post.slug === params.slug);
    return { props: { post } };
}

File components/MDXComponents.tsx:

import Link from 'next/link';

const CustomLink = (props: any) => {
    const href = props.href;
    const isInternalLink =
        href && (href.startsWith('/') || href.startsWith('#'));

    if (isInternalLink) {
        return (
            <Link href={href}>
                <a {...props}>{props.children}</a>
            </Link>
        );
    }

    return <a target='_blank' rel='noopener noreferrer' {...props} />;
};

const MDXComponents = {
    a: CustomLink,
};

export default MDXComponents;

Setup prisma, và planetscale để đếm số lượng view

Bổ sung

  • B1: Cài đặt pscale và client-sql thông qua brew (Terminal)

  • B2: Access vào pscale shell với các câu lệnh sau

    • pscale login

    • pscale shell <tên database>

    • sau đó câu lệnh tạo table đếm view

    CREATE TABLE views (
    slug VARCHAR(128) PRIMARY KEY,
    count BIGINT DEFAULT 1
    );
    

Cách tạo database và connect prisma đến planetscale

Sau khi setup prisma vào project theo hướng dẫn ở đây. Và lấy DATABASE_URL dùng để connect tới database từ planetscale .Ta edit file prisma/schema.prisma lại như sau:

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["referentialIntegrity"]
}

datasource db {
  provider             = "mysql"
  url                  = env("DATABASE_URL")
  referentialIntegrity = "prisma"
}

model views {
  slug  String @id @db.VarChar(128)
  count BigInt @default(1)
}

Tạo api để lấy và update số lượng view. Ví dụ file api/views/[slug].ts:

import type { NextApiRequest, NextApiResponse } from 'next';
import prisma from '../../../lib/prisma';

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse,
) {
    try {
        const slug = req.query.slug.toString();

        if (req.method === 'POST') {
            const newOrUpdatedViews = await prisma.views.upsert({
                where: { slug },
                create: {
                    slug,
                },
                update: {
                    count: {
                        increment: 1,
                    },
                },
            });

            return res.status(200).json({
                total: newOrUpdatedViews.count.toString(),
            });
        }

        if (req.method === 'GET') {
            const views = await prisma.views.findUnique({
                where: {
                    slug,
                },
            });

            return res.status(200).json({ total: views?.count.toString() });
        }
    } catch (e: any) {
        return res.status(500).json({ message: e.message });
    }
}

Sau đó gọi API từ client bằng axios hoặc fetch. Để tiết kiệm thời gian, mình viết một component sử dụng swr, ViewCounter.tsx:

import { useEffect } from 'react';
import fetcher from 'src/lib/fetcher';
import { Views } from 'src/lib/types';
import useSWR from 'swr';

export default function ViewCounter({
    slug,
    update = false,
}: {
    slug: string;
    update?: boolean;
}) {
    const { data } = useSWR<Views>(`/api/views/${slug}`, fetcher);
    const views = new Number(data?.total);

    useEffect(() => {
        // nếu update = true thì update lại số lượng view
        if (update)
            fetch(`/api/views/${slug}`, {
                method: 'POST',
            });
    }, [slug, update]);

    return <span>{`${views > 0 ? views.toLocaleString() : '–––'} views`}</span>;
}

Kết luận

Như vậy, ta đã tạo được 1 trang blog cơ bản. Tuy có sử dụng hơi nhiều package, nhưng nhờ chúng mà việc code trở nên trơn tru và dễ dàng hơn rất nhiều.

Hy vọng là bài viết này đã 1 phần nào đó giúp bạn có thể tạo ra 1 trang blog tương tự như mình :d.