Fresh Notes
Fresh에서 Markdown 블로그 만들기: Front Matter부터 동적 라우팅, SEO까지 | Fresh Notes

Fresh

Fresh에서 Markdown 블로그 만들기: Front Matter부터 동적 라우팅, SEO까지

Fresh 2에서 Markdown 파일을 글 데이터로 사용하고 Front Matter, 동적 라우팅, 코드 하이라이팅, SEO와 sitemap까지 연결하는 과정을 살펴봅니다.

2026.10.05·Fresh·7 min read

기술 블로그를 만들 때 글을 어디에 저장할지는 생각보다 중요한 문제입니다.

CMS를 사용할 수도 있고 데이터베이스에 저장할 수도 있습니다. 하지만 개인 기술 블로그라면 Markdown 파일만으로도 꽤 많은 것을 할 수 있습니다.

Fresh Notes에서는 글 하나를 다음처럼 관리하고 있습니다.

content/
└── posts/
    ├── building-a-fresh-2-blog.md
    ├── fresh-server-first-islands-architecture.md
    ├── fresh-2-3-zero-js.md
    └── fresh-vite-deno-roles.md

Git 저장소에 Markdown 파일을 추가하면 새로운 글이 생기고, 수정하면 그대로 버전 관리됩니다.

별도의 관리자 페이지나 데이터베이스도 없습니다.

이번에는 이 Markdown 파일 하나가 실제로:

Markdown
↓
Front Matter
↓
HTML
↓
/posts/slug
↓
홈 / 카테고리
↓
SEO / Sitemap / RSS

까지 연결되는 과정을 살펴보겠습니다.

먼저 Markdown 렌더러가 필요합니다

Fresh가 Markdown 블로그 기능을 기본으로 제공하는 것은 아닙니다.

필요한 기능을 직접 조합할 수 있습니다.

이번 프로젝트에서는 두 패키지를 사용했습니다.

deno add jsr:@std/front-matter
deno add jsr:@deno/gfm

역할은 명확합니다.

@std/front-matter
→ Markdown 앞부분의 메타데이터 읽기

@deno/gfm
→ Markdown 본문을 HTML로 변환

@deno/gfm은 이름 그대로 GitHub Flavored Markdown을 서버에서 렌더링할 수 있는 패키지입니다.

테이블, fenced code block 같은 기술 문서에서 자주 사용하는 Markdown 문법도 사용할 수 있습니다.

글 하나는 Front Matter와 본문으로 나눕니다

실제 글 파일은 다음과 비슷합니다.

---
title: "Fresh는 어떤 프레임워크인가"
description: "Fresh의 Server-first 구조와 Islands Architecture를 살펴봅니다."
date: "2026-09-26"
category: Fresh
tags:
  - Fresh
  - Islands
  - SSR
draft: false
featured: false
image: "/og/default.png"
---

Fresh는 Server-first 웹 프레임워크입니다.

## Server-first란?

본문은 여기서부터 시작합니다.

위쪽의 --- 사이가 Front Matter입니다.

여기에는 본문 내용이 아니라 글을 관리하는 데 필요한 정보를 넣습니다.

현재 Fresh Notes에서는 다음 정보를 사용합니다.

export interface PostMeta {
  title: string;
  description: string;
  date: string | Date;
  category: string;
  tags: string[];
  draft: boolean;
  featured?: boolean;
  image?: string;
}

본문에서는 제목을 다시 # 제목으로 작성하지 않습니다.

Front Matter의 title을 페이지의 <h1>으로 사용하기 때문입니다.

따라서 Markdown 본문은 ##부터 시작합니다.

Front Matter title
→ 페이지의 h1

Markdown ## 제목
→ 페이지의 h2

이 규칙을 정해두면 한 페이지에 <h1>이 두 개 생기는 것도 피할 수 있습니다.

Markdown 파일을 읽어봅니다

글을 읽는 코드는 utils/posts.ts에 모았습니다.

기본 형태는 다음과 같습니다.

import { extractYaml } from "@std/front-matter";
import { render } from "@deno/gfm";

export interface PostMeta {
  title: string;
  description: string;
  date: string | Date;
  category: string;
  tags: string[];
  draft: boolean;
  featured?: boolean;
  image?: string;
}

export interface Post {
  slug: string;
  meta: PostMeta;
  html: string;
  readingTime: number;
}

const POSTS_DIR = "./content/posts";

그리고 특정 글 하나를 읽는 함수는 다음처럼 만들 수 있습니다.

export async function getPost(
  slug: string,
): Promise<Post | null> {
  try {
    const source = await Deno.readTextFile(
      `${POSTS_DIR}/${slug}.md`,
    );

    const { attrs, body } = extractYaml<PostMeta>(source);

    return {
      slug,
      meta: attrs,
      html: render(body),
      readingTime: estimateReadingTime(body),
    };
  } catch {
    return null;
  }
}

흐름을 보면 단순합니다.

Deno.readTextFile()
        ↓
Markdown 원본
        ↓
extractYaml()
        ↓
attrs + body
        ↓
render(body)
        ↓
HTML

attrs에는 Front Matter가 들어가고, body에는 실제 Markdown 본문이 들어갑니다.

slug는 파일 이름에서 가져옵니다

예를 들어 파일 이름이:

fresh-markdown-blog.md

라면 slug는:

fresh-markdown-blog

가 됩니다.

URL은 자연스럽게:

/posts/fresh-markdown-blog

가 됩니다.

글마다 Front Matter에 slug를 별도로 적을 수도 있지만, 지금 규모에서는 파일 이름 자체를 slug로 사용하는 편이 단순합니다.

파일 하나를 이동하거나 이름을 바꾸는 것만으로 콘텐츠 구조도 쉽게 파악할 수 있습니다.

전체 글 목록도 같은 방식으로 읽습니다

홈의 Latest나 카테고리 목록을 만들려면 글 하나가 아니라 전체 글이 필요합니다.

그래서 content/posts 디렉터리를 순회합니다.

export async function getPosts() {
  const posts = [];

  for await (const entry of Deno.readDir(POSTS_DIR)) {
    if (
      !entry.isFile ||
      !entry.name.endsWith(".md")
    ) {
      continue;
    }

    const slug = entry.name.replace(/\.md$/, "");

    const source = await Deno.readTextFile(
      `${POSTS_DIR}/${entry.name}`,
    );

    const { attrs, body } = extractYaml<PostMeta>(source);

    if (attrs.draft) {
      continue;
    }

    posts.push({
      slug,
      meta: attrs,
      readingTime: estimateReadingTime(body),
    });
  }

  return posts.sort(
    (a, b) =>
      new Date(b.meta.date).getTime() -
      new Date(a.meta.date).getTime(),
  );
}

여기서 draft가 꽤 유용합니다.

작성 중인 글은:

draft: true

로 두면 됩니다.

그러면 파일은 Git에 올려두면서 실제 사이트의 글 목록에서는 제외할 수 있습니다.

발행할 때:

draft: false

로 바꾸면 됩니다.

YAML 날짜에서 예상하지 못한 문제가 생겼습니다

이 과정에서 작은 함정을 하나 만났습니다.

처음에는 날짜를 이렇게 적었습니다.

date: 2026-09-18

그리고 화면에 표시하기 위해 이런 함수를 만들었습니다.

export function formatDate(date: string) {
  const [year, month, day] = date.split("-");

  return `${year}.${month}.${day}`;
}

그런데 실행하자:

date.split is not a function

오류가 발생했습니다.

TypeScript에서는 date를 string이라고 선언했는데 실제 값은 string이 아니었던 것입니다.

원인은 YAML parser였습니다.

YAML의 날짜 표현은 파싱 과정에서 Date 객체로 해석될 수 있습니다.

즉 TypeScript에:

date: string;

이라고 적었다고 해서 런타임 값까지 자동으로 string으로 변환되는 것은 아닙니다.

그래서 Front Matter에서는 날짜를 명시적으로 문자열로 작성하기로 했습니다.

date: "2026-09-18"

그리고 혹시 모를 상황을 위해 formatter도 두 형식을 처리하도록 만들 수 있습니다.

export function formatDate(
  date: string | Date,
) {
  if (date instanceof Date) {
    const year = date.getUTCFullYear();

    const month = String(
      date.getUTCMonth() + 1,
    ).padStart(2, "0");

    const day = String(
      date.getUTCDate(),
    ).padStart(2, "0");

    return `${year}.${month}.${day}`;
  }

  const [year, month, day] = date.split("-");

  return `${year}.${month}.${day}`;
}

작은 문제였지만 Front Matter의 타입과 실제 런타임 값이 반드시 같지는 않다는 것을 확인할 수 있었습니다.

이제 /posts/[slug]와 연결합니다

Fresh는 routes/ 디렉터리 구조를 이용해 file-based routing을 만들 수 있습니다.

그래서 다음 파일을 만들었습니다.

routes/
└── posts/
    └── [slug].tsx

대괄호가 붙은 [slug]는 동적 URL segment입니다.

따라서:

/posts/fresh-zero-js
/posts/fresh-vite-deno-roles
/posts/fresh-markdown-blog

를 하나의 route 파일에서 처리할 수 있습니다.

route에서는 URL에서 slug를 받아 글을 찾습니다.

export const handler = define.handlers({
  async GET(ctx) {
    const post = await getPost(ctx.params.slug);

    if (!post) {
      return new Response(
        "Not found",
        { status: 404 },
      );
    }

    return {
      data: post,
    };
  },
});

Markdown 파일이 없으면 404를 반환합니다.

글이 존재한다면 page component로 전달합니다.

Markdown HTML은 그대로 출력해야 합니다

@deno/gfm의 render()가 반환하는 값은 이미 HTML 문자열입니다.

예를 들어 Markdown이:

## Fresh

**Server-first** framework

라면 결과는 대략:

<h2>Fresh</h2>
<p><strong>Server-first</strong> framework</p>

형태가 됩니다.

이 문자열을 JSX에 그냥 넣으면 HTML이 아니라 문자 그대로 출력됩니다.

그래서 HTML로 렌더링해야 합니다.

<article
  dangerouslySetInnerHTML={{
    __html: data.html,
  }}
/>;

여기서 Deno lint가 바로 경고를 냅니다.

react-no-danger:
Do not use dangerouslySetInnerHTML

경고 이유는 명확합니다.

사용자 입력을 그대로 HTML로 출력하면 XSS 문제가 생길 수 있기 때문입니다.

현재 블로그에서는 외부 사용자가 입력한 HTML을 출력하는 것이 아니라 저장소 안에서 직접 관리하는 Markdown을 렌더링하고 있습니다.

그래서 해당 책임을 한 컴포넌트에 모았습니다.

// deno-lint-ignore-file react-no-danger
// Renders trusted local Markdown converted to HTML.

interface Props {
  html: string;
  className?: string;
}

export default function MarkdownContent(
  { html, className }: Props,
) {
  return (
    <article
      class={className}
      dangerouslySetInnerHTML={{
        __html: html,
      }}
    />
  );
}

그리고 페이지에서는:

<MarkdownContent
  html={data.html}
  className="prose prose-lg max-w-none"
/>;

처럼 사용합니다.

중요한 것은 lint 규칙을 프로젝트 전체에서 꺼버리지 않은 것입니다.

HTML을 직접 출력하는 장소를 한 군데로 제한하고 그 이유도 코드에 남겼습니다.

코드 블록에는 PrismJS가 사용됩니다

기술 블로그라면 코드블록의 syntax highlighting도 필요합니다.

@deno/gfm은 기본적인 syntax highlighting을 지원하지만 모든 언어 grammar가 처음부터 포함되는 것은 아닙니다.

TypeScript, TSX, Bash 같은 언어도 사용하기 위해 Prism component를 추가했습니다.

import "prismjs/components/prism-typescript.js";
import "prismjs/components/prism-jsx.js";
import "prismjs/components/prism-tsx.js";
import "prismjs/components/prism-bash.js";
import "prismjs/components/prism-json.js";
import "prismjs/components/prism-css.js";

여기서도 작은 문제가 하나 있었습니다.

처음에는:

import "npm:prismjs/components/prism-typescript.js";

처럼 버전 없이 npm specifier를 직접 사용했습니다.

실행 자체는 가능했지만 deno lint에서:

no-unversioned-import

오류가 발생했습니다.

그렇다고 모든 import에:

@1.30.0

을 반복해서 적으면 Prism 버전을 올릴 때 여러 줄을 함께 수정해야 합니다.

그래서 버전 관리는 deno.json으로 옮겼습니다.

{
  "imports": {
    "prismjs": "npm:prismjs@^1.30.0",
    "prismjs/": "npm:prismjs@^1.30.0/"
  }
}

코드에서는 버전을 신경 쓰지 않습니다.

import "prismjs/components/prism-typescript.js";

이제 역할도 깔끔하게 나뉩니다.

deno.json
→ 어떤 Prism 버전을 사용할 것인가

posts.ts
→ 어떤 언어를 하이라이팅할 것인가

의존성 버전을 코드 여러 곳에 퍼뜨리지 않아도 됩니다.

홈 화면은 Markdown 목록에서 만들어집니다

Markdown 시스템을 만들기 전에는 홈의 글 목록을 직접 작성할 수도 있습니다.

하지만 실제 콘텐츠가 늘어나기 시작하면 곧 문제가 됩니다.

새 글을 추가할 때마다:

Markdown 작성
+
홈 수정
+
Featured 수정
+
카테고리 수정

을 반복하고 싶지는 않습니다.

그래서 홈에서도 getPosts()를 사용합니다.

const posts = await getPosts();

최신 글은 이미 날짜순으로 정렬되어 있으므로:

const latestPosts = posts.slice(0, 5);

처럼 사용할 수 있습니다.

Featured 역시:

const featured = posts.find((post) => post.meta.featured) ??
  posts[0];

정도로 결정할 수 있습니다.

이제 새로운 Markdown 파일을 추가하면 홈도 자동으로 바뀝니다.

카테고리도 같은 데이터에서 만듭니다

카테고리별로 별도의 글 목록을 관리하지 않습니다.

예를 들어 Fresh 카테고리는 전체 글에서:

const filtered = posts.filter(
  (post) =>
    post.meta.category.toLowerCase() ===
      "fresh",
);

처럼 걸러냅니다.

결국 사이트 곳곳의 데이터 원본은 하나입니다.

content/posts/*.md
       │
       ▼
    getPosts()
       │
       ├─ Home
       ├─ Featured
       ├─ Latest
       ├─ Category
       ├─ Search
       └─ Sitemap

이 구조의 장점은 글 정보를 한 번만 작성한다는 것입니다.

제목이나 카테고리를 바꿔야 한다면 Markdown의 Front Matter만 수정하면 됩니다.

SEO 정보도 Front Matter에서 가져옵니다

글 제목과 설명을 가지고 있으니 SEO 메타데이터도 같은 정보를 사용할 수 있습니다.

const canonical = `${SITE.url}/posts/${data.slug}`;

const ogImage = data.meta.image
  ? `${SITE.url}${data.meta.image}`
  : `${SITE.url}${SITE.ogImage}`;

그리고 <head>에 넣습니다.

<head>
  <title>
    {data.meta.title} | {SITE.name}
  </title>

  <meta
    name="description"
    content={data.meta.description}
  />

  <link
    rel="canonical"
    href={canonical}
  />

  <meta
    property="og:title"
    content={data.meta.title}
  />

  <meta
    property="og:description"
    content={data.meta.description}
  />

  <meta
    property="og:type"
    content="article"
  />

  <meta
    property="og:url"
    content={canonical}
  />

  <meta
    property="og:image"
    content={ogImage}
  />
</head>;

결국 Front Matter의:

title:
description:
image:

가 본문 위의 제목뿐 아니라 검색엔진과 SNS 공유 정보까지 함께 결정하게 됩니다.

sitemap도 같은 글 목록에서 만들 수 있습니다

검색엔진에 존재하는 글 URL을 알려주는 sitemap도 별도로 관리할 필요가 없습니다.

이미 getPosts()가 모든 공개 글을 알고 있기 때문입니다.

개념적으로는 다음과 같습니다.

const posts = await getPosts();

const urls = posts.map((post) => ({
  loc: `${SITE.url}/posts/${post.slug}`,
  lastmod: post.meta.date,
}));

여기에 홈과 카테고리 페이지를 추가해 XML로 반환하면 됩니다.

새 글이 생길 때마다 sitemap 파일을 직접 수정할 필요가 없습니다.

Markdown 파일 하나 추가로 다음이 모두 바뀝니다.

글 상세 페이지
홈 Latest
카테고리
검색
sitemap
RSS

콘텐츠의 원본이 한 곳에 있다는 장점이 여기서 꽤 크게 느껴집니다.

RSS도 크게 다르지 않습니다

RSS 역시 원리는 같습니다.

getPosts()
↓
최신 글 목록
↓
RSS XML

각 글에서 이미 가지고 있는:

title
description
date
slug

를 <item>으로 변환하면 됩니다.

결국 Markdown 시스템을 한 번 만들어 놓으니 검색엔진용 sitemap과 RSS까지 같은 데이터를 재사용할 수 있었습니다.

고정 페이지는 posts와 분리했습니다

About, Privacy, Contact도 Markdown으로 작성합니다.

하지만 일반 글과 같은 content/posts에 넣지는 않았습니다.

현재 구조는 다음처럼 나눴습니다.

content/
├── posts/
│   ├── fresh-zero-js.md
│   └── ...
│
└── pages/
    ├── about.md
    ├── privacy.md
    └── contact.md

이유는 역할이 다르기 때문입니다.

posts는 날짜, 카테고리, 태그를 가지고 목록에 나타나는 콘텐츠입니다.

반면:

About
Privacy
Contact

는 사이트 구조를 구성하는 고정 페이지입니다.

둘 다 Markdown을 사용하지만 같은 콘텐츠 타입으로 취급할 필요는 없습니다.

그래서 고정 페이지는 별도의 utils/pages.ts에서 읽도록 분리했습니다.

결국 Markdown 파일이 작은 CMS 역할을 합니다

지금 구조에는 별도의 CMS가 없습니다.

관리자 페이지도 없고 데이터베이스도 없습니다.

그렇지만 블로그 운영에 필요한 정보는 대부분 가지고 있습니다.

title
description
date
category
tags
draft
featured
image
body

Front Matter가 데이터베이스의 글 레코드 역할을 하고, Markdown 본문이 실제 콘텐츠 역할을 하는 셈입니다.

그리고 Fresh가 서버에서 이 파일을 읽어 페이지를 만듭니다.

Git
↓
Markdown
↓
Deno filesystem
↓
Fresh
↓
HTML

개인 기술 블로그처럼 작성자가 한 명이고 콘텐츠를 Git으로 관리한다면 꽤 단순하면서도 편한 구조입니다.

글을 작성하는 작업과 사이트 데이터를 관리하는 작업이 사실상 하나가 됩니다.

직접 만들면서 중요했던 것은 렌더러보다 데이터 흐름이었습니다

처음에는 Markdown을 HTML로 변환하는 것만 해결하면 블로그 기능이 거의 끝날 것처럼 보였습니다.

실제로 render() 자체는 한 줄입니다.

render(body);

하지만 블로그를 구성하려면 그 주변에 더 많은 것이 필요했습니다.

파일 이름
→ slug

Front Matter
→ metadata

metadata
→ Home / Category

body
→ HTML

metadata + slug
→ SEO

전체 posts
→ Sitemap / RSS

이 흐름을 하나로 연결하고 나니 이후 기능을 추가하기도 쉬워졌습니다.

실제로 검색 기능을 만들 때도 별도의 검색 데이터베이스를 만들지 않았습니다.

이미 존재하는 getPosts()의 결과를 검색하면 되기 때문입니다.

다음 글에서는 바로 이 구조를 이용해 Fresh에서 JavaScript 없이 서버 검색 기능을 만드는 과정을 살펴보겠습니다.

검색창에서 Fresh, Vite, zero js처럼 입력하면 서버에서 Markdown 메타데이터를 검색하고 관련도 순으로 결과를 돌려주는 구조를 만들어보겠습니다.

참고 자료

#Fresh#Markdown#Deno#SEO#PrismJS