🏠

Next.jsで作ったポートフォリオサイトをAstroで作り直した

2025/01/04に公開

サイトはこちらです。

https://naary.me

変更点

  • Next.js → Astro v5.1(width React)へ変更
  • KumaUI → Tailwind CSSへ変更
  • ESLint → Biomeへ変更

変更していない点

  • CMS(microCMS)
  • サーバー(Cloudflare Pages)

リプレイスの理由

動機のほとんどは、ソースコードのメンテナンス性によるものです。

以前からディレクトリ設計を見直したり内部の実装を改善したいと思い続けていましたが、そもそもスタイリングに採用しているKumaUIをこれからも使い続けたいという動機があまりありませんでした。

さらに言えばAstroの開発体験をすでに経験しており、Next.jsをやめてこちらへ移行したいという気持ちもありました。

こうした背景から、これはもう一から書き直してしまおうという気分が自分の中でだんだんと醸成されたことで今回のリプレイスに踏み切った次第です。

作業は2024年10月19日から週末を使ってコツコツ進め、年末の休暇で追い上げて2025年の1月3日におおむね完了しました。

スタックを変更した理由

Next.js → Astro

Astroを選んだのは、こういったほとんど静的なサイトを作るためのフレームワークとしてはこの上なくシンプルなところが気に入っていたからです。

2024年末時点でのNext.js(およびAppRouter)はパフォーマンスチューニングのための各種機能や仕様の理解がとても難しく、これらをきちんと理解するのは自分には難しくハードルが高いと感じています。(本職のエンジニアじゃないし)

KumaUI → Tailwind CSS

今回のサイトは、以前に一度ChakraUIからKumaUIへライブラリを変更したことがあります。

(同時にサーバーをVercelからCloudflare Pagesへ移行しており、XMLやHTMLのパースに使用していた各種ライブラリが使えなくなったため、このときもほとんど全面的な作り直しとなりました)

ChakraUIはバンドルサイズが大きくなってしまう点に不満を感じており、ゼロランタイムかつ国産のUIライブラリという点に魅力を感じたためKumaUIを採用していました。

しかしここ最近、スタイリングにはこうしたUIコンポーネントではなくもっとシンプルなものを利用したいと考えるようになっていたので、現時点では安定的に実績のあるTailwindの採用に至りました。

ESLint → Biome

Biomeは以前に利用したことがあり、導入から利用開始までのスムーズさと実行速度が気に入っていたので、引き続きの採用となりました。

個人開発という性質上、そこまでルールを厳格にカスタマイズしたいというモチベーションがなく、なおかつ以前のESLintで定義していたルールを引き継ぎたいという気持ちもありませんでした。

さらに今回は既存の実装を何も流用できない一からの書き直しですから、既存のコードが新しいルールでフォーマットされて余分な差分が生じてしまうリスクもなかったです。

なお、Biomeは現在.astroファイルのフォーマットには対応していないため、こちらの記事を参考にPrettierも利用しています。

https://zenn.dev/15/articles/6951b368ce195f

設計

/componentsの内部はこのようにしました。

📁/components
  📁/Layouts
    📁/Header 共通のヘッダー
    📁/Footer 共通のフッター
    /Meta.tsx 👈 <html><head><body>をラップしたコンポーネント

  📁/Routes   各ルートごとのコンポーネント
    📁/Blog
    📁/Blogs
    📁/Error
    📁/Experiences
    📁/Top

以前は「スタイルを指定する層」と「ロジックを記述する層」を分離していたのですが、今回は主要なロジックは単体ファイルへ切り出し、全て/pages.astroコンポーネントでのみ実行するようにしています。

その他の工夫ポイント

Webフォントの読み込みを高速化した

ページを表示した際、一瞬ローカルフォントが見えてからGoogleフォントに切り替わる現象が発生していました。これはリプレイス以前から時折り、リプレイス後は常に発生するようになりました。

対策として、利用していたGoogleフォントNoto Sans JPをダウンロードし、ローカルフォントとして.woff2ファイルを読み込むようにしました。こちらの記事の内容をそのまま実践しています。

https://qiita.com/wwwy/items/7f55eccbe23272180c26

最終的な読み込み方法はこのようになりました。

<link
	rel="preload"
	href="/fonts/noto-sans-jp-regular.woff2"
	as="font"
	type="font/woff2"
	crossOrigin=""
/>
<link
	rel="preload"
	href="/fonts/noto-sans-jp-700.woff2"
	as="font"
	type="font/woff2"
	crossOrigin=""
/>

(TSXコンポーネントで実装しているためこのような書き方になっています。基本的に/pages以外では.astroコンポーネントを使用していません)

重要度の高い画像にfetchpriority属性を追加した

トップページで使用している画像やブログのアイキャッチなど、読み込み時点で表示されていて欲しい画像に属性を追加しました。

例👇

<img
	className="object-cover h-auto max-h-[8rem] w-full rounded-xl z-10"
	src={`${image.url}?w=360`}
	alt=""
	fetchPriority="high"
/>

VSCodeのコンソールでは「Reactではfetchpriority属性を指定できないから、消しなさい」みたいなWarningが出るのですが、実際にデベロッパツールで見てみるとちゃんと属性が付いていました。

HTML・XMLのパースの実装を変更した

動的コンテンツの管理にはこれまで通りmicroCMSを利用しています。

microCMSから受信したブログ記事本文はHTML文字列となっており、これをどうにかDOMへパースしなければなりません。

Astroコンポーネントでは、以下のようにディレクティブを利用することでHTML文字列をパースすることができます。

---
const rawHTMLString = "Hello <strong>World</strong>"
---
<h1>{rawHTMLString}</h1>
  <!-- Output: <h1>Hello &lt;strong&gt;World&lt;/strong&gt;</h1> -->
<h1 set:html={rawHTMLString} />
  <!-- Output: <h1>Hello <strong>World</strong></h1> -->

https://docs.astro.build/ja/reference/directives-reference/#sethtml

しかしTailwindを使用してスタイルを付与する場合、この方法だけでは不十分です。

これまではhtml-react-parserを利用し、タグごとにChakra UIのコンポーネントやTailwindのクラス属性を付与したHTMLへ差し替えることで実装していました。

しかしこのライブラリは@astrojs/cloudflareを追加した状態では利用することができなかったため、今回は代わりにcheerioを導入することにしました。

https://github.com/cheeriojs/cheerio

まず/src/stylesへCSSファイルを作成し、以下のスタイルを記載しました。

@tailwind base;
@tailwind components;
@tailwind utilities;

.paragraph {
  @apply text-text-darken-1 mt-[1em] first:mt-0 leading-[1.7rem];
}

.heading-2 {
  @apply text-2xl font-bold text-text-default text-center mt-[4em] first:mt-0;
}

.heading-3 {
  @apply text-xl font-bold mt-[2.4em] first:mt-0 text-center;
}

/* 以下略 */

VSCodeでTailwind拡張が推奨する設定を行うことで、CSSファイルでもサジェストなどの恩恵を受けられました。

これを記事詳細ページの.astroファイルでインポートします。

import "../styles/blog.css"

あとはHTML文字列を以下の関数へ読み込ませることで、Tailwindによるスタイル指定が付与された状態のHTMLをset:htmlディレクティブを使って表示させることができました。

import * as cheerio from "cheerio";

export const parseHtml = (html: string) => {
	const $ = cheerio.load(html);

	$("p").addClass("paragraph");
	$("h2").addClass("heading-2");
	$("h3").addClass("heading-3");
	$("ul").addClass("unordered-list");
	$("ol").addClass("ordered-list");
	$("li").addClass("list-item");
	$("figcaption").addClass("figcaption");
	$("figure").addClass("figure");
	$("blockquote").addClass("blockquote");
	$("img").addClass("image");
	$("a").addClass("anchor").attr("target", "_blank").attr("rel", "noreferrer");

	$("script").remove();
	$("iframe").remove();

  // <html>と<body>を含んでしまうので、<body>の内部だけを返す
	return $("body").html();
};
---
import "../styles/blog.css"
/* 〜〜略〜〜 */
const blog = await getBlog(blogId);
const content = parseHtml(blog.content)
---
<Fragment set:html={content} />

リンクカードの実装も変更した

Next.jsでは以下のように実装していました。

  • html-react-parserで専用コンポーネントへ差し替え
  • 専用コンポーネントが/api/ogへクライアントからフェッチ
  • OG情報をJSONで取得してCSR

しかしcheerioへの移行にあたり、パース後の値がDOMではなく静的なHTML文字列として返るようになりました。そのためTSXコンポーネントへ差し替えてCSRを行うアプローチでは実装できません。

結果、静的生成時にOG情報の取得とリンクカードのレンダリングも併せて行うことにしました。
以下が主要なロジック全文です。(大体ChatGPTが書いています)

import * as cheerio from "cheerio";

export type OgObject = {
	ogUrl: string;
	ogTitle: string;
	ogDescription?: string;
	ogImage?: { url: string; width?: number; height?: number }[];
	favicon?: string;
};

const fetchOgData = async (url: string): Promise<OgObject> => {
	try {
		const response = await fetch(url);
		const html = await response.text();
		const $ = cheerio.load(html);

		const ogUrl = $('meta[property="og:url"]').attr('content') || url;
		const ogTitle = $('meta[property="og:title"]').attr('content') || "No Title";
		const ogDescription = $('meta[property="og:description"]').attr('content') || "";
		const ogImageMeta = $('meta[property="og:image"]');
		const ogImage = ogImageMeta.map((_, elem) => {
			const imageUrl = $(elem).attr('content');
			if (imageUrl) {
				return { url: imageUrl };
			}
			return null;
		}).get().filter(Boolean);

		const favicon = $('link[rel="icon"]').attr('href') || $('link[rel="shortcut icon"]').attr('href') || "";

		return { ogUrl, ogTitle, ogDescription, ogImage, favicon };
	} catch (error) {
		console.error(`Failed to fetch Open Graph data from ${url}:`, error);
		return { ogUrl: url, ogTitle: "Failed to fetch data" };
	}
};

export const createOgMap = async (htmlString: string): Promise<Map<string, OgObject>> => {
	const $ = cheerio.load(htmlString);
	const ogMap = new Map<string, OgObject>();

	const promises = $(".iframely-embed a").map(async (_, elem) => {
		const href = $(elem).attr("href");
		if (href) {
			const result = await fetchOgData(href);
			ogMap.set(href, result);
		}
	}).get();

	await Promise.all(promises);

	return ogMap;
};

以前はOG情報のスクレイピングにopen-graph-scraperを使っていましたが、今回はそうしたHTMLのパースにもcheerioを利用しています。

microCMSのリッチテキストでリンクを埋め込むと、<div class=“iframely-embed”>にラップされた<iframe><a>タグが出力されます。これを要素丸ごと差し替えるような実装にしています。

import * as cheerio from "cheerio";
+ import ReactDOMServer from "react-dom/server";
+ import type { OgObject } from "../og-scraper";

+ import OgLink from "../../components/Routes/Blog/Content/OgLink";

- export const parseHtml = (html: string) => {
+ export const parseHtml = (html: string, ogMap?: Map<string, OgObject> ) => {
	const $ = cheerio.load(html);

	$("p").addClass("paragraph");
	$("h2").addClass("heading-2");
	$("h3").addClass("heading-3");
	$("ul").addClass("unordered-list");
	$("ol").addClass("ordered-list");
	$("li").addClass("list-item");
	$("figcaption").addClass("figcaption");
	$("figure").addClass("figure");
	$("blockquote").addClass("blockquote");
	$("img").addClass("image");
	$("a").addClass("anchor").attr("target", "_blank").attr("rel", "noreferrer");

+	$("div.iframely-embed").each((_, elem) => {
+		const anchor = $(elem).find("a");
+		const url = anchor.attr("href");
+
+		if (url && ogMap) {
+			const result = ogMap.get(url);
+			if (result === undefined) {
+				return $(elem).replaceWith("");
+			}
+			const ogLinkHtml = ReactDOMServer.renderToStaticMarkup(OgLink({result}));
+			$(elem).replaceWith(ogLinkHtml);
+		}
+	});

	$("script").remove();
	$("iframe").remove();

	return $("body").html();
};

上手くいかなかったポイント

主にこちらのページを作っているときの話です👉https://naary.me/experiences/

Server Islands を動かせなかった

https://docs.astro.build/ja/guides/server-islands/

Server Islandsは、 ページの一部のみリクエストを受けてからSSRする機能です。

概要はこちらの記事で知りました。
https://zenn.dev/morinokami/articles/astro-server-islands-vs-nextjs-ppr

ディレクティブserver:deferを付与したコンポーネントは、初回のレスポンスには含まれなくなります。裏でレンダリングされたあと、すでに返っているHTMLに差し込まれる形で遅れて表示されます。

この機能を是非とも利用したく、12月にリリースされた最新バージョンへさっそくアップグレードしたのですが、いろいろと問題が発生しました。

  • @astrojs/cloudflare + Cloudflare Pagesでは動作したりしなかったりした
  • @astrojs/vercel + Vercelでも動作したりしなかったりした
  • ローカルでは一切動作させられなかった

それぞれのPaaSでは、何やら動いたり動かなかったりといった不安定さがありました。

またローカルでは初期画面が一瞬表示された後、Cannot read property 'name' of undefinedが発生してエラー画面へ変わってしまうようになりました。エラーの発生箇所はnode_modulesの内部で、Vite周りで何か不整合が起きているような気配は感じるものの詳細はまったくわかりません。

※なおここでの「ローカル」というのは全てGitHub Codespaces(iPadのSafariで利用)のことです。筆者はPCを持っていません。

各PaaSでも同様の挙動になっていたのかもしれませんが、ログを見てもエラーは何も検出されていなかった(二回目のレスポンスも200で返っていた)ため、全く手がかりがつかめませんでした。

SSRもできなかった

リクエストごとにHTMLをレンダリングする方法として、Next.jsではISGやSSRが使えました。

AstroではSSRに相当する機能として On Demand Rendering があり、設定で全体的にSSRをデフォルトにするか、全体では静的生成をデフォルトにした上でページごとにSSRするように指定することで利用できます。

---
export const prerender="false";
👆
// 事前レンダリングしない === SSRする
---

ドキュメントを読んだ上での理解ではそうだったのですが、Cloudflare用のアダプターをインストールした状態でCloudflare Pagesへデプロイしてみると、SSRを指定したページが真っ白になって一切レンダリングされないという現象に遭遇しました。

ページごとにSSRするとそのページが、全体でSSRをデフォルトにすると全ページが表示されなくなります。

Server Islandsが使えなければせめてSSRしようかと思ったのですが、こちらも上手くいかなかったため最終的には以下のように妥協しました。

  • 静的生成+client:onlyで対象のコンポーネントのみCSR
  • データ取得はAPI + fetch()で実装

APIコール周りはChatGPTが書いています。

<ExperienceContainer client:only />
type ApiResponse = {
	rss: GroupedPerYear[];
	socials: SocialIcon[];
};

export default function ExperienceContainer() {
	const [data, setData] = useState<ApiResponse | null>(null);
	const [loading, setLoading] = useState(true);
	const [error, setError] = useState<string | null>(null);

	useEffect(() => {
		const fetchData = async () => {
			try {
				const response = await fetch("/api/experiences");
				if (!response.ok) {
					throw new Error(`Failed to fetch: ${response.status}`);
				}
				const result: ApiResponse = await response.json();
				setData(result);
			} catch (err) {
				setError(err instanceof Error ? err.message : "Unknown error");
			} finally {
				setLoading(false);
			}
		};

		fetchData();
	}, []);

	if (loading) return (
		<Fallback>
			<p>Loading...</p>
		</Fallback>
	);
	if (error) return (
		<Fallback>
			<p>Error: {error}</p>
		</Fallback>
	);

	if (!data) return (
		<Fallback>
			<p>No data available</p>
		</Fallback>
	);

	return (
		/* データ取得後のHTML */
	);
}

今後やりたいこと

最終的には全ページを静的に生成するベーシックな使い方となりました。

もう少し時間が経ったら Server Islands に再挑戦してみたいですし、microCMSの下書きをSSRするページを追加して動的コンテンツのプレビューにも対応できるようにしたいです。

Discussion