
前回の記事で、ブログサイトの全体像を紹介しました。 今回は、具体的な機能実装について、なぜその設計にしたかという視点を中心に書いていきます。
扱う機能は以下の4つです。
ブログサイトで記事を書く際、マークダウン形式で書けることは必須だと考えました。 理由はシンプルで、普段から技術的なメモをマークダウンで書いている習慣があったからです。HTMLやリッチテキストエディタよりも、プレーンテキストに近いマークダウンで書ける方が、自分のイメージに合っていました。
今回実装する機能としては以下になります。
マークダウンの処理には、unifiedというエコシステムを使うことにしました。unifiedは、テキストを処理するためのツールで、remark(マークダウン処理)とrehype(HTML処理)を組み合わせて使います。
なぜunifiedを選んだかというと、プラグイン形式で機能を追加できる点が魅力的だったからです。マークダウンからHTMLへの変換だけでなく、シンタックスハイライトや、カスタム機能(コードコピーボタンやリンクアイコン)をプラグインとして追加できます。
処理の流れは以下のようになります。
Markdown
→ remark-parse (マークダウンを解析)
→ remark-gfm (GitHub Flavored Markdown対応)
→ remark-rehype (HTML構造に変換)
→ rehype-raw (HTMLタグを許可)
→ rehype-slug (見出しにIDを付与)
→ rehype-link-icon (外部リンクにアイコン追加)
→ rehype-highlight (シンタックスハイライト)
→ rehype-code-copy (コードコピーボタン追加)
→ rehype-sanitize (XSS対策)
→ rehype-stringify (HTML文字列に変換)
→ HTML
プラグインの順序も重要です。例えば、rehype-sanitizeは最後に実行します。
これは他のプラグイン(シンタックスハイライトやコードコピーボタン)が追加した要素や属性を、サニタイズ時に削除しないようにするためです。
実装で苦労した点がいくつかありました。
XSS対策とのバランス: セキュリティのためにrehype-sanitizeでサニタイズしますが、シンタックスハイライト用のクラス(hljs-*など)やコードコピーボタン用の属性は残したいという課題です。これは、デフォルトのスキーマをカスタマイズして、必要な属性のみを許可する方式にしました。
コードコピーボタンの実装: rehype-code-copyというカスタムプラグインを作りましたが、これは単にHTMLを追加するだけで、実際のコピー機能はJavaScriptで実装する必要があります。プレビュー表示時に、コードコピーボタンにイベントリスナーを追加する処理も必要でした。
プレビューのパフォーマンス: マークダウンを編集するたびにHTMLに変換するので、長い記事だと処理が重くなる可能性があります。現状はuseEffectで処理していますが、将来的にはデバウンス処理を追加することも検討しています。
マークダウンの処理は、単純にHTMLに変換するだけでは済まないことが分かりました。
セキュリティ、UX、パフォーマンスのバランスを取る必要があります。
プラグイン形式のunifiedを使うことで、機能を段階的に追加できたのは良かったです。
記事の作成・編集・削除を管理する画面が必要でした。 最初はシンプルなフォームから始めましたが、以下の機能を段階的に追加していきました。
APIには、Next.js API RoutesではなくHonoを使うことにしました。 理由は、Edge Runtimeで動かすためです。Cloudflare Workers上でNext.js API Routesを使うと、一部の機能が制限されるため、Honoの方が柔軟に対応できます。
APIの設計では、以下の点を意識しました。
特にバリデーションは重要です。フロントエンドでもチェックしますが、サーバー側でも必ず検証します。 Zodを使うことで、TypeScriptの型とバリデーションロジックを統一できました。
記事管理機能の実装では、クリーンアーキテクチャを意識しました。
各層が独立していることで、テストが書きやすくなりました。 例えば、データベースを使わずにユースケース層のテストを書けます。
フォーム管理: React Hook Formを使いましたが、マークダウンのような大量のテキストを扱う場合、状態管理が複雑になりがちです。特に、プレビュー機能と連動させる際に、状態の同期が課題でした。
画像アップロードのタイミング: 記事を保存する前に画像をアップロードする必要がありますが、記事IDが確定するまではアップロードできません。そこで、記事を先に保存してから画像をアップロードする方式にしました。
エラーハンドリング: ネットワークエラーやバリデーションエラーなど、様々なエラーケースに対応する必要がありました。エラーメッセージをユーザーに分かりやすく伝えるために、トースト通知を使いました。
管理画面の実装では、UXとセキュリティのバランスが重要だと感じました。 使いやすさを追求しつつ、適切なバリデーションとレートリミットを設ける必要があります。 また、クリーンアーキテクチャを意識することで、コードの見通しが良くなったと思います。
記事が増えてきたとき、過去の記事を探しやすくする機能が必要だと考えました。 検索とフィルタリングの両方を実装しました。
検索機能は、最初はシンプルな文字列検索から始めました。 将来的には全文検索(Full-Text Search)も検討していますが、現時点ではタイトルとタグでの検索で十分だと考えています。
検索とフィルタリングは、URLのクエリパラメータで管理することにしました。 理由は、検索結果のURLを共有できるようにするためです。 例えば、「Next.jsの記事一覧」のURLを誰かに共有すれば、同じ結果を表示できます。
URLの構造は以下のようになります。
/articles/articles?tag=Next.js/articles?q=React/articles?page=2D1での検索クエリ: SQLiteベースのD1では、全文検索(FTS)を使うこともできますが、現時点ではシンプルなLIKE検索を使っています。記事が増えてきたら、パフォーマンスを考慮してFTSに移行することも検討しています。
ページネーションの実装: 総記事数を取得して総ページ数を計算する必要がありますが、検索やフィルタリングが適用されている場合、正しく計算する必要があります。そのため、検索条件に応じて総記事数を再計算する処理が必要でした。
状態管理: 検索クエリとページネーションの状態を、URLパラメータと同期させる必要があります。Next.jsのuseSearchParamsを使いましたが、サーバーコンポーネントとクライアントコンポーネントの間での状態同期が少し複雑でした。
検索機能は、シンプルに見えて意外と複雑です。URLの管理、クエリの最適化、状態の同期など、様々な考慮点があります。しかし、URLで状態を管理することで、ブラウザの戻る/進むボタンも自然に使えるようになり、UXが向上しました。
記事にアイキャッチ画像を設定できるようにしたいと考えました。 画像の保存先として、Cloudflare R2を使うことにしました。 理由は、Cloudflare Workersと統合しやすく、エグレス費用がかからないためです。
要件としては以下を挙げました:
画像アップロードには、Presigned URLを使う方式にしました。これは、AWS S3互換のAPIで、一時的なURLを生成してクライアントから直接R2にアップロードできる方式です。サーバーを経由せずにアップロードできるため、パフォーマンス面でも有利です。
流れは以下のようになります。
記事IDと画像の紐付け: 記事を保存する前に画像をアップロードしたい場合、記事IDがまだ決まっていません。そこで、記事を先に保存してIDを確定させてから、画像をアップロードする方式にしました。
画像削除の実装: 画像を削除する際、データベースからURLを削除するだけでなく、R2からも実際のファイルを削除する必要があります。画像を削除するAPIエンドポイントを作り、R2からも削除する処理を実装しました。
エラーハンドリング: 画像アップロードは非同期処理が多いため、エラーハンドリングが複雑になりがちです。特に、ネットワークエラーやファイルサイズ制限などのエラーケースに対応する必要がありました。
画像アップロードは、単純にアップロードするだけでなく、セキュリティやパフォーマンスを考慮する必要があります。Presigned URLを使うことで、セキュアかつ効率的にアップロードできるようになりました。また、画像の削除処理も忘れずに実装することが重要だと感じました。
この記事では、主要な機能について、なぜその設計にしたかという視点を中心に書きました。技術的な詳細よりも、設計の考え方や試行錯誤のログに焦点を当てたつもりです。
実装してみて分かったことは、シンプルに見える機能でも、様々な考慮点があるということです。マークダウンの処理一つ取っても、セキュリティ、UX、パフォーマンスのバランスを取る必要があります。
次回は、実際の開発で遭遇した課題と解決方法について詳しく書く予定です。Next.js App Routerとの格闘や、Edge Runtimeの制約など、技術的な深掘りに入っていきます。