NextJS v15 블로그 만들기
NextJS v15
- params prop은 Promise이기 때문에 값을 사용하려면 async/await 또는 React의 use 함수를 사용해야 합니다.
- Next.js 14 이전 버전에서는 params가 동기적인 prop이었습니다. 하위 호환성을 위해 Next.js 15에서는 여전히 동기적으로 접근할 수 있지만, 이 방식은 향후에 제거될 예정입니다.
- Dynamic Segment는 기존 pages 디렉토리에서 사용하던 Dynamic Route와 동일한 개념입니다.
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <div>My Post: {slug}</div>;
}
Tailwindcss v4
Tailwindcss v4 부터는 tailwind.config.js 파일이 사용되지 않는다!!
Instead of a tailwind.config.js file, you can configure all of your customizations directly in the CSS file where you import Tailwind, giving you one less file to worry about in your project:
tailwind.config.js 파일 대신 Tailwind를 가져오는 CSS 파일에서 모든 사용자 지정을 직접 구성할 수 있어 프로젝트에서 걱정할 파일이 하나 줄어듭니다:
The new CSS-first configuration lets you do just about everything you could do in your tailwind.config.js file, including configuring your design tokens, defining custom utilities and variants, and more
새로운 CSS 우선 설정을 통해 tailwind.config.js 파일에서 할 수 있는 거의 모든 작업을 수행할 수 있습니다. 여기에는 디자인 토큰 구성, 사용자 지정 유틸리티 및 변형 정의 등이 포함됩니다
참고 사이트
tailwindcss v4에서 plugin 적용방법
@import 'tailwindcss';
@import 'tw-animate-css';
@plugin '@tailwindcss/typography';
don't forget the "@" sign!
출처: https://github.com/tailwindlabs/tailwindcss/discussions/13292
RGB와 HSL에서 OKLCH로 전환
왜 우리는 RGB와 HSL에서 OKLCH로 전환했을까요?
OKLCH Color Picker & Converter에서 RGB와 HSL을 OKLCH로 전환할 수 있습니다.
:root {
--white: oklch(99.2% 0.005 230);
--black: oklch(18.8% 0.03 282);
--background: var(--white);
--foreground: var(--black);
--secondary: oklch(96.5% 0.012 235);
--secondary-foreground: oklch(23.5% 0.065 264);
--informative: oklch(92.3% 0.048 222);
--informative-foreground: var(--black);
--warning: oklch(93.8% 0.092 102);
--warning-foreground: var(--black);
--destructive: oklch(90.3% 0.084 18);
--destructive-foreground: var(--black);
--card: oklch(100% 0 0); /* white */
--card-foreground: oklch(23.5% 0.065 264); /* 222.2 84% 4.9% */
--popover: oklch(100% 0 0);
--popover-foreground: oklch(23.5% 0.065 264);
--primary: oklch(23.5% 0.065 264); /* 222.2 47.4% 11.2% */
--primary-foreground: oklch(99.2% 0.005 230);
--muted: oklch(96.5% 0.012 235);
--muted-foreground: oklch(64.8% 0.027 256);
--accent: oklch(96.5% 0.012 235);
--accent-foreground: oklch(23.5% 0.065 264);
--border: oklch(94.8% 0.014 245);
--input: oklch(94.8% 0.014 245);
--ring: oklch(23.5% 0.065 264);
}
1. 게시글 목록 페이지 (전체, 카테고리별)
페이징
https://dev-joy.github.io/?page=2
QueryString으로 Page를 받아와서 표시하기 위해
route.ts를 이용하였습니다.
import { getSortedPostList } from '@/service/posts';
export const revalidate = 60;
export async function GET() {
const posts = await getSortedPostList();
return new Response(JSON.stringify(posts), {
headers: { 'Content-Type': 'application/json' },
});
}
참고 사이트
2. 게시글 상세
// 허용된 param 외 접근시 404
export const dynamicParams = false;
- true (기본값): generateStaticParams에 포함되지 않은 다이내믹 세그먼트(dynamic segment)는 요청 시(on demand) 생성됩니다.
- false: generateStaticParams에 포함되지 않은 다이내믹 세그먼트는 404 페이지를 반환합니다.
참고 사이트
Rehype Pretty Code - CSS Issue
globals.css에서 rehype pretty code의 css를 추가해주었습니다.
/* rehype-pretty-code */
@layer components {
code {
background: var(--color-informative);
}
code[data-line-numbers] {
counter-reset: line;
}
code[data-line-numbers] > [data-line]::before {
counter-increment: line;
content: counter(line);
@apply inline-block w-4 mr-4 text-right text-gray-500;
}
pre [data-line] {
@apply px-4 border-l-2 border-l-transparent;
}
[data-highlighted-line] {
background: oklch(0.8675 0.202 85.03 / 0.2);
@apply border-l-blue-400;
}
[data-highlighted-chars] {
@apply bg-zinc-600/50 rounded;
box-shadow: 0 0 0 4px rgb(82 82 91 / 0.5);
}
[data-chars-id] {
@apply shadow-none p-1 border-b-2;
}
[data-chars-id] span {
@apply !text-inherit;
}
[data-chars-id='v'] {
@apply !text-pink-300 bg-rose-800/50 border-b-pink-600 font-bold;
}
[data-chars-id='s'] {
@apply !text-yellow-300 bg-yellow-800/50 border-b-yellow-600 font-bold;
}
[data-chars-id='i'] {
@apply !text-purple-200 bg-purple-800/50 border-b-purple-600 font-bold;
}
[data-rehype-pretty-code-title] {
@apply bg-zinc-700 text-zinc-200 rounded-t-lg py-2 px-3 font-semibold text-sm;
}
figure[data-rehype-pretty-code-figure]:has(> [data-rehype-pretty-code-title])
pre {
@apply !rounded-t-none;
}
}
참고 사이트
- rehype-pretty-code/global.css
- rehype-pretty-code/tailwind.config.js
- rehype-pretty-code/highlight-lines
3. 댓글 Giscus
GitHub Discussions로 작동하는 댓글 시스템입니다.
GitHub를 통해 웹사이트에 댓글과 반응을 남기게 해보세요!
utterances에서 큰 영감을 받았습니다.
참고사이트
4. 다크/라이트 모드
# 설치
pnpm add next-themes
'use client';
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
import './globals.css';
import Header from '@/components/common/Header';
import Footer from '@/components/common/Footer';
import { ThemeProvider } from '@/components/libraries/theme-provider';
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang='ko'
suppressHydrationWarning
>
<body>
<ThemeProvider
attribute='class'
defaultTheme='system'
enableSystem
disableTransitionOnChange
>
<Header />
<main>{children}</main>
<Footer />
</ThemeProvider>
</body>
</html>
);
}
'use client';
import { useTheme } from 'next-themes';
import Link from 'next/link';
import { Button } from '../ui/button';
import { HomeIcon } from '../../app/icon/home';
import { SunIcon } from '../../app/icon/sun';
import { MoonIcon } from '../../app/icon/moon';
import { usePathname } from 'next/navigation';
export default function Header() {
const pathname = usePathname();
return (
<header className='fixed z-40 flex w-full flex-col items-center justify-center border-b bg-background shadow-sm print:hidden'>
<nav className='mt-1 flex h-[64px] w-full max-w-[1200px] items-center justify-between px-4'>
<Link
href='/posts'
className={`${pathname === '/posts' ? 'underline' : ''}`}
>
<span className='font-bold hover:text-blue-600'>POSTS</span>
</Link>
<div className='flex items-center'>
<Button
type='button'
variant='ghost'
size='icon'
>
<Link href='/'>
<HomeIcon />
</Link>
</Button>
<ThemeChanger />
</div>
</nav>
</header>
);
}
function ThemeChanger() {
const { setTheme, theme } = useTheme();
const toggleTheme = () => {
setTheme(theme === 'dark' ? 'light' : 'dark');
};
return (
<Button
type='button'
variant='ghost'
size='icon'
onClick={toggleTheme}
>
<SunIcon className='transition-all dark:hidden text-primary stroke-1' />
<MoonIcon className='hidden transition-all dark:block text-primary stroke-1' />
<span className='sr-only'>Toggle theme</span>
</Button>
);
}
참고 사이트
5. 목차 사이드바 (TOC, Table Of Content)
import { useEffect, useRef, useState } from 'react';
export const useHeadingsObserver = (query: string) => {
const observer = useRef<IntersectionObserver | null>(null);
const [activeIdList, setActiveIdList] = useState<string[]>([]);
const [tempId, setTempId] = useState('');
useEffect(() => {
const scrollMarginOption = { rootMargin: '-32px 0px -80px 0px' };
const handleObserver: IntersectionObserverCallback = (entries) => {
entries.forEach((entry) => {
const targetId = `#${entry.target.id}`;
if (entry.isIntersecting) {
setActiveIdList((prev) => [...prev, targetId]);
setTempId(() => '');
} else {
setActiveIdList((prev) => {
if (prev.length === 1) setTempId(targetId);
return prev.filter((elem) => elem !== targetId);
});
}
});
};
observer.current = new IntersectionObserver(
handleObserver,
scrollMarginOption
);
const elements = document.querySelectorAll(query);
elements.forEach((elem) => observer.current?.observe(elem));
return () => observer.current?.disconnect();
}, [query]);
return [...activeIdList, tempId];
};
'use client';
import { useHeadingsObserver } from '@/hook/useHeadingObserver';
import { cn } from '@/lib/utils';
import { HeadingItem } from '@/service/types';
import Link from 'next/link';
interface Props {
toc: HeadingItem[];
}
export default function TableOfContentSidebar({ toc }: Props) {
const activeIdList = useHeadingsObserver('h2, h3');
return (
<aside className='not-prose absolute -top-[200px] left-full -mb-[100px] hidden h-[calc(100%+150px)] xl:block '>
<div className='sticky bottom-0 top-[200px] z-10 ml-[5rem] mt-[200px] w-[200px]'>
<div className='mb-4 border-l px-4 py-2'>
<div className='mb-1 font-bold'>On this page</div>
<ul className='text-xs'>
{toc.map((item) => {
const isH3 = item.indent === 1;
const isIntersecting = activeIdList.includes(item.link);
return (
<li
key={item.link}
className={cn(
isH3 && 'ml-4',
isIntersecting &&
'font-medium text-blue-600 dark:text-blue-300',
'py-1 transition'
)}
>
<Link href={item.link}>{item.text}</Link>
</li>
);
})}
</ul>
</div>
</div>
</aside>
);
}
참고 사이트
- https://developer.mozilla.org/ko/docs/Web/API/Intersection_Observer_API
- https://github.com/d5br5/nextjs-tailwind-blog
6. 검색 엔진 최적화 (SEO)
export const metadata: Metadata = {
title: {
default: '김주희 블로그',
template: '%s | 김주희 블로그',
},
description: '김주희 개발 블로그',
icons: {
icon: `/favicon.ico`,
},
openGraph: {
title: '김주희 블로그',
description: '김주희 개발 블로그',
siteName: '김주희 블로그',
images: '/images/og-img.png',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: '김주희 블로그',
description: '김주희 개발 블로그',
images: '/images/og-img.png',
},
};
import PostGrid from '@/components/post_list/PostGrid';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'All Posts',
description: '공유하고 싶은 기술이나 에러 해결방법을 정리합니다.',
};
export default async function PostsPage() {
return <PostGrid />;
}
app/layout.tsx의 title '%s | 김주희 블로그' 부분이
app/posts/page.tsx의 title 값이 들어가서 아래 사진 처럼 적용되어 보입니다.
MetaData적용
sitemap.xml을 만들어서 Google Search Console 등록시 검색이 잘되게 만들어줍니다.
import { getSitemapPostList } from '@/service/posts';
import { MetadataRoute } from 'next';
export const dynamic = 'force-static';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const postList = await getSitemapPostList();
const baseUrl = 'https://dev-joy.github.io';
return [
{
url: baseUrl,
lastModified: new Date(),
},
...postList,
];
}
참고 사이트
7. Reading time
# 설치
pnpm add reading-time
import readingTime from 'reading-time';
const readingMinutes = readingTime(text).minutes;
참고 사이트
8. Google Third party
Google Adsense
public/ads.txt 파일 추가
import Script from 'next/script';
import { FunctionComponent } from 'react';
export const GoogleAdSense: FunctionComponent = () => {
return (
<Script
async
src=`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${YOUR_CA_PUB_ID}`
crossOrigin='anonymous'
/>
);
};
Google Analytics & Google TagManger
import { GoogleTagManager } from '@next/third-parties/google';
import { GoogleAnalytics } from '@next/third-parties/google';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang='en'>
<body>{children}</body>
<GoogleTagManager gtmId='GTM-XYZ' />
<GoogleAnalytics gaId='G-XYZ' />
</html>
);
}
참고 사이트