🐙

Declarative Routes で型安全なルーティングを実現

2024/04/25に公開
2

declarative-routing とは

declarative-routing を導入することで以下を実現できます。

  • React で型安全なルーティング
  • ルートが常にコードと同期している状態
  • 壊れたリンクや欠落しているルートを心配することがなくなる

Jack Herrington 氏が開発したパッケージです。

https://youtu.be/MiZcNHJT_Ss?si=sXya28QviEbJ2xpq

Declarative Routes を利用することで以下のようなコードが書けるようになります。

// Declarative Routes 導入前
<Link href={"/"}>戻る</Link>

  ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓

// Declarative Routes 導入後
<Home.Link>戻る</Home.Link>
// Declarative Routes 導入前
<Link href={"/product/dvd/anime"}>
  DVD/アニメに移動
</Link>

  ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓

// Declarative Routes 導入後
<ProductCategoryItem.Link category="dvd" item="anime">
  http://localhost:3000/product/dvd/anime
</ProductCategoryItem.Link>
// Declarative Routes 導入前
<Link href={"/store?q=12345"}>
  商品番号「12345」を検索
</Link>

  ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓

// Declarative Routes 導入後
<Store.Link search={{ q: "12345" }}>
  商品番号「12345」を検索
</Store.Link>

URL 引数やパスパラメーターの型チェックにはzodが利用されています。

https://zod.dev/


今後は zod 以外の Validation ライブラリーも利用できるようになるかもしれません。

https://github.com/ProNextJS/declarative-routing/issues/6

Next.jsプロジェクトの作成

動作を作業するための Next.js プロジェクトを作成します。長いので、折り畳んでおきます。

新規プロジェクト作成と初期環境構築の手順詳細

プロジェクトを作成

create next-app@latestでプロジェクトを作成します。

$ pnpm create next-app@latest next-declarative-routing --typescript --eslint --import-alias "@/*" --src-dir --use-pnpm --tailwind --app
$ cd next-declarative-routing

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の内容を以下のように上書きします。

src/styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

初期ページ

app/page.tsxを上書きします。

src/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を上書きします。

src/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の設定を上書きします。

tailwind.config.ts
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の設定を上書きします。

tsconfig.json
{
  "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"]
}

スクリプトを追加

型チェックのスクリプトを追加します。

package.json
{
  "scripts": {
+   "typecheck": "tsc"
  },
}

動作確認

ローカルで動作確認します。

$ pnpm run dev

コミットして作業結果を保存しておきます。

$ git add .
$ git commit -m "feat:新規にプロジェクトを作成し, 作業環境を構築"

ページ作成

検索クエリ、パスパラメータを含むページを作成します。以下のようなページ構成となります。

alt text

middlewareを作成

サーバーコンポーネントで URL を表示するために middleware を利用しリクエストヘッダーを追加します。middleware.ts を作成します。リクエストのヘッダーに x-url を追加します。x-url にはリクエストの URL が設定されます。

$ touch src/middleware.ts
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
src/app/user/\[slug\]/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;
src/app/student/\[...slug\]/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;
src/app/product/\[category\]/\[item\]/page.tsx
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 を動作確認します。

alt text

http://localhost:3000/student/grade9/12345 を動作確認します。

alt text

http://localhost:3000/product/dvd/anime を確認します。

alt text

コミットします。

$ 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
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 を動作確認します。

alt text

http://localhost:3000/store?a=1&b=2 を動作確認します。

alt text

コミットします。

$ git add .
$ git commit -m "feat:URLクエリパラメータを取得"

リンクを含むページを作成

page.tsx を修正します。各ページへのリンクを追加します。

リンクを追加したページを作成

src/app/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={"/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;

コミットします。

$ git add .
$ git commit -m "feat:リンクを追加"

Declarative Routesをインストール

Next.js に Declarative Routes をインストールします。公式ドキュメントはこちらです。

$ npx declarative-routing@latest init

Setting up declarative routes for Next.js
✔ What is your source directory? … ./src/app
✔ Where do you want the routes directory? … ./src/routes
✔ Add OpenAPI output? … yes
✔ Done.

Initialization completed successfully
✔ Added 5 .info files to your project.
✔ Added declarative-routing support files in ./src/routes.

Your next step is to read the README.md file in the routes directory and follow the post setup tasks.

インストールすると DR-README.md が作成されます。README には利用方法が記載されています。こちらが作成された README ですが英語です。README を ChatGPT で日本語化しました。

作成された英語のREADME
This application supports typesafe routing for NextJS using the `declarative-routing` system.

# What is `declarative-routing`?

Declarative Routes is a system for typesafe routing in React. It uses a combination of TypeScript and a custom routing system to ensure that your routes are always in sync with your code. You'll never have to worry about broken links or missing routes again.

In NextJS applications, Declarative Routes also handles API routes, so you'll have typesafe input and output from all of your APIs. In addition to `fetch` functions that are written for you automatically.

# Route List

Here are the routes of the application:

| Route | Verb | Route Name | Using It |
| ----- | ---- | ---- | ---- |
| `/` | - | `Home` | `<Home.Link>` |
| `/product/[category]/[item]` | - | `ProductCategoryItem` | `<ProductCategoryItem.Link>` |
| `/store` | - | `Store` | `<Store.Link>` |
| `/student/[...slug]` | - | `StudentSlug` | `<StudentSlug.Link>` |
| `/user/[slug]` | - | `UserSlug` | `<UserSlug.Link>` |

To use the routes, you can import them from `@/routes` and use them in your code.

# Using the routes in your application

For pages, use the `Link` component (built on top of `next/link`) to link to other pages. For example:

```tsx
import { ProductDetail } from "@/routes";

return (
  <ProductDetail.Link productId={"abc123"}>Product abc123</ProductDetail.Link>
);
```

This is the equivalent of doing `<Link href="/product/abc123">Product abc123</Link>` but with typesafety. And you never have to remember the URL. If the route moves, the typesafe route will be updated automatically.

For APIs, use the exported `fetch` wrapping functions. For example:

```tsx
import { useEffect } from "react";
import { getProductInfo } from "@/routes";

useEffect(() => {
  // Parameters are typed to the input of the API
  getProductInfo({ productId: "abc123" }).then((data) => {
    // Data is typed to the result of the API
    console.log(data);
  });
}, []);
```

This is the equivalent of doing `fetch('/api/product/abc123')` but with typesafety, and you never have to remember the URL. If the API moves, the typesafe route will be updated automatically.

## Using typed hooks

The system provides three typed hooks to use in your application `usePush`, `useParams`, and `useSearchParams`.

* `usePush` wraps the NextJS `useRouter` hook and returns a typed version of the `push` function.
* `useParams` wraps `useNextParams` and returns the typed parameters for the route.
* `useSearchParams` wraps `useNextSearchParams` and returns the typed search parameters for the route.

For each hook you give the route to get the appropriate data back.

```ts
import { Search } from "@/routes";
import { useSearchParams } from "@/routes/hooks";

export default MyClientComponent() {
  const searchParams = useSearchParams(Search);
  return <div>{searchParams.query}</div>;
}
```

We had to extract the hooks into a seperate module because NextJS would not allow the routes to include hooks directly if
they were used by React Server Components (RSCs).

# Configure declarative-routing

After running `npx declarative-routing init`, you don't need to configure anything to use it.
However, you may want to customize some options to change the behavior of route generation.

You can edit `declarative-routing.config.json` in the root of your project. The following options are available:

- `mode`: choose between `react-router`, `nextjs` or `qwikcity`. It is automatically picked on init based on the project type.
- `routes`: the directory where the routes are defined. It is picked from the initial wizard (and defaults to `./src/components/declarativeRoutes`).
- `importPathPrefix`: the path prefix to add to the import path of the self-generated route objects, in order to be able to resolve them. It defaults to `@/app`.

# When your routes change

You'll need to run `pnpm dr:build` to update the generated files. This will update the types and the `@/routes` module to reflect the changes.

The way the system works the `.info.ts` files are link to the `@/routes/index.ts` file. So changing the Zod schemas for the routes does **NOT** require a rebuild. You need to run the build command when:

- You change the name of the route in the `.info.ts` file
- You change the location of the route (e.g. `/product` to `/products`)
- You change the parameters of the route (e.g. `/product/[id]` to `/product/[productId]`)
- You add or remove routes
- You add or remove verbs from API routes (e.g. adding `POST` to an existing route)

You can also run the build command in watch mode using `pnpm dr:build:watch` but we don't recommend using that unless you are changing routes a lot. It's a neat party trick to change a route directory name and to watch the links automagically change with hot module reloading, but routes really don't change that much.

# Finishing your setup

Post setup there are some additional tasks that you need to complete to completely typesafe your routes. We've compiled a handy check list so you can keep track of your progress.

- [ ] `/page.info.ts`: Add search typing to if the page supports search paramaters
- [ ] Convert `Link` components for `/` to `<Home.Link>`
- [ ] `/product/[category]/[item]/page.info.ts`: Add search typing to if the page supports search paramaters
- [ ] Convert `Link` components for `/product/[category]/[item]` to `<ProductCategoryItem.Link>`
- [ ] Convert `params` typing in `/product/[category]/[item]/page.ts` to `z.infer<>`
- [ ] `/store/page.info.ts`: Add search typing to if the page supports search paramaters
- [ ] Convert `Link` components for `/store` to `<Store.Link>`
- [ ] `/student/[...slug]/page.info.ts`: Add search typing to if the page supports search paramaters
- [ ] Convert `Link` components for `/student/[...slug]` to `<StudentSlug.Link>`
- [ ] Convert `params` typing in `/student/[...slug]/page.ts` to `z.infer<>`
- [ ] `/user/[slug]/page.info.ts`: Add search typing to if the page supports search paramaters
- [ ] Convert `Link` components for `/user/[slug]` to `<UserSlug.Link>`
- [ ] Convert `params` typing in `/user/[slug]/page.ts` to `z.infer<>`
Once you've got that done you can remove this section.

# Why is `makeRoute` copied into the `@/routes` module?

You **own** this routing system once you install it. And we anticipate as part of that ownership you'll want to customize the routing system. That's why we create a `makeRoute.tsx` file in the `@/routes` module. This file is a copy of the `makeRoute.tsx` file from the `declarative-routing` package. You can modify this file to change the behavior of the routing system.

For example, you might want to change the way `GET`, `POST`, `PUT`, and `DELETE` are handled. Or you might want to change the way the `Link` component works. You can do all of that by modifying the `makeRoute.tsx` file.

We do **NOT** recommend changing the parameters of `makeRoute`, `makeGetRoute`, `makePostRoute`, `makePutRoute`, or `makeDeleteRoute` functions because that would cause incompatibility with the `build` command of `declarative-routing`.

# Credit where credit is due

This system is based on the work in [Fix Next.JS Routing To Have Full Type-Safety](https://www.flightcontrol.dev/blog/fix-nextjs-routing-to-have-full-type-safety). However the original article had a significantly different interface and didn't cover API routes at all.

日本語化したREADME
このアプリケーションは、`declarative-routing` システムを使用して NextJS でタイプセーフなルーティングをサポートします。

# `declarative-routing` とは何ですか?

宣言的ルーティングは、React でのタイプセーフなルーティングのためのシステムです。このシステムは TypeScript とカスタムルーティングシステムの組み合わせを使用して、ルートが常にコードと同期していることを保証します。これにより、リンク切れやルートの欠落の心配がなくなります。

NextJS アプリケーションでは、宣言的ルーティングは API ルートも処理するため、すべての API からタイプセーフな入出力が得られます。さらに、自動的に書かれる `fetch` 関数も提供されます。

# ルートリスト

以下はアプリケーションのルートです:

| ルート | 動詞 | ルート名 | 使用方法 |
| ----- | ---- | ---- | ---- |
| `/` | - | `Home` | `<Home.Link>` |
| `/product/[category]/[item]` | - | `ProductCategoryItem` | `<ProductCategoryItem.Link>` |
| `/store` | - | `Store` | `<Store.Link>` |
| `/student/[...slug]` | - | `StudentSlug` | `<StudentSlug.Link>` |
| `/user/[slug]` | - | `UserSlug` | `<UserSlug.Link>` |

ルートを使用するには、`@/routes` からそれらをインポートしてコード内で使用します。

# アプリケーションでのルートの使用方法

ページのためには、他のページへのリンクに `Link` コンポーネント(`next/link` の上に構築)を使用します。例えば:

```tsx
import { ProductDetail } from "@/routes";

return (
  <ProductDetail.Link productId={"abc123"}>Product abc123</ProductDetail.Link>
);
```

これは `<Link href="/product/abc123">Product abc123</Link>` と同じことを行いますが、タイプセーフであり、URLを覚える必要がありません。ルートが移動すると、タイプセーフルートは自動的に更新されます。

API の場合、エクスポートされた `fetch` ラッピング関数を使用します。例えば:

```tsx
import { useEffect } from "react";
import { getProductInfo } from "@/routes";

useEffect(() => {
  getProductInfo({ productId: "abc123" }).then((data) => {
    console.log(data);
  });
}, []);
```

これは `fetch('/api/product/abc123')` を行うことと同等ですが、タイプセーフであり、URLを覚える必要がありません。API が移動すると、タイプセーフルートは自動的に更新されます。

## タイプ付きフックの使用

システムはアプリケーションで使用するための3つのタイプ付きフックを提供します:`usePush`, `useParams`, `useSearchParams`* `usePush` は NextJS の `useRouter` フックをラップし、`push` 関数のタイプバージョンを返します。
* `useParams``useNextParams` をラップし、ルートのタイプパラメータを返します。
* `useSearchParams``useNextSearchParams` をラップし、ルートのタイプ検索パラメータを返します。

各フックは適切なデータを取得するためのルートを指定します。

```ts
import { Search } from "@/routes";
import { useSearchParams } from "@/routes/hooks";

export default MyClientComponent() {
  const searchParams = useSearchParams(Search);
  return <div>{

searchParams.query}</div>;
}
```

NextJS が React Server Components(RSCs)によって使用される場合、直接フックをルートに含めることを許可しなかったため、フックを別のモジュールに抽出する必要がありました。

# 宣言的ルーティングの設定

`npx declarative-routing init` を実行した後、それを使用するために特に設定する必要はありません。ただし、ルート生成の挙動を変更したい場合は、いくつかのオプションをカスタマイズしたいかもしれません。

プロジェクトのルートにある `declarative-routing.config.json` を編集することができます。利用可能なオプションは以下の通りです:

- `mode`: `react-router``nextjs`、または `qwikcity` の中から選択します。これはプロジェクトタイプに基づいて初期化時に自動的に選択されます。
- `routes`: ルートが定義されているディレクトリです。初期のウィザードから選ばれ、デフォルトでは `./src/components/declarativeRoutes` に設定されます。
- `importPathPrefix`: 自動生成されたルートオブジェクトのインポートパスに追加するパス接頭辞で、これにより解決が可能になります。デフォルトでは `@/app` に設定されています。

# ルートが変更されたとき

生成されたファイルを更新するために `pnpm dr:build` を実行する必要があります。これにより、タイプと `@/routes` モジュールが変更を反映して更新されます。

システムの動作方法として、`.info.ts` ファイルは `@/routes/index.ts` ファイルにリンクされています。したがって、ルートの Zod スキーマを変更しても再ビルドは**必要ありません**。ビルドコマンドを実行する必要があるのは以下の場合です:

- `.info.ts` ファイル内のルート名を変更したとき
- ルートの場所を変更したとき(例:`/product` から `/products` へ)
- ルートのパラメータを変更したとき(例:`/product/[id]` から `/product/[productId]` へ)
- ルートを追加または削除したとき
- API ルートから動詞を追加または削除したとき(例:既存のルートに `POST` を追加する場合)

また、`pnpm dr:build:watch` を使用してビルドコマンドをウォッチモードで実行することもできますが、頻繁にルートを変更する場合を除き、使用することはお勧めしません。ルートディレクトリ名を変更して、リンクがホットモジュールリローディングで自動的に変更されるのを見るのは面白いトリックですが、ルートはそれほど頻繁には変更されません。

# セットアップの完了

セットアップ後、ルートを完全にタイプセーフにするために完了する必要がある追加のタスクがいくつかあります。進捗状況を追跡できるように、便利なチェックリストをまとめました。

- [ ] `/page.info.ts`: ページが検索パラメータをサポートしている場合、検索タイピングを追加します
- [ ] `/``Link` コンポーネントを `<Home.Link>` に変換します
- [ ] `/product/[category]/[item]/page.info.ts`: ページが検索パラメータをサ

ポートしている場合、検索タイピングを追加します
- [ ] `/product/[category]/[item]``Link` コンポーネントを `<ProductCategoryItem.Link>` に変換します
- [ ] `/product/[category]/[item]/page.ts``params` タイピングを `z.infer<>` に変換します
- [ ] `/store/page.info.ts`: ページが検索パラメータをサポートしている場合、検索タイピングを追加します
- [ ] `/store``Link` コンポーネントを `<Store.Link>` に変換します
- [ ] `/student/[...slug]/page.info.ts`: ページが検索パラメータをサポートしている場合、検索タイピングを追加します
- [ ] `/student/[...slug]``Link` コンポーネントを `<StudentSlug.Link>` に変換します
- [ ] `/student/[...slug]/page.ts``params` タイピングを `z.infer<>` に変換します
- [ ] `/user/[slug]/page.info.ts`: ページが検索パラメータをサポートしている場合、検索タイピングを追加します
- [ ] `/user/[slug]``Link` コンポーネントを `<UserSlug.Link>` に変換します
- [ ] `/user/[slug]/page.ts``params` タイピングを `z.infer<>` に変換します

これらが完了したら、このセクションを削除できます。

README に記載されている内容を参考にしながら、Declarative Routes について解説します。

コミットします。

$ git add .
$ git commit -m "feat:Declarative Routesをインストール"

ルートリスト

npx declarative-routing init するとルートに基づいたコンポーネントが生成されます。

ルート 動詞 ルート名 使用方法
/ - Home <Home.Link>
/product/[category]/[item] - ProductCategoryItem <ProductCategoryItem.Link>
/store - Store <Store.Link>
/student/[...slug] - StudentSlug <StudentSlug.Link>
/user/[slug] - UserSlug <UserSlug.Link>

例えば、/ にアクセスする場合は、<Home.Link> を使用します。

セットアップの手順の確認

こちらが README.md に記載されている手順です。こちらに従いセットアップを完了させます。

  • /page.info.ts: ページが検索パラメータをサポートしている場合、検索タイピングを追加します
  • /Link コンポーネントを <Home.Link> に変換します
  • /product/[category]/[item]/page.info.ts: ページが検索パラメータをサポートしている場合、検索タイピングを追加します
  • /product/[category]/[item]Link コンポーネントを <ProductCategoryItem.Link> に変換します
  • /product/[category]/[item]/page.tsparams タイピングを z.infer<> に変換します
  • /store/page.info.ts: ページが検索パラメータをサポートしている場合、検索タイピングを追加します
  • /storeLink コンポーネントを <Store.Link> に変換します
  • /student/[...slug]/page.info.ts: ページが検索パラメータをサポートしている場合、検索タイピングを追加します
  • /student/[...slug]Link コンポーネントを <StudentSlug.Link> に変換します
  • /student/[...slug]/page.tsparams タイピングを z.infer<> に変換します
  • /user/[slug]/page.info.ts: ページが検索パラメータをサポートしている場合、検索タイピングを追加します
  • /user/[slug]Link コンポーネントを <UserSlug.Link> に変換します
  • /user/[slug]/page.tsparams タイピングを z.infer<> に変換します

/ の変換

/ への変換を実装します。

  • /page.info.ts: ページが検索パラメータをサポートしている場合、検索タイピングを追加します

/page.info.ts を確認します。が、ここでは何もすることはありません。

src/app/page.info.ts
import { z } from "zod";

export const Route = {
  name: "Home",
  params: z.object({
  })
};
  • /Link コンポーネントを <Home.Link> に変換します

各ファイルを修正します。

src/app/product/[category]/[item]/page.tsx
-import Link from "next/link";
import { FC } from "react";
import { headers } from "next/headers";
+import { Home } from "@/routes";

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>
+           <Home.Link>戻る</Home.Link>
          </div>
        </header>
        ...
      </div>
    </>
  );
};

export default Page;
src/app/store/page.tsx
-import Link from "next/link";
import { type FC } from "react";
import { headers } from "next/headers";
+import { Home } from "@/routes";

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>
+           <Home.Link>戻る</Home.Link>
          </div>
        </header>
        ...
      </div>
    </>
  );
};

export default Page;
src/app/student/[...slug]/page.tsx
-import Link from "next/link";
import { FC } from "react";
import { headers } from "next/headers";
+import { Home } from "@/routes";

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>
+           <Home.Link>戻る</Home.Link>
          </div>
        </header>
        ...
      </div>
    </>
  );
};

export default Page;
src/app/user/[slug]/page.tsx
-import Link from "next/link";
import { FC } from "react";
import { headers } from "next/headers";
+import { Home } from "@/routes";

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>
+           <Home.Link>戻る</Home.Link>
          </div>
        </header>
        ...
      </div>
    </>
  );
};

export default Page;

コミットします。

$ git add .
$ git commit -m "Home.Linkに変換"

/product/[category]/[item] の変換

/product/[category]/[item] への変換を実装します。

  • /product/[category]/[item]/page.info.ts: ページが検索パラメータをサポートしている場合、検索タイピングを追加します

/product/[category]/[item]/page.info.ts を確認します。Dynamic Segment の情報が定義されています。zod を利用し string 型で Dynamic Segment の変数を取得できます。型は必要に応じて変更できます。

src/app/product/[category]/[item]/page.info.ts
import { z } from "zod";

export const Route = {
  name: "ProductCategoryItem",
  searchParams: z.object({
    category: z.string(),
    item: z.string(),
  })
};
  • /product/[category]/[item]Link コンポーネントを <ProductCategoryItem.Link> に変換します

src/app/page.tsx を修正します。

src/app/page.tsx
+import { ProductCategoryItem } from "@/routes";
import Link from "next/link";
import { FC } from "react";

type Props = {
  params: {
    slug: string;
  };
};

const Page: FC<Props> = (props) => {
  return (
    <>
      <div className="p-5">
        <div className="p-5 bg-blue-100">
          ...
          <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-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"}>
+                   <ProductCategoryItem.Link category="dvd" item="anime">
                     http://localhost:3000/product/dvd/anime
-                   </Link>
+                   </ProductCategoryItem.Link>
                  </h2>
                </div>
              </div>
            </div>
            ...
          </main>
        </div>
      </div>
    </>
  );
};

export default Page;
  • /product/[category]/[item]/page.tsparams タイピングを z.infer<> に変換します

src/app/product/[category]/[item]/page.tsx を修正します。

src/app/product/[category]/[item]/page.tsx
import { FC } from "react";
import { headers } from "next/headers";
import { Home } from "@/routes";
+import { Route } from "./page.info";
+import { z } from "zod";

type Props = {
- params: {
-   category: string;
-   item: string;
- };
+ params: z.infer<typeof Route.params>;
};

const Page: FC<Props> = (props) => {
  ...
};

export default Page;

コミットします。

$ git add .
$ git commit -m "ProductCategoryItem.Linkに変換"

/store の変換

/store への変換を実装します。

  • /store/page.info.ts: ページが検索パラメータをサポートしている場合、検索タイピングを追加します

/store は引数を受け付けるので page.info.tssearchPaerams を追加します。

src/app/store/page.info.ts
import { z } from "zod";

export const Route = {
  name: "Store",
  params: z.object({
  }),
+ searchParams: z.object({
+   a: z.string().optional(),
+   b: z.string().optional(),
+ }),
};

src/app/store/page.tsx を修正します。

src/app/store/page.tsx
import { type FC } from "react";
import { headers } from "next/headers";
import { Home } from "@/routes";
+import { Route } from "./page.info";
+import { z } from "zod";

type Props = {
- searchParams: { [key: string]: string | string[] | undefined };
+ searchParams: z.infer<typeof Route.searchParams>;
};

const Page: FC<Props> = (props) => {
  ...
};

export default Page;
  • /storeLink コンポーネントを <Store.Link> に変換します

src/app/page.tsx を修正します。

src/app/page.tsx
-import { ProductCategoryItem } from "@/routes";
+import { ProductCategoryItem, Store } from "@/routes";
import Link from "next/link";
import { FC } from "react";

type Props = {
  params: {
    slug: string;
  };
};

const Page: FC<Props> = (props) => {
  return (
    <>
      <div className="p-5">
        <div className="p-5 bg-blue-100">
          ...
          <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-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"}>
+                   <Store.Link search={{ a: "1" }}>
                      http://localhost:3000/store?a=1
-                   </Link>
+                   </Store.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"}>
+                   <Store.Link search={{ a: "1", b: "2" }}>
                      http://localhost:3000/store?a=1&b=2
-                   </Link>
+                   </Store.Link>
                  </h2>
                </div>
              </div>
            </div>
          </main>
        </div>
      </div>
    </>
  );
};

export default Page;

コミットします。

$ git add .
$ git commit -m "Store.Linkに変換"

/student/[...slug] の変換

/student/[...slug] への変換を実装します。

  • /student/[...slug]/page.info.ts: ページが検索パラメータをサポートしている場合、検索タイピングを追加します

page.info.ts を確認します。[...slugs]は配列で受け取るので、z.string().array() となっています。

src/app/student/[...slug]/page.info.ts
import { z } from "zod";

export const Route = {
  name: "StudentSlug",
  params: z.object({
    slug: z.string().array(),
  })
};
  • /student/[...slug]Link コンポーネントを <StudentSlug.Link> に変換します

src/app/page.tsx を修正します。

src/app/page.tsx
-import { ProductCategoryItem, Store } from "@/routes";
+import { ProductCategoryItem, Store, StudentSlug } from "@/routes";
import Link from "next/link";
import { FC } from "react";

type Props = {
  params: {
    slug: string;
  };
};

const Page: FC<Props> = (props) => {
  return (
    <>
      <div className="p-5">
        <div className="p-5 bg-blue-100">
          ...
          <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-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"}>
+                   <StudentSlug.Link slug={["student", "grade9", "12345"]}>
                      http://localhost:3000/student/grade9/12345
-                   </Link>
+                   </StudentSlug.Link>
                  </h2>
                </div>
              </div>
              ...
            </div>
            ...
          </main>
        </div>
      </div>
    </>
  );
};

export default Page;
  • /student/[...slug]/page.tsparams タイピングを z.infer<> に変換します

src/app/student/[...slug]/page.tsx を修正します。

src/app/student/[...slug]/page.tsx
import { FC } from "react";
import { headers } from "next/headers";
import { Home } from "@/routes";
+import { Route } from "./page.info";
+import { z } from "zod";

type Props = {
- params: {
-   slug: string;
- };
+params: z.infer<typeof Route.params>;
};

const Page: FC<Props> = (props) => {
  ...
};

export default Page;

コミットします。

$ git add .
$ git commit -m "StudentSlug.Linkに変換"

/user/[slug] の変換

/user/[slug] への変換を実装します。

  • /user/[slug]/page.info.ts: ページが検索パラメータをサポートしている場合、検索タイピングを追加します

page.info.ts を確認します。特に変更するところはありません。

src/app/user/[slug]/page.info.ts
import { z } from "zod";

export const Route = {
  name: "UserSlug",
  params: z.object({
    slug: z.string(),
  })
};
  • /user/[slug]Link コンポーネントを <UserSlug.Link> に変換します

src/app/page.tsx を修正します。

src/app/page.tsx
-import { ProductCategoryItem, Store, StudentSlug } from "@/routes";
+import { ProductCategoryItem, Store, StudentSlug, UserSlug } from "@/routes";
-import Link from "next/link";
import { FC } from "react";

type Props = {
  params: {
    slug: string;
  };
};

const Page: FC<Props> = (props) => {
  return (
    <>
      <div className="p-5">
        <div className="p-5 bg-blue-100">
          ...
          <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-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"}>
+                   <UserSlug.Link slug={"12345"}>
                      http://localhost:3000/user/12345
-                   </Link>
+                   </UserSlug.Link>
                  </h2>
                </div>
              </div>
              ...
            </div>
            ...
          </main>
        </div>
      </div>
    </>
  );
};

export default Page;
  • /user/[slug]/page.tsparams タイピングを z.infer<> に変換します

src/app/user/[slug]/page.tsx を修正します。

src/app/user/[slug]/page.tsx
import { FC } from "react";
import { headers } from "next/headers";
import { Home } from "@/routes";
+import { Route } from "./page.info";
+import { z } from "zod";

type Props = {
- params: {
-   slug: string;
- };
+ params: z.infer<typeof Route.params>;
};

const Page: FC<Props> = (props) => {
  ...
};

export default Page;

コミットします。

$ git add .
$ git commit -m "UserSlug.Linkに変換"

動作確認

変更内容をローカルで確認できるはずです。

page.info.ts の更新方法

page.info.ts を更新する際には、npx declarative-routing build を実行することで、変更内容が反映されます。

例えば, Route の名前を ProductCategoryItem から ProductDetail に変更したいとします。/product/[category]/[item]/page.info.ts を修正します。

src/app/product/[category]/[item]/page.info.ts
import { z } from "zod";

export const Route = {
- name: "ProductCategoryItem",
+ name: "ProductDetail",
  params: z.object({
    category: z.string(),
    item: z.string(),
  })
};

npx declarative-routing build を実行することで、変更を反映することが出来ます。ログには変更内容が表示されます。

$ npx declarative-routing build

5 total routes
╭──────────────────────────────────────────────────────────────────────────────╮
│  import * as ProductCategoryItemRoute from                                   │
│  "@/app/product/[category]/[item]/page.info";                                │
│  import * as ProductDetailRoute from                                         │
│  "@/app/product/[category]/[item]/page.info";                                │
│  export const ProductCategoryItem = makeRoute(                               │
│  export const ProductDetail = makeRoute(                                     │
│  ...ProductCategoryItemRoute.Route                                           │
│  ...ProductDetailRoute.Route                                                 │
╰──────────────────────────────────────────────────────────────────────────────╯

<ProductCategoryItemRoute><ProductDetail> に自動的には更新されないようなので更新します。

src/app/page.tsx
-import { ProductCategoryItem, Store, StudentSlug, UserSlug } from "@/routes";
+import { ProductDetail, Store, StudentSlug, UserSlug } from "@/routes";
import { FC } from "react";

type Props = {};

const Page: FC<Props> = (props) => {
  return (
    <>
      <div className="p-5">
        <div className="p-5 bg-blue-100">
          ...
          <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-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">
-                   <ProductCategoryItem.Link category="dvd" item="anime">
+                   <ProductDetail.Link category="dvd" item="anime">
                      http://localhost:3000/product/dvd/anime
-                   </ProductCategoryItem.Link>
+                   </ProductDetail.Link>
                  </h2>
                </div>
              </div>
            </div>
            ...
          </main>
        </div>
      </div>
    </>
  );
};

export default Page;

動作確認すると、問題なく動作することが確認できます。

$ pnpm run dev

コミットします。

$ git add .
$ git commit -m "ProductCategoryItemをProductDetailに変更"

Routeを更新する場合

Route を更新する際には、npx declarative-routing build --watch を実行することで、変更内容が自動的に反映されます。

例えば、src/app/storesrc/app/shop に変更したいとします。

まず、コマンドを実行します。

$ pnpm dr:build:watch

別コンソールでコマンドを実行しフォルダ名を変更します。

$ mv src/app/store src/app/shop

すると、npx declarative-routing build --watch のログで以下のようなメッセージが流れ、自動的に変更が反映されます。

╭──────────────────────────────────────────────────────────────────────────────╮
│  import * as StoreRoute from "@/app/store/page.info";                        │
│  export const Store = makeRoute(                                             │
│    "/store",                                                                 │
│    {                                                                         │
│      ...defaultInfo,                                                         │
│      ...StoreRoute.Route                                                     │
│    }                                                                         │
│  );                                                                          │
╰──────────────────────────────────────────────────────────────────────────────╯

ローカルで実行しても無事動作します。

$ pnpm run dev

コミットします。

$ git add .
$ git commit -m "storeをshopに変更"

つづいて、page.info.ts にて nameStore のままのため Shop に変更します。

src/app/shop/page.info.ts
import { z } from "zod";

export const Route = {
- name: "Store",
+ name: "Shop",
  params: z.object({}),
  searchParams: z.object({
    a: z.string().optional(),
    b: z.string().optional(),
  }),
};

すると、npx declarative-routing build --watch のログで以下のようなメッセージが流れ、自動的に変更が反映されます。


╭──────────────────────────────────────────────────────────────────────────────╮
│  import * as StoreRoute from "@/app/shop/page.info";                         │
│  export const Store = makeRoute(                                             │
│    "/shop",                                                                  │
│    {                                                                         │
│      ...defaultInfo,                                                         │
│      ...StoreRoute.Route                                                     │
│    }                                                                         │
│  );                                                                          │
╰──────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────────╮
│  import * as StoreRoute from "@/app/shop/page.info";                         │
│  import * as ShopRoute from "@/app/shop/page.info";                          │
│  export const Store = makeRoute(                                             │
│  export const Shop = makeRoute(                                              │
│  ...StoreRoute.Route                                                         │
│  ...ShopRoute.Route                                                          │
╰──────────────────────────────────────────────────────────────────────────────╯

先ほどと同じで、page.tsx は変更されないので手動で変更します。

src/app/page.tsx
-import { ProductDetail, Store, StudentSlug, UserSlug } from "@/routes";
+import { ProductDetail, Shop, StudentSlug, UserSlug } from "@/routes";
import { FC } from "react";

type Props = {};

const Page: FC<Props> = (props) => {
  return (
    <>
      <div className="p-5">
        <div className="p-5 bg-blue-100">
          ...
          <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">
                  引数を含むページ
                </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">
-                   <Store.Link search={{ a: "1" }}>
+                   <Shop.Link search={{ a: "1" }}>
                      http://localhost:3000/store?a=1
-                   </Store.Link>
+                   </Shop.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">
-                   <Store.Link search={{ a: "1", b: "2" }}>
+                   <Shop.Link search={{ a: "1", b: "2" }}>
                      http://localhost:3000/store?a=1&b=2
-                   </Store.Link>
+                   </Shop.Link>
                  </h2>
                </div>
              </div>
            </div>
          </main>
        </div>
      </div>
    </>
  );
};

export default Page;

ローカルで実行しても無事動作します。

$ pnpm run dev

コミットします。

$ git add .
$ git commit -m "StoreをShopに変更"

まとめ

  • declarative-routing を使うことで、ルーティングの変更が簡単に行える
  • page.info.ts を変更する際には、npx declarative-routing build を実行するこで変更内容が反映される
  • Route を変更する際には、npx declarative-routing build --watch を実行することで変更内容が自動的に反映される
  • page.tsx のリンクコンポーネントは適宜手動で更新する必要がある。これは自動化されない。

作業リポジトリはこちらです。

https://github.com/hayato94087/next-declarative-routing

Discussion

松田美文松田美文

タイトルは「Declarative Routes で型安全なルーティングを実現」が正しいと思われます(c抜け)