tj.park
← Back

블로그를 만드며..

·1 min read· views
#nextjs#tailwind#velite#회고

첫 글. 이 블로그는 직접 만들었습니다. velog, medium 등 기성 플랫폼도 좋지만, 코드 하이라이팅 톤이나 타이포그래피 같은 디테일을 내 입맛대로 통제하고 싶었습니다. 그 과정에서 내린 결정과 배운 것들을 끄적여 보았습니다.

스택

Next.js 15 (App Router) + TypeScript
Tailwind v4          — 디자인 토큰
velite + MDX         — zod 프론트매터 검증
shiki                — rehype-pretty-code (Vitesse Dark)
next-themes          — 다크 모드
Supabase             — 조회수 카운터
Biome                — 린트 + 포맷

콘텐츠 파이프라인

글은 전부 content/posts/*.mdx에 둡니다. 빌드 단계에서 velite가 프론트매터를 zod로 검증하고, 파일명에서 slug를 뽑고, MDX 본문을 컴파일해 .velite/로 떨궈 줍니다.

const posts = defineCollection({
  name: "Post",
  pattern: "posts/**/*.mdx",
  schema: s
    .object({
      title: s.string().max(99),
      date: s.isodate(),
      description: s.string().max(200).optional(),
      tags: s.array(s.string()).default([]),
      body: s.mdx(),
      path: s.path(),
    })
    .transform((data) => {
      const slug = data.path.split("/").at(-1) ?? data.path;
      return { ...data, slug, permalink: `/posts/${slug}` };
    }),
});

velite는 next.config.mjs의 최상단 await build(...)로 묶여 있어서, pnpm devpnpm build 양쪽에서 자동으로 돎. 페이지에서는 경로 별칭 하나로 가져옵니다.

import { posts } from "#site/content";

재밌는 부분은 컴파일된 MDX가 함수 본문 문자열이라는 점입니다. components/mdx-content.tsx에서 이 문자열을 런타임에 평가해 컴포넌트로 되살립니다.

const Component = new Function(code)({ ...jsxRuntime }).default;

처음엔 낯설었지만, 덕분에 글 한 편이 그냥 데이터가 되어 어디서든 import해서 쓸 수 있게 됐습니다.

3계층으로 나눈 컬러 시스템

색은 app/globals.css에서 세 층으로 나뉩니다.

  1. 원시 CSS 변수:root(라이트)와 .dark(다크)에 --bg, --fg, --accent 같은 값을 정의. next-themes<html>.dark 클래스를 토글.
  2. @theme 매핑 — 원시 변수를 Tailwind 유틸리티(bg-bg, text-fg, text-accent …)로 노출.
  3. 액센트 전환[data-accent="violet"]처럼 데이터 속성으로 원시 변수를 덮어써서 액센트별 팔레트를 갈아끼움.

여기서 한 번 크게 막혔습니다. 2번을 @theme inline으로 썼더니 bg-bg/70처럼 투명도 모디파이어가 전부 투명해지는 버그가 났습니다.

/* X: inline이면 --color-* 변수가 안 박혀서 color-mix가 깨진다 */
@theme inline {
  --color-bg: var(--bg);
}
 
/* O: 일반 @theme이어야 opacity 모디파이어가 산다 */
@theme {
  --color-bg: var(--bg);
}

inline은 변수를 인라인 전개해 버려서 --color-bg가 CSS에 남지 않고, color-mix()가 정의되지 않은 색을 받아 투명해지는 것이었습니다. 한 줄 차이로 한참을 헤맨, 블로그에 꼭 남겨야 할 종류의 버그였습니다.

액센트는 플래시 없이

액센트 5종은 lib/accents.ts에 있고, 선택값은 localStorage에 저장합니다. 스위처가 document.documentElement.dataset.accent를 바꾸고 blog-accent 커스텀 이벤트를 쏘면, 색에 의존하는 컴포넌트들이 받아서 다시 칠합니다.

새로고침할 때 기본색이 번쩍였다가 바뀌는 걸 막으려고, app/layout.tsx페인트 전에 실행되는 인라인 스크립트로 저장된 액센트를 먼저 적용했습니다.

배경 셰이더

배경은 @paper-design/shaders-react의 Warp 셰이더입니다. 화면 상단에 절대 위치로 깔고 콘텐츠 뒤(-z-10)에 두되, 고정이 아니라 스크롤과 함께 흘러가게 했습니다. 셰이더 색은 현재 액센트 팔레트에서 가져오고, prefers-reduced-motion이면 속도를 0으로 떨어뜨립니다.

조회수 카운터

조회수는 Supabase로 셉니다. 핵심은 동시 쓰기에서도 안전하도록 평범한 upsert 대신 원자적 increment RPC를 쓴 것.

create function increment_view(p_slug text) ...

그리고 로컬에서 Supabase 없이도 앱이 돌아가야 하므로, 환경변수가 없으면 조용히 { views: 0 }을 돌려주고 넘어가게 했습니다. 서비스 롤 키는 절대 클라이언트로 새지 않게 API 라우트에서만 import합니다.

마치며

직접 만들면 손이 많이 가지만, 막힌 지점 하나하나가 다음 글의 소재가 됩니다. 앞으로도 배운 것들을 이곳에 천천히 기록해 나가겠습니다.

accent