はじめに
2024/11/22に React Router v7 がリリースされました。
2024/5にRemixとReact Routerの統合が発表されてから約半年、どのような形で統合されるか気になっていた方も多いのではないでしょうか。
React Router v7は、Remixの延長でframeworkとして、ReactRouerの延長でライブラリとして、どのように利用するか開発者の選択の余地を残しつつ、
- 型安全性の向上
-
routes.ts
による設定ベースのルーティング定義 Route Module をデフォルトに -
prerender()
によるSSGのサポート
あたりが大きな変化と感じています。
個人的には、新機能であるSSGのサポートは興味深いものでした。
そこでこの記事では、その動作検証も兼ねてSSGの活用事例でよくあるドキュメントサイトジェネレータ1を作ってみたいと思います。
なお、実際のコードはこちらにありますので是非ご利用ください。
また、作ってみたもののデモは以下から確認できます。
つくるものと方針
- Markdown、MDXで作成したコンテンツからドキュメントサイトを構築できる
- React Router v7 を活用して
- なるべくシンプル、お手軽に
基本機能の作成
React Router v7 のセットアップ
インストール
公式ドキュメントに従いcreate-react-router
を利用してインストールします。
prerender の有効化
react-router-config.ts に prerender:true
を追加して、prerender を有効にします。
export default {
ssr: true,
+ prerender: true,
} satisfies Config;
prerenderにパスの配列を与えたり関数を与えて、特定のパスのみprerenderする、ことも可能です。
https://reactrouter.com/how-to/pre-rendering
MDXのセットアップ
MDXを利用して、.mdx(.md)をコンパイルしてReactのコンポーネントを生成できるようにします。.mdxをコンパイルするタイミングは
- ビルド
- ランタイム
の2つがあります。実装の容易性とスケーラビリティのトレードオフではありますが、ここでは前者をとります。
Rmiexのガイドにもこの議論がもう少し詳しく記載されています。
https://remix.run/docs/en/main/guides/mdx
@mdx-js/rollup
のインストールと設定
React Router v7 は viteを利用しているので、MDXのRollup pluginである、@mdx-js/rollup
を利用します。
npm install @mdx-js/rollup
でインストールした後、MDXのガイドに従い設定します。
export default defineConfig({
plugins: [
+ {
+ enforce: 'pre',
+ ...mdx(),
+ },
reactRouter(),
tsconfigPaths(),
],
});
何のための設定か
この後の手順の理解のために、この設定により何が起きるかを補足しておきます。
この設定により、.mdx(.md)を、通常のコンポーネントと同様に扱えるようになります。
例えば、以下の.mdxがある場合、
# Hello
Hello world!
イメージ的には、以下のようなコンポーネントに変換し、それを default export
してくれます。
export default function MDXContent() {
return (
<>
<h1>Hello</h1>
<p>Hello world!</p>
</>
)
}
他のファイルやコンポーネントからは、jsxやtsxで定義したコンポーネント同様にimportして利用できます。
ルーティングの設定
React Router v7 では、routes.ts
にルーティング定義を行うアプローチをデフォルトで採用しています。そこから参照している各ルートファイルをRoute Moduleと呼びます。
Remixと同様 File Based Routing も可能ですが、Architecture Decision Recordによれば、React Router v7では、以下の理由からRoute Moduleがデフォルトです。
- ファイルやディレクトリ構造は好みが分かれる
- ファイル名の規約は制約が大きく、ファイルベースは高度なルーティングがやりづらい
- 設定ベースの柔軟なルーティングのほうが、React Routerの当初の哲学に沿う
Route Moduleを利用して、.mdxで作成したコンテンツが表示されるようにしていきましょう。
コンテンツの用意
app/routes
以下にmdxを用意していきます。ここでは、app/routes/hello.mdx
とします。
# Hello
Hello world!
app/routes.ts
の設定
/hello にアクセスすると、app/routes/hello.mdx
のコンテンツが画面に表示されるようにしてみましょう。
export default [
index('routes/home.tsx'),
+ route('routes/hello.mdx', '/hello'),
] satisfies RouteConfig;
(Optional) 型エラーへの対応
React Router v7 では型安全性を高めるために、routes.ts
に定義したルート定義から各ルート定義の型ファイルを生成できます。
npm run typecheck
こうすると、.react-routerディレクトリ内に各ルートの型を定義した、.tsファイルが生成されます。これを利用することで、例えば以下のように、コンポーネント内でloader
が返した値を型安全に利用することができます。
https://reactrouter.com/explanation/type-safety より引用:
import type { Route } from "./+types/product";
// types generated for this route 👆
export function loader({ params }: Route.LoaderArgs) {
// 👆 { id: string }
return { planet: `world #${params.id}` };
}
export default function Component({
loaderData, // 👈 { planet: string }
}: Route.ComponentProps) {
return <h1>Hello, {loaderData.planet}!</h1>;
}
ただし、本記事執筆時の2024/11/30時点(ReactRouter@7.0.1)では、.mdxで定義したルートから生成されたファイルでエラーが起きてしまいます。既にissueとして上がっており、対応が検討されています。
パッチを当てる
.mdxファイルのルートで本機能を利用することは考えにくく、実際に動作にあたって害はないのですが、エラーが出るのは気持ち悪いですよね。本件修正されるまで、.mdx(.md)ファイルをルートとする場合は型ファイルを生成しないようにパッチを当ててみましょう。
diff --git a/node_modules/@react-router/dev/dist/cli/index.js b/node_modules/@react-router/dev/dist/cli/index.js
index 266c57f..0300f63 100644
--- a/node_modules/@react-router/dev/dist/cli/index.js
+++ b/node_modules/@react-router/dev/dist/cli/index.js
@@ -744,6 +744,12 @@ async function writeAll(ctx) {
const typegenDir = getTypesDir(ctx);
import_node_fs2.default.rmSync(typegenDir, { recursive: true, force: true });
Object.values(ctx.config.routes).forEach((route) => {
+
+ if([".md", ".mdx"].includes(Path2.extname(route.file))){
+ console.warn(`[patch-package] Skipping type generation for "${route.file}" until the issue is resolved. For more details, please refer to https://github.com/remix-run/react-router/issues/12362.`)
+ return;
+ }
+
const typesPath = getTypesPath(ctx, route);
const content = generate(ctx, route);
import_node_fs2.default.mkdirSync(Path4.dirname(typesPath), { recursive: true });
patch-package を利用して、npm install
時にパッチがあたるようにしておくと便利です。
動作確認
開発時
npm run dev
hello.mdx
を修正すると、変更内容が画面にも反映されて、HMRが有効になっていることが確認できます。
ビルドと起動
npm run build
npm run start
npm run build
のタイミングで、prerenderが走って、build/client/hello.html
が生成されていることが確認できると思います。
Tips
ここからはより機能を充実させていきましょう。
コンポーネントのカスタマイズ
.mdxのコンテンツはReactのコンポーネントに変換されます。デフォルトではMDXの公式ガイドにある通り、
-
# 見出し
-><h1>見出し</h1>
-
[リンク](/hello)
-><a href="/hello">リンク</a>
のようにHTMLのエレメントに対応するコンポーネントになりますが、独自のコンポーネントに変換したい場合があると思います。
例えば、React Routerはリンクのために、Link
コンポーネントを提供しているので、
-
[リンク](/hello)
-><Link to="/hello">リンク</Link>
のようにしてみましょう。
まず、@mdx-js/react
をインストールして、設定します。
npm install `@mdx-js/react`
export default defineConfig({
plugins: [
{enforce: "pre", ...mdx({
+ providerImportSource: '@mdx-js/react',
})},
reactRouter(),
tsconfigPaths()],
});
次に、カスタマイズするコンポーネントを定義します。今回は、アンカーリンクの<a>
を<Link>
にしてみます。
import { ComponentProps, forwardRef } from 'react';
import { Link } from 'react-router';
const Anchor = forwardRef<HTMLAnchorElement, ComponentProps<'a'>>(
function ReactRouterLink({ href = '', ...props }, forwardedRef) {
return <Link {...props} to={href} ref={forwardedRef} />;
},
);
Anchor.displayName = 'Anchor';
export const components = {
a: Anchor,
}
そして、ここでexportしたオブジェクトを、以下のように <MDXProvider>
で渡してあげます。
import { MDXProvider } from '@mdx-js/react';
+ import { components } from '~/components/mdx-components';
//(略)
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning={true}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
+ <MDXProvider components={components}>
{children}
+ </MDXProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
これだけでOKです。便利ですね。
スタイリング
基本機能で生成されたHTMLは味気が無いので、スタイルを当てたいですね。
コンポーネントのカスタマイズ を利用して、classを当てたコンポーネントを用意すれば実現できます。例えばtailwindを利用している場合は、
function MyHeading1(props: React.ComponentProps<"h1">) {
return <h1 {...props} className="font-bold text-4xl" />
}
export const components = {
h1: MyHeading1,
//...他のコンポーネントも同様
}
このような塩梅です。これでなんでもできますが、Markdownで表現できるコンポーネント全てカスタムコンポーネントを用意するのは骨が折れます。
「お手軽にいい感じ」にスタイルを当てたい場合は、HTMLの要素に対して程よくスタイルを当ててくれるCSSフレームワークで、Simple.cssやMVP.cssを使うと良いと思います。Tailwindでは、Tailwind Typography があります。
.mdxファイルの追加とroutes.ts
への反映
.mdxを追加するたびに、routes.ts
を更新するのは面倒なので、.mdxファイルのファイル、ディレクトリ構造から、routes.ts
のルーティング定義を作成できるようにしましょう。
React Router v7 のベースとなる vite ではこういうケースでimport.meta.glob
が便利です。
例えば、以下のようにすれば、routes/docs/ 配下の.md、.mdxファイルをRuote Moduleとして登録してくれます。
import type { RouteConfig } from '@react-router/dev/routes';
import { index, route } from '@react-router/dev/routes';
const docsRoutes = Object.keys(
import.meta.glob('./routes/docs/**/*.{md,mdx}', {
query: '?url',
eager: true,
}),
)
.map((file) => {
return {
path: file
.replace('./routes/docs/', 'docs/')
.replace(/\.(mdx|md)$/, ''),
file,
};
})
.map(({ path, file }) => route(path, file));
export default [
index('routes/home.tsx'),
...docsRoutes,
] satisfies RouteConfig;
Frontmatterの利用
.mdxで作成した記事にメタ情報を付与できるようにしましょう。Frontmatterとは、
---
title: This is my first article
date: 2024/11/29
---
# Hello
Hello world
の---
~---
で囲んである部分です。YAML等で任意の情報を定義できます。
remark-frontmatter、remark-mdx-frontmatterを利用すれば、簡単にFrontmatterを扱うことができます。
npm install remark-frontmatter remark-mdx-frontmatter
export default defineConfig({
plugins: [
{enforce: "pre", ...mdx({
providerImportSource: '@mdx-js/react',
+ remarkPlugins: [
+ [remarkFrontmatter],
+ [remarkMdxFrontmatter],
+ ],
})},
reactRouter(),
tsconfigPaths()],
});
こうすると、先の.mdxは以下のように変換されます。
export frontmatter = {
title: 'This is my first article',
date: '2024/11/29'
}
export default function MDXContent() {
return (
<>
<h1>Hello</h1>
<p>Hello world!</p>
</>
)
}
したがって、mdxファイルをインポートすれば、好きにFrontmatterを扱うことができます。
const { frontmatter } = await import('./routes/hello.mdx')
console.log(frontmatter.title);
Route Module と Frontmatter の統合
Route Moduleで解説されている通り、各ルートのファイルには規約があります。
例えば、meta
という名前の関数をexport
すると<head>
の中の<meta>
タグとしてレンダリングしてくれる機能があります。
そうなると、例えば、
---
title: Hello World
description: This is the article about Hello World.
---
This is my first article.
と書いた記事のページは最終的に、
<head>
<title>Hello World</title>
<meta name="description" content="This is the article about Hello World.">
<meta property="og:title" content="HelloWorld">
<meta property="og:description" content="This is the article about Hello World.">
...
</head>
のようになると嬉しいですね。
これを実現するには、.mdxをコンパイルする際に、以下のように変換するようにすればよいわけです。
export meta = () => {
return [
{ title: "Hello World" },
{
name: "description",
content: "This is the article about Hello World.",
},
{
name: "og:title",
content: "Hello World",
},
{
name: "og:description",
content: "This is the article about Hello World.",
},
];
}
export default function MDXContent() {
return (
<>
<p>This is my first article.</p>
</>
)
}
この変換は、Remarkプラグインの出番です。Frontmatterの利用でもご紹介した、remark-mdx-frontmatter を参考に以下の通り実装してみます。
Remarkが.mdxの内容をAST(抽象構文木)に変換してくれるので、Frontmatterの内容をRoute Moduleの規約に合わせてexport function meta(){...}
で返すように、ASTをいじります。
ちょっと長いですが、
import { valueToEstree } from 'estree-util-value-to-estree';
import { type Literal, type Root } from 'mdast';
import { type Plugin } from 'unified';
import { parse as parseYaml } from 'yaml';
type RemarkReactRouerFrontmatterOptions = {
meta: (frontmatter: unknown) => Record<string, string>[];
};
const remarkReactRouerFrontmatter: Plugin<
[RemarkReactRouerFrontmatterOptions],
Root
> = ({ meta }) => {
const allParsers: Record<string, (value: string) => unknown> = {
yaml: parseYaml,
};
return (ast) => {
let data: unknown;
const node = ast.children.find((child) =>
Object.hasOwn(allParsers, child.type),
);
if (node) {
const parser = allParsers[node.type];
const { value } = node as Literal;
data = parser(value);
}
const metaData = data ? meta(data) : undefined;
if (metaData) {
ast.children.unshift({
type: 'mdxjsEsm',
value: '',
data: {
estree: {
type: 'Program',
sourceType: 'module',
body: [
{
type: 'ExportNamedDeclaration',
specifiers: [],
declaration: {
type: 'VariableDeclaration',
kind: 'const',
declarations: [
{
type: 'VariableDeclarator',
id: { type: 'Identifier', name: 'meta' },
init: {
type: 'ArrowFunctionExpression',
expression: false,
generator: false,
async: false,
params: [],
body: {
type: 'BlockStatement',
body: [
{
type: 'ReturnStatement',
argument: valueToEstree(metaData, {
preserveReferences: true,
}),
},
],
},
},
},
],
},
},
],
},
},
});
}
};
};
export default remarkReactRouerFrontmatter;
使うときは、@mdx-js/rollup
プラグインに設定します。
import remarkReactRouerFrontmatter from './scripts/remark-react-router-frontmatter';
export default defineConfig({
plugins: [
{
enforce: 'pre',
...mdx({
providerImportSource: '@mdx-js/react',
remarkPlugins: [
[remarkFrontmatter],
+ [
+ remarkReactRouerFrontmatter,
+ {
+ meta: (frontmatter: Record<string, string>) => {
+ return [
+ { title: `${frontmatter.title} | MySite` },
+ {
+ name: 'description',
+ content: frontmatter.description,
+ },
+ {
+ property: 'og:title',
+ content: `${frontmatter.title} | MySite`,
+ },
+ ];
+ },
+ },
+ ],
],
}),
},
reactRouter(),
tsconfigPaths(),
],
});
これにより、各記事のfrontmatterのプロパティをmetaタグで設定することが可能になりました。
同様にして、React Routerのhandle
にFrontmatterの内容を設定しておくと、useMatches
で各記事のメタ情報が取得できるので便利です。
また、ASTをどういじれば良いかは、こちらのプレイグランドがとても役に立ちます。
シンタックスハイライト
シンタックスハイライターを導入しましょう。shikiを使ってみます。
Rehypeプラグインがあるので設定しましょう。
import rehypeShiki from '@shikijs/rehype';
export default defineConfig({
plugins: [
{
enforce: 'pre',
...mdx({
providerImportSource: '@mdx-js/react',
remarkPlugins: [
//略
],
+ rehypePlugins: [
+ [
+ rehypeShiki,
+ {
+ themes: {
+ light: 'github-light-default',
+ dark: 'github-dark-default',
+ },
+ },
+ ],
+ ],
}),
},
reactRouter(),
tsconfigPaths(),
],
ドキュメント一覧のリンク作成
記事の一覧からメニューを作成したいですよね。
作成した.mdxのURLとそのタイトルが取得できれば、リンク一覧が作れます。
まず思いつくのが、viteのimport.meta.glab
を利用する方法。.mdxのパス、.mdxのモジュール(つまり、Frontmatterで定義したタイトル)が取得できるので、.mdxのパスからURLを組み立てればリンク一覧を作ることができます。
もっと楽なやり方は、React Routerが公開している仮想モジュールを利用する方法です。
仮想モジュールは、ビルド時の情報をランタイム時に利用できる機構です。仮想モジュールのvirtual:react-router/server-build
から、ルーティング定義の情報が以下の通り取得できるので、この情報を利用します。
const {routes} = await import('virtual:react-router/server-build');
{
"root": {
"id": "root",
"path": "",
"module": {}
},
"routes/home": {
"id": "routes/home",
"parentId": "root",
"index": true,
"module": {}
},
"routes/docs/00-hello": {
"id": "routes/docs/hello",
"path": "docs/hello",
"module": {
"handle": {
"title": "Hello World",
"description": "This is the article about Hello World."
}
}
}
}
先ほど少し言及しましたが、handle
にタイトル(frontmatterの情報)を入れておくと、記事一覧のリンクに必要なURL(上の例でいうpath
)とタイトル(上の例でいうmodule.handle.title
)が一発で取得できるようになります。
なお、virtual:react-router/server-build
にはサーバサイドのみアクセスできるため、loader
等サーバで実行されるコードからアクセスしてください。
おわりに
この記事では、最近リリースされたReact Router v7 の機能を簡単に紹介しつつ、シンプルなドキュメントサイトジェネレータを作ってみました。
今後は、React Router v7 を実践で活用してノウハウをためていくとともに、どう進化していくか引き続きウォッチしていきたいです。
個人的には、Tanstack Routerのような型安全性のさらなる向上(例えば、Link
のto
やSerchParmsに型補完が効く)、LoaderによるRSCのサポート、SPAモード + prerenderでの Loaderのサポート、あたりができると嬉しいなと思っています。
そして最後になにより、、、
今年も NRI によるアドベントカレンダーを是非ともよろしくお願いします!
- NRI OpenStandia Advent Calendar 2024 (シリーズ 1〜3)
- NRI OpenStandia (IAM編) Advent Calendar 2024
- 一歩ずつRustに慣れていくTypeScriptエンジニアの記録 Advent Calendar 2024 @k4nd4 の個人カレンダー
-
DocusaurusやVitePressなど、ドキュメント作成にフォーカスした静的サイトジェネレータを指しています ↩