typedRoutesで静的に型付けされたリンクを実現
typedRoutes
とは
typedRoutes
を導入することで静的に型付けされたリンクを実現出来ます。2024 年 4 月 25 日現在、実験的機能として Next.js が提供しています。
Alex Sidorenko 氏の 14 秒の動画で typedRoutes
の動作を確認できます。
設定方法は簡単で、next.config.mjs
に experimental.typedRoutes
を設定するだけです。
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
typedRoutes: true,
},
};
export default nextConfig;
たとえば app
フォルダー内で存在しない /hoge/12345
というパスへのリンクがあった場合、ビルド時にエラーが出ます。
$ pnpm run build
▲ Next.js 14.2.3
- Experiments (use with caution):
· typedRoutes
Creating an optimized production build ...
✓ Compiled successfully
Linting and checking validity of types ...Failed to compile.
./src/app/page.tsx:24:27
Type error: "/hoge/12345" is not an existing route. If it is intentional, please type it explicitly with `as Route`.
22 | <div className="flex flex-row">
23 | <h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
> 24 | <Link href={"/hoge/12345"}>
| ^
25 | http://localhost:3000/user/12345
26 | </Link>
27 | </h2>
同様に tsc
で型チェックするとエラーが出ます。
$ pnpm typecheck
src/app/page.tsx:24:27 - error TS2322: Type '"/hoge/12345"' is not assignable to type 'UrlObject | RouteImpl<"/hoge/12345">'.
24 <Link href={"/hoge/12345"}>
~~~~
.next/types/link.d.ts:78:5
78 href: __next_route_internal_types__.RouteImpl<RouteInferType> | UrlObject
~~~~
The expected type comes from property 'href' which is declared here on type 'IntrinsicAttributes & LinkRestProps & { href: UrlObject | RouteImpl<"/hoge/12345">; }'
Found 1 error in src/app/page.tsx:24
エディター上でもエラーが表示されます。
typedRoutes
と同様に静的型付けされたリンクを実現するパッケージにdeclarative-routing
があります。こちらの記事で利用方法について解説しています。
declarative-routing
と比較すると、typedRoutes
と declarative-routing
はどちらも URL 誤りを検出できます。typedRoutes
は Next.js の公式機能であるため、導入が容易です。declarative-routing
と比較した際に、URL 引数とパスパラメータの型チェックできない欠点があります。
項目 | declarative-routing |
typedRoutes |
---|---|---|
導入の難易度 | ◯ | ◎ |
URL誤り | ◯ | ◯ |
URL引数の型チェック | ○ | × |
パスパラメータの型チェック | ○ | × |
メンテナンスの難易度 | ◯ | ◎ |
この記事では、typedRoutes
を使って Next.js で静的に型付けされたリンクを実現する方法を紹介します。
Next.jsプロジェクトの作成
動作を作業するための Next.js プロジェクトを作成します。長いので、折り畳んでおきます。
新規プロジェクト作成と初期環境構築の手順詳細
プロジェクトを作成
create next-app@latest
でプロジェクトを作成します。
$ pnpm create next-app@latest next-typedroutes --typescript --eslint --import-alias "@/*" --src-dir --use-pnpm --tailwind --app
$ cd next-typedroutes
Peer Dependenciesの警告を解消
Peer dependenciesの警告が出ている場合は、pnpm install
を実行し、警告を解消します。
WARN Issues with peer dependencies found
.
├─┬ eslint-config-next 14.2.1
│ └─┬ @typescript-eslint/parser 7.2.0
│ └── ✕ unmet peer eslint@^8.56.0: found 8.0.0
└─┬ next 14.2.1
├── ✕ unmet peer react@^18.2.0: found 18.0.0
└── ✕ unmet peer react-dom@^18.2.0: found 18.0.0
以下を実行することで警告が解消されます。
$ pnpm i -D eslint@^8.56.0 react@^18.2.0 react-dom@^18.2.0
不要な設定を削除し、プロジェクトを初期化します。
styles
CSSなどを管理するstylesディレクトリを作成します。globals.css
を移動します。
$ mkdir -p src/styles
$ mv src/app/globals.css src/styles/globals.css
globals.css
の内容を以下のように上書きします。
@tailwind base;
@tailwind components;
@tailwind utilities;
初期ページ
app/page.tsx
を上書きします。
import { type FC } from "react";
const Page: FC = () => {
return (
<div className="">
<div className="text-lg font-bold">Home</div>
<div>
<span className="text-blue-500">Hello</span>
<span className="text-red-500">World</span>
</div>
</div>
);
};
export default Page;
レイアウト
app/layout.tsx
を上書きします。
import "@/styles/globals.css";
import { type FC } from "react";
type RootLayoutProps = {
children: React.ReactNode;
};
export const metadata = {
title: "Sample",
description: "Generated by create next app",
};
const RootLayout: FC<RootLayoutProps> = (props) => {
return (
<html lang="ja">
<body className="">{props.children}</body>
</html>
);
};
export default RootLayout;
TailwindCSSの設定
TailwindCSSの設定を上書きします。
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
plugins: [],
}
export default config
TypeScriptの設定
TypeScriptの設定を上書きします。
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl" : ".",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
スクリプトを追加
型チェックのスクリプトを追加します。
{
"scripts": {
+ "typecheck": "tsc"
},
}
動作確認
ローカルで動作確認します。
$ pnpm run dev
コミットして作業結果を保存しておきます。
$ git add .
$ git commit -m "feat:新規にプロジェクトを作成し, 作業環境を構築"
ページ作成
検索クエリ、パスパラメータを含むページを作成します。以下のようなページ構成となります。
middlewareを作成
サーバーコンポーネントで URL を表示するために middleware
を利用しリクエストヘッダーを追加します。middleware.ts
を作成します。リクエストのヘッダーに x-url
を追加します。x-url にはリクエストの URL が設定されます。
$ touch src/middleware.ts
import { NextResponse } from 'next/server';
export function middleware(request: Request) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-url', request.url);
return NextResponse.next({
request: {
headers: requestHeaders
}
});
}
params
を含むページを作成
params
を通して、Dynamic Route の Dynamic Segment の値を取得できます。Dynamic Route とは、/product/[slug]
のように、URL の一部が動的に変わるページのことです。Dynamic Segment とは、[slug]
のように、[]
で囲まれた部分のことです。
src パス | URL | params |
---|---|---|
src/app/user/[slug]/page.tsx |
/user/12345 |
{ slug: "12345" } |
src/app/student/[...slug]/page.tsx |
/student/grade9/12345 |
{ slug: ["student", "12345"] } |
src/app/product/[category]/[item]/page.tsx |
/product/dvd/anime |
{ category: "dvd", item: "anime" } |
Dynamic Route のページを作成
コードを作成します。
$ mkdir -p src/app/user/\[slug\] \
src/app/student/\[...slug\] \
src/app/product/\[category\]/\[item\]
$ touch src/app/user/\[slug\]/page.tsx \
src/app/student/\[...slug\]/page.tsx \
src/app/product/\[category\]/\[item\]/page.tsx
import Link from "next/link";
import { FC } from "react";
import { headers } from "next/headers";
type Props = {
params: {
slug: string;
};
};
const Page: FC<Props> = (props) => {
const requestUrl = headers().get("x-url");
return (
<>
<div className="p-5 bg-blue-100">
<header className="p-5 bg-red-100 flex flex-row justify-between">
<h1 className="text-lg font-bold self-center">{requestUrl}</h1>
<div className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/"}>戻る</Link>
</div>
</header>
<main className="p-5 bg-green-100 flex flex-col space-y-5">
<section className="flex flex-col space-y-3 bg-yellow-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 bg-blue-100 rounded-md">
props
</h2>
</div>
<div className="flex flex-row">
<pre className="w-full text-sm text-white py-2 px-3 bg-slate-800 rounded-md">
{JSON.stringify(props)}
</pre>
</div>
</section>
<section className="flex flex-col space-y-3 bg-yellow-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 bg-blue-100 rounded-md">
props.params
</h2>
</div>
<div className="flex flex-row">
<pre className="w-full text-sm text-white py-2 px-3 bg-slate-800 rounded-md">
{JSON.stringify(props.params)}
</pre>
</div>
</section>
</main>
</div>
</>
);
};
export default Page;
import Link from "next/link";
import { FC } from "react";
import { headers } from "next/headers";
type Props = {
params: {
slug: string;
};
};
const Page: FC<Props> = (props) => {
const requestUrl = headers().get("x-url");
return (
<>
<div className="p-5 bg-blue-100">
<header className="p-5 bg-red-100 flex flex-row justify-between">
<h1 className="text-lg font-bold self-center">{requestUrl}</h1>
<div className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/"}>戻る</Link>
</div>
</header>
<main className="p-5 bg-green-100 flex flex-col space-y-5">
<section className="flex flex-col space-y-3 bg-yellow-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 bg-blue-100 rounded-md">
props
</h2>
</div>
<div className="flex flex-row">
<pre className="w-full text-sm text-white py-2 px-3 bg-slate-800 rounded-md">
{JSON.stringify(props)}
</pre>
</div>
</section>
<section className="flex flex-col space-y-3 bg-yellow-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 bg-blue-100 rounded-md">
props.params
</h2>
</div>
<div className="flex flex-row">
<pre className="w-full text-sm text-white py-2 px-3 bg-slate-800 rounded-md">
{JSON.stringify(props.params)}
</pre>
</div>
</section>
</main>
</div>
</>
);
};
export default Page;
import Link from "next/link";
import { FC } from "react";
import { headers } from "next/headers";
type Props = {
params: {
category: string;
item: string;
};
};
const Page: FC<Props> = (props) => {
const requestUrl = headers().get("x-url");
return (
<>
<div className="p-5 bg-blue-100">
<header className="p-5 bg-red-100 flex flex-row justify-between">
<h1 className="text-lg font-bold self-center">{requestUrl}</h1>
<div className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/"}>戻る</Link>
</div>
</header>
<main className="p-5 bg-green-100 flex flex-col space-y-5">
<section className="flex flex-col space-y-3 bg-yellow-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 bg-blue-100 rounded-md">
props
</h2>
</div>
<div className="flex flex-row">
<pre className="w-full text-sm text-white py-2 px-3 bg-slate-800 rounded-md">
{JSON.stringify(props)}
</pre>
</div>
</section>
<section className="flex flex-col space-y-3 bg-yellow-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 bg-blue-100 rounded-md">
props.params
</h2>
</div>
<div className="flex flex-row">
<pre className="w-full text-sm text-white py-2 px-3 bg-slate-800 rounded-md">
{JSON.stringify(props.params)}
</pre>
</div>
</section>
<section className="flex flex-col space-y-3 bg-yellow-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 bg-blue-100 rounded-md">
props.params.category
</h2>
</div>
<div className="flex flex-row">
<pre className="w-full text-sm text-white py-2 px-3 bg-slate-800 rounded-md">
{JSON.stringify(props.params.category)}
</pre>
</div>
</section>
<section className="flex flex-col space-y-3 bg-yellow-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 bg-blue-100 rounded-md">
props.params.item
</h2>
</div>
<div className="flex flex-row">
<pre className="w-full text-sm text-white py-2 px-3 bg-slate-800 rounded-md">
{JSON.stringify(props.params.item)}
</pre>
</div>
</section>
</main>
</div>
</>
);
};
export default Page;
動作確認します。
http://localhost:3000/user/12345 を動作確認します。
http://localhost:3000/student/grade9/12345 を動作確認します。
http://localhost:3000/product/dvd/anime を確認します。
コミットします。
$ git add .
$ git commit -m "feat:Dynamic RouteのDynamic Segmentの値を取得"
searchParams
を含むページを作成
searchParams
を通して、引数を取得できます。引数とは、URL クエリパラメータのことです。URL クエリパラメータとは、?
以降のパラメータのことです。例えば ?q=test
のように q
がパラメータ名で test
が値です。
srcパス | URL | searchParams |
---|---|---|
src/app/store/page.tsx |
/store?a=1 |
{ a: '1' } |
src/app/store/page.tsx |
/store?a=1&b=2 |
{ a: '1', b: '2' } |
引数を含むページを作成
コードを作成します。
$ mkdir -p src/app/store
$ touch src/app/store/page.tsx
import Link from "next/link";
import { type FC } from "react";
import { headers } from "next/headers";
type Props = {
searchParams: { [key: string]: string | string[] | undefined };
};
const Page: FC<Props> = (props) => {
const requestUrl = headers().get("x-url");
return (
<>
<div className="p-5 bg-blue-100">
<header className="p-5 bg-red-100 flex flex-row justify-between">
<h1 className="text-lg font-bold self-center">{requestUrl}</h1>
<div className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/"}>戻る</Link>
</div>
</header>
<main className="p-5 bg-green-100 flex flex-col space-y-5">
<section className="flex flex-col space-y-3 bg-yellow-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 bg-blue-100 rounded-md">
props
</h2>
</div>
<div className="flex flex-row">
<pre className="w-full text-sm text-white py-2 px-3 bg-slate-800 rounded-md">
{JSON.stringify(props)}
</pre>
</div>
</section>
<section className="flex flex-col space-y-3 bg-yellow-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 bg-blue-100 rounded-md">
props.searchParams
</h2>
</div>
<div className="flex flex-row">
<pre className="w-full text-sm text-white py-2 px-3 bg-slate-800 rounded-md">
{JSON.stringify(props.searchParams)}
</pre>
</div>
</section>
</main>
</div>
</>
);
};
export default Page;
動作確認します。
http://localhost:3000/store?a=1 を動作確認します。
http://localhost:3000/store?a=1&b=2 を動作確認します。
コミットします。
$ git add .
$ git commit -m "feat:URLクエリパラメータを取得"
リンクを含むページを作成
page.tsx
を修正します。各ページへのリンクを追加します。
リンクを追加したページを作成
import Link from "next/link";
import { FC } from "react";
type Props = {};
const Page: FC<Props> = (props) => {
return (
<>
<div className="p-5">
<div className="p-5 bg-blue-100">
<header className="p-5 bg-red-100 flex flex-col space-y-2">
<h1 className="text-lg font-bold">ホーム</h1>
</header>
<main className="p-5 bg-green-100 flex flex-col space-y-5">
<div className="p-5 bg-yellow-100 flex flex-col space-y-5">
<div className="flex flex-row">
<h2 className="text-lg py-2 px-3 font-bold">
Dynamic Route のページ
</h2>
</div>
<div className="flex flex-col space-y-3 bg-blue-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/user/12345"}>
http://localhost:3000/user/12345
</Link>
</h2>
</div>
</div>
<div className="flex flex-col space-y-3 bg-blue-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/user/student/12345"}>
http://localhost:3000/user/student/12345
</Link>
</h2>
</div>
</div>
<div className="flex flex-col space-y-3 bg-blue-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/product/dvd/anime"}>
http://localhost:3000/product/dvd/anime
</Link>
</h2>
</div>
</div>
</div>
<div className="p-5 bg-yellow-100 flex flex-col space-y-5">
<div className="flex flex-row">
<h2 className="text-lg py-2 px-3 font-bold">
引数を含むページ
</h2>
</div>
<div className="flex flex-col space-y-3 bg-blue-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/store?a=1"}>
http://localhost:3000/store?a=1
</Link>
</h2>
</div>
</div>
<div className="flex flex-col space-y-3 bg-blue-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/store?a=1&b=2"}>
http://localhost:3000/store?a=1&b=2
</Link>
</h2>
</div>
</div>
</div>
</main>
</div>
</div>
</>
);
};
export default Page;
http://localhost:3000/ を動作確認します。
コミットします。
$ git add .
$ git commit -m "feat:リンクを追加"
typedRoutes
を設定
typedRoutes
を設定します。next.config.mjs
に typedRoutes
を設定します。
/** @type {import('next').NextConfig} */
const nextConfig = {
+ experimental: {
+ typedRoutes: true,
+ },
};
export default nextConfig;
これで、静的に型付けされたリンクを実現出来ます。
コミットします。
$ git add .
$ git commit -m "feat:typedRoutesを設定"
動作確認
動作確認のため存在しない URL を指定します。
import Link from "next/link";
import { FC } from "react";
type Props = {};
const Page: FC<Props> = (props) => {
return (
<>
<div className="p-5">
<div className="p-5 bg-blue-100">
<header className="p-5 bg-red-100 flex flex-col space-y-2">
<h1 className="text-lg font-bold">ホーム</h1>
</header>
<main className="p-5 bg-green-100 flex flex-col space-y-5">
<div className="p-5 bg-yellow-100 flex flex-col space-y-5">
<div className="flex flex-row">
<h2 className="text-lg py-2 px-3 font-bold">
Dynamic Route のページ
</h2>
</div>
<div className="flex flex-col space-y-3 bg-blue-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
- <Link href={"/user/12345"}>
+ <Link href={"/hoge/12345"}>
http://localhost:3000/user/12345
</Link>
</h2>
</div>
</div>
<div className="flex flex-col space-y-3 bg-blue-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/student/grade9/12345"}>
http://localhost:3000/student/grade9/12345
</Link>
</h2>
</div>
</div>
<div className="flex flex-col space-y-3 bg-blue-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/product/dvd/anime"}>
http://localhost:3000/product/dvd/anime
</Link>
</h2>
</div>
</div>
</div>
<div className="p-5 bg-yellow-100 flex flex-col space-y-5">
<div className="flex flex-row">
<h2 className="text-lg py-2 px-3 font-bold">
引数を含むページ
</h2>
</div>
<div className="flex flex-col space-y-3 bg-blue-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/store?a=1"}>
http://localhost:3000/store?a=1
</Link>
</h2>
</div>
</div>
<div className="flex flex-col space-y-3 bg-blue-100 p-5">
<div className="flex flex-row">
<h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
<Link href={"/store?a=1&b=2"}>
http://localhost:3000/store?a=1&b=2
</Link>
</h2>
</div>
</div>
</div>
</main>
</div>
</div>
</>
);
};
export default Page;
ビルドすると Lint でエラーが出るようになります。
$ pnpm run build
▲ Next.js 14.2.3
- Experiments (use with caution):
· typedRoutes
Creating an optimized production build ...
✓ Compiled successfully
Linting and checking validity of types ...Failed to compile.
./src/app/page.tsx:24:27
Type error: "/hoge/12345" is not an existing route. If it is intentional, please type it explicitly with `as Route`.
22 | <div className="flex flex-row">
23 | <h2 className="text-sm text-blue-800 py-2 px-3 rounded-md underline underline-offset-2 hover:text-blue-600">
> 24 | <Link href={"/hoge/12345"}>
| ^
25 | http://localhost:3000/user/12345
26 | </Link>
27 | </h2>
同様に tsc
で型チェックするとエラーが出ます。
$ pnpm typecheck
src/app/page.tsx:24:27 - error TS2322: Type '"/hoge/12345"' is not assignable to type 'UrlObject | RouteImpl<"/hoge/12345">'.
24 <Link href={"/hoge/12345"}>
~~~~
.next/types/link.d.ts:78:5
78 href: __next_route_internal_types__.RouteImpl<RouteInferType> | UrlObject
~~~~
The expected type comes from property 'href' which is declared here on type 'IntrinsicAttributes & LinkRestProps & { href: UrlObject | RouteImpl<"/hoge/12345">; }'
Found 1 error in src/app/page.tsx:24
エディター上でもエラーが表示されます。
コミットします。
$ git add .
$ git commit -m "feat:存在しないURLを指定"
比較
declarative-routing
と比較します。typedRoutes
と declarative-routing
はどちらも URL 誤りを検出できます。typedRoutes
は Next.js の公式機能であるため、導入が容易です。declarative-routing
と比較した際に、URL 引数とパスパラメータの型チェックできない欠点があります。
項目 | declarative-routing |
typedRoutes |
---|---|---|
導入の難易度 | ◯ | ◎ |
URL誤り | ◯ | ◯ |
URL引数の型チェック | ○ | × |
パスパラメータの型チェック | ○ | × |
メンテナンスの難易度 | ◯ | ◎ |
まとめ
typedRoutes
を導入することで、静的に型付けされたリンクを実現できました。
作業リポジトリはこちらです。
Discussion