デザインについての学習メモブログ

Next.js入門 #14 実践編 記事コンテンツを強化する — Markdownレンダリングと画像アップロード

記事内に広告が含まれています。

Next.js入門 #14 実践編 記事コンテンツを強化する — Markdownレンダリングと画像アップロード

#13までで、ログインしたユーザーが自分の記事を投稿・編集・削除できる、という基本機能が一通り揃いました。

しかし、本文は今のところ、ただのプレーンテキストです。

見出しも、コードブロックも、太字すら表現できません。

今回は、以下の2つを実装してコンテンツの表現力を上げます。

  • Markdownレンダリング:本文をMarkdownで書き、見出しやコードブロック付きで表示する
  • 画像アップロード:記事のアイキャッチ画像をアップロードできるようにする

この記事は#13(自分の投稿を編集できる状態)が前提です。

Markdownレンダリングを実装する

インストール

Bash
npm install react-markdown remark-gfm rehype-highlight

それぞれの役割は以下のとおりです。

パッケージ役割
react-markdownMarkdown文字列をReactコンポーネントとして描画する本体
remark-gfmGitHub Flavored Markdown(テーブル・取り消し線など)に対応させる拡張
rehype-highlightコードブロックにシンタックスハイライトを適用する拡張

記事詳細ページでMarkdownを描画する

TSX
// app/posts/[id]/page.tsx
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";

// ...省略(#13で実装したデータ取得・権限チェック部分はそのまま)

export default async function PostDetailPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  // post取得・アクセス制御は#13と同様のため省略(#13同様、先頭で const { id } = await params; を行う)

  return (
    <article className="prose max-w-none">
      <h1>{post.title}</h1>
      <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>
        {post.content}
      </ReactMarkdown>
    </article>
  );
}

articleタグのproseクラスは、Tailwind CSSの公式プラグイン@tailwindcss/typographyが提供するもので、見出し・段落・リストなどに自然な余白とフォントサイズを一括で当ててくれます。

まだ導入していない場合は、以下でセットアップします。

Bash
npm install -D @tailwindcss/typography
CSS
/* app/globals.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";

/* 以下、既存の @theme ブロックなどはそのまま */

投稿したpostをマークダウンで編集してみてください。

postに対してスタイルが適用されているはずです。

投稿フォームをMarkdown対応にする

投稿・編集フォームのtextareaはそのままで問題ありません。

ユーザーがMarkdown記法(# 見出し```コード```など)をそのまま入力し、保存時はプレーンテキストとしてDBに格納、表示時にReactMarkdownでレンダリングする、というシンプルな構成です。

余裕があれば、プレビューを並べて表示すると執筆体験が向上します。

TSX
// app/posts/_components/markdown-editor.tsx
"use client";

import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Textarea } from "@/components/ui/textarea";

export default function MarkdownEditor({ name }: { name: string }) {
  const [content, setContent] = useState("");

  return (
    <div className="grid grid-cols-2 gap-4">
      <Textarea
        name={name}
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Markdownで本文を書けます"
        rows={16}
      />
      <div className="prose max-w-none border rounded-md p-4 overflow-auto">
        <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
      </div>
    </div>
  );
}

左側に入力欄、右側にリアルタイムのプレビューを配置することで、Markdownに不慣れな人でも書きやすくなります。

投稿フォームに組み込んで確認する

画像アップロードの実装に進む前に、ここまでの内容だけを一度組み込んで動作確認しておきましょう。

#12で作ったnew-post-dialog.tsxTextareaを、今作ったMarkdownEditorに差し替えます。

TSX
// app/posts/_components/new-post-dialog.tsx
"use client";

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { usePostDialogStore } from "@/stores/use-post-dialog-store";
import { createPost } from "../actions";
import MarkdownEditor from "./markdown-editor";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export default function NewPostDialog() {
  const { isOpen, close } = usePostDialogStore();
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
      toast.success("記事を投稿しました");
      close();
    },
    onError: () => {
      toast.error("投稿に失敗しました");
    },
  });

  return (
    <Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>新しい記事</DialogTitle>
        </DialogHeader>
        <form action={(formData) => mutation.mutate(formData)} className="space-y-4">
          <div className="space-y-2">
            <Label htmlFor="title">タイトル</Label>
            <Input id="title" name="title" placeholder="記事のタイトル" required />
          </div>

          {/* ↓ #11のTextareaをMarkdownEditorに置き換えた部分 */}
          <MarkdownEditor name="content" />

          <Button type="submit" className="w-full" disabled={mutation.isPending}>
            投稿する
          </Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}

変更点は、Textareaのimportと使用箇所をMarkdownEditorに置き換えただけです。

Dialog構造・Zustand・useMutationはそのまま残しています。

動作確認

ヘッダーの「新しい記事を書く」からモーダルを開き、本文欄に# 見出し**太字**のようなMarkdown記法を入力してみてください。

右側にリアルタイムでプレビューが表示され、投稿後は先ほど実装したReactMarkdownによって記事詳細ページにも装飾付きで表示されるはずです。

ここまで確認できたら、画像アップロードの実装に進みます。

⚠️ 編集時もプレビューしたい場合

編集ボタン押して編集している画面では、まだリアルタイムにプレビューを見れないでしょう。

応用すれば編集時もリアルタイムにプレビューで確認できるようになります。

画像アップロードを実装する

続いて、アイキャッチ画像のアップロード機能です。

今回はVercel Blobを使います。

Vercel Blobは、Vercelが提供しているファイルストレージサービスです。画像・PDF・動画といった「ファイル」を保存し、それぞれに固有のURLを発行してくれます。

最終的に#17でVercelにデプロイする前提のアプリであれば、環境変数の設定がほぼ不要で導入しやすいのが利点です。

デプロイ自体はまだ先ですが、プロジェクトをVercelに連携しておくだけで、この時点からローカル開発でも使えます。

Postモデルに画像URLのフィールドを追加する

Prisma
// prisma/schema.prisma

model Post {
  id          Int      @id @default(autoincrement())
  title       String
  content     String
  published   Boolean  @default(false)
  coverImage  String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  authorId    String
  author      User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
}

coverImageはオプショナル(String?)にしておき、画像なしでも投稿できるようにしています。

Bash
npx prisma migrate dev --name add_cover_image
npx prisma generate

Vercel Blobをセットアップする

Bash
npm install @vercel/blob

💡 補足

この記事の時点では、まだVercelへのデプロイを行っていません(デプロイ自体は#17で扱います)。

ですが、Vercel Blobを使うにはVercel側に「プロジェクト」が存在している必要があります。

本番公開までは不要で、プロジェクトをVercelに作成・連携するだけで、以下の手順を進められます。

まだVercelアカウントでこのプロジェクトを使ったことがない場合、以下の手順でVercel CLIをインストールし、プロジェクトを連携します。

Bash
npm install -g vercel
vercel login
vercel link

vercel linkを実行すると、「既存のVercelプロジェクトと紐付けるか、新規作成するか」を聞かれます。

まだ何もデプロイしていない場合は、新規プロジェクトとして作成を選べば大丈夫です。

この時点ではまだvercel deploy(本番公開)は行いません。

プロジェクトが連携できたら、Vercelのダッシュボードを開き、対象プロジェクトの「Storage」タブからBlobストアを作成します。

⚠️ 重要

Blobストアの作成画面では、「Public」か「Private」かのアクセスモードも選ぶことになります(ダッシュボードではデフォルトが「Private」になっていることがあります)。

この記事のアイキャッチ画像のように、誰でも閲覧できてよいファイルを扱う場合は、必ず「Public」を選んでください

アクセスモードは作成後に変更できないため、間違えた場合はストアを削除して作り直す必要があります。

CLIから作成する場合は、--accessオプションで明示できます。

Bash
vercel blob create-store my-blob-store --access public

作成直後の画面にはProduction / Previewしか表示されません。

作成が完了したら、ローカル開発でも使えるようにする追加の一手間が必要です。

  1. 作成したBlobストアの管理画面を開く
  2. 「Projects」タブを開く
  3. 連携しているプロジェクトの右側にある「⋯」(メニュー)→「Update Project Connection」を選ぶ
  4. ここでDevelopment環境にチェックを入れて保存する

この一手間を忘れると、次のvercel env pullを実行しても.env.localBLOB_READ_WRITE_TOKENが出力されず、ローカル開発でアップロードが失敗する原因になります。

作成が完了すると、BLOB_READ_WRITE_TOKENという環境変数がプロジェクトに自動追加されます。

ローカル開発では、Vercel CLIでこの環境変数を.env.localに同期させます。

Bash
npx vercel env pull .env.local

これで、デプロイをしていなくても、ローカルの開発サーバー(npm run dev)から画像アップロードを試せるようになります。

アップロード用のServer Actionを作成する

TypeScript
// app/posts/upload-actions.ts
"use server";

import { put } from "@vercel/blob";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";

const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];

export async function uploadCoverImage(file: File) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session?.user) {
    throw new Error("ログインが必要です");
  }

  if (!ALLOWED_TYPES.includes(file.type)) {
    throw new Error("対応していないファイル形式です(jpg / png / webpのみ)");
  }

  if (file.size > MAX_FILE_SIZE) {
    throw new Error("ファイルサイズは5MB以下にしてください");
  }

  const blob = await put(`covers/${session.user.id}/${Date.now()}-${file.name}`, file, {
    access: "public",
  });

  return blob.url;
}

ファイル形式とサイズのチェックをサーバー側で行っている点がポイントです。

フロントエンド側のaccept属性やJavaScriptでの制限は、あくまでユーザーへの親切なガイドであり、悪意のあるリクエストは簡単にすり抜けられます。

検証は必ずサーバー側でも行います。

ファイル名にはsession.user.idDate.now()を含め、他ユーザーとの衝突や、同じユーザーの連続アップロードによる上書きを避けています。

アップロードUIを実装する

Server ActionはFormDataのフィールドとしてではなく、ここでは「アップロード完了後にURLを受け取ってから投稿フォームに含める」という2段階の流れにします。

TSX
// app/posts/_components/cover-image-input.tsx
"use client";

import { useState } from "react";
import { uploadCoverImage } from "../upload-actions";
import { toast } from "sonner";

export default function CoverImageInput({
  onUploaded,
}: {
  onUploaded: (url: string) => void;
}) {
  const [preview, setPreview] = useState<string | null>(null);
  const [isUploading, setIsUploading] = useState(false);

  async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;

    setIsUploading(true);
    try {
      const url = await uploadCoverImage(file);
      setPreview(url);
      onUploaded(url);
      toast.success("画像をアップロードしました");
    } catch (error) {
      toast.error(error instanceof Error ? error.message : "アップロードに失敗しました");
    } finally {
      setIsUploading(false);
    }
  }

  return (
    <div className="space-y-2">
      <input type="file" accept="image/*" onChange={handleChange} disabled={isUploading} />
      {isUploading && <p className="text-sm text-muted-foreground">アップロード中...</p>}
      {preview && (
        // eslint-disable-next-line @next/next/no-img-element
        <img src={preview} alt="プレビュー" className="w-full max-w-sm rounded-md" />
      )}
    </div>
  );
}

投稿フォームと組み合わせる

アップロードして得たURLを、隠しフィールドとして投稿フォームに含めます。

ここでは新しいコンポーネントを作るのではなく、先ほど動作確認したnew-post-dialog.tsxMarkdownEditorまで組み込み済みのもの)に、CoverImageInputを追加する形で統合します。

TSX
// app/posts/_components/new-post-dialog.tsx
"use client";

import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { usePostDialogStore } from "@/stores/use-post-dialog-store";
import { createPost } from "../actions";
import CoverImageInput from "./cover-image-input";
import MarkdownEditor from "./markdown-editor";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export default function NewPostDialog() {
  const { isOpen, close } = usePostDialogStore();
  const queryClient = useQueryClient();
  const [coverImage, setCoverImage] = useState<string | null>(null);

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
      toast.success("記事を投稿しました");
      close();
    },
    onError: () => {
      toast.error("投稿に失敗しました");
    },
  });

  return (
    <Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>新しい記事</DialogTitle>
        </DialogHeader>
        <form action={(formData) => mutation.mutate(formData)} className="space-y-4">
          <CoverImageInput onUploaded={setCoverImage} />
          <input type="hidden" name="coverImage" value={coverImage ?? ""} />

          <div className="space-y-2">
            <Label htmlFor="title">タイトル</Label>
            <Input id="title" name="title" placeholder="記事のタイトル" required />
          </div>

          <MarkdownEditor name="content" />

          <Button type="submit" className="w-full" disabled={mutation.isPending}>
            投稿する
          </Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}

今回新しく追加したのはCoverImageInput(画像アップロード)と隠しフィールドのcoverImageの2点だけです。

MarkdownEditorは先ほどのステップですでに組み込み済み、Dialogまわりの構造・isOpen / close(Zustand)・useMutationによるキャッシュ更新は#12のままなので、それらのコードを消さずに残した上で、CoverImageInputと隠しフィールドだけを追加してください。

TypeScript
// app/posts/actions.ts

export async function createPost(formData: FormData) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session?.user) throw new Error("ログインが必要です");

  const title = formData.get("title") as string;
  const content = formData.get("content") as string;
  const coverImage = formData.get("coverImage") as string;
  
  if (!title || !content) {
    throw new Error("タイトルと本文は必須です");
  }

  await prisma.post.create({
    data: {
      title,
      content,
      coverImage: coverImage || null,
      authorId: session.user.id,
    },
  });

  revalidatePath("/posts");
}

next/imageで画像を最適化して表示する

一覧・詳細ページでアイキャッチ画像を表示する際は、<img>タグではなくnext/imageImageコンポーネントを使うことで、自動的な画像最適化(サイズ変換・遅延読み込み・WebP変換など)の恩恵を受けられます。

TSX
// app/posts/page.tsx
import Image from "next/image";

// ...

{posts.map((post) => (
  <li key={post.id}>
    {post.coverImage && (
      <Image
        src={post.coverImage}
        alt={post.title}
        width={640}
        height={360}
        className="rounded-md object-cover"
      />
    )}
    <h2>{post.title}</h2>
  </li>
))}

Vercel Blobの画像URLをnext/imageで使う場合、next.config.tsにドメインの許可設定が必要です。

TypeScript
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "*.public.blob.vercel-storage.com",
      },
    ],
  },
};

export default nextConfig;

これを設定しておかないと、「許可されていないドメインの画像です」というエラーで表示に失敗するので注意してください。

まとめ

この記事では、以下を実装しました。

  • react-markdownで本文をMarkdownとしてレンダリング、rehype-highlightでコードブロックにシンタックスハイライトを追加
  • 左右分割のリアルタイムMarkdownプレビューを実装
  • Vercel Blobを使い、ファイル形式・サイズをサーバー側で検証したうえで画像をアップロード
  • next/imageでアップロードした画像を最適化して表示

これで、ブログとしての表現力が大きく上がりました。ここまでで、記事の投稿・編集・削除・認証・DB・Markdown・画像という、実際のブログアプリに必要な機能がほぼ揃っています。

次回の#15 テスト入門 — VitestとPlaywrightで品質を保つでは、ここまで作ってきた機能に対してテストコードを書き、変更を加えても壊れていないことを自動で確認できる状態を作ります。