Next.jsで型安全なルーティングを実現する方法【2026年最新】


Next.jsのApp Routerは強力ですが、標準では型安全性が保証されていません。URLの文字列を直接書くと、タイポやパラメータの渡し忘れに気づけません。この記事では、Next.jsで型安全なルーティングを実現する複数の方法を紹介し、プロジェクトに最適なアプローチを見つける手助けをします。

問題点: 型安全でないルーティング

// 問題1: タイポに気づけない
<Link href="/blgo/post-1">記事を見る</Link> // /blog が /blgo に

// 問題2: パラメータの型が不明
router.push(`/user/${userId}`) // userIdは何型?

// 問題3: クエリパラメータの型安全性がない
router.push(`/search?q=${query}&page=${page}`)

// 問題4: 存在しないルートへのリンク
<Link href="/non-existent-page">リンク</Link> // ビルド時にエラーにならない

アプローチ1: next-safe-navigation

next-safe-navigationは、ファイルシステムベースのルーティングから自動的に型を生成します。

インストールと設定

npm install next-safe-navigation
// lib/navigation.ts
import { createNavigationConfig } from "next-safe-navigation";

export const { Link, redirect, useRouter, usePathname } = createNavigationConfig(
  // App Routerの型情報を自動生成
  (defineRoute) => ({
    home: defineRoute("/"),
    blog: defineRoute("/blog"),
    post: defineRoute("/blog/[slug]", {
      params: (slug: string) => ({ slug }),
    }),
    user: defineRoute("/user/[id]", {
      params: (id: number) => ({ id: id.toString() }),
      searchParams: (filters?: { page?: number; sort?: "asc" | "desc" }) => filters,
    }),
  })
);

使用例

// app/page.tsx
import { Link } from "@/lib/navigation";

export default function Home() {
  return (
    <div>
      {/* 型安全なリンク */}
      <Link route="home">ホーム</Link>
      <Link route="blog">ブログ一覧</Link>

      {/* パラメータ付きルート */}
      <Link route="post" params={{ slug: "hello-world" }}>
        記事を見る
      </Link>

      {/* クエリパラメータ */}
      <Link
        route="user"
        params={{ id: 123 }}
        searchParams={{ page: 1, sort: "desc" }}
      >
        ユーザーページ
      </Link>
    </div>
  );
}

// app/components/Navigation.tsx
"use client";

import { useRouter } from "@/lib/navigation";

export function Navigation() {
  const router = useRouter();

  const handleClick = () => {
    // 型安全なナビゲーション
    router.push("post", { slug: "hello-world" });

    // エラー: 必須パラメータが不足
    // router.push("post"); // TypeScriptエラー

    // エラー: 存在しないルート
    // router.push("invalid-route"); // TypeScriptエラー
  };

  return <button onClick={handleClick}>記事へ移動</button>;
}

アプローチ2: pathpida

pathpidaは、ファイルシステムから自動的にパス型を生成するツールです。

インストールと設定

npm install -D pathpida
// package.json
{
  "scripts": {
    "dev": "pathpida --watch & next dev",
    "build": "pathpida && next build"
  }
}

使用例

// pathpidaが自動生成する型
// lib/$path.ts (自動生成)
export const pagesPath = {
  blog: {
    _slug: (slug: string | number) => ({
      $url: (url?: { hash?: string }) => ({
        pathname: '/blog/[slug]' as const,
        query: { slug },
        hash: url?.hash
      })
    })
  },
  user: {
    _id: (id: string | number) => ({
      $url: (url?: { query?: { page?: number }, hash?: string }) => ({
        pathname: '/user/[id]' as const,
        query: { id, ...url?.query },
        hash: url?.hash
      })
    })
  }
}

// 使用
import { pagesPath } from "@/lib/$path";
import Link from "next/link";

export default function Page() {
  return (
    <div>
      <Link href={pagesPath.blog._slug("hello-world").$url()}>
        記事を見る
      </Link>

      <Link href={pagesPath.user._id(123).$url({ query: { page: 1 } })}>
        ユーザーページ
      </Link>
    </div>
  );
}

アプローチ3: 独自の型定義

小規模プロジェクトなら、独自の型定義で十分です。

// lib/routes.ts
import type { Route } from "next";

// ルート定義
export const routes = {
  home: "/" as Route,
  blog: "/blog" as Route,
  post: (slug: string) => `/blog/${slug}` as Route,
  user: (id: number, params?: { page?: number; sort?: "asc" | "desc" }) => {
    const base = `/user/${id}`;
    if (!params) return base as Route;

    const query = new URLSearchParams();
    if (params.page) query.set("page", params.page.toString());
    if (params.sort) query.set("sort", params.sort);

    return `${base}?${query.toString()}` as Route;
  },
} as const;

// 型ヘルパー
export type RouteKey = keyof typeof routes;

// 使用例
import Link from "next/link";
import { routes } from "@/lib/routes";

export default function Page() {
  return (
    <div>
      <Link href={routes.home}>ホーム</Link>
      <Link href={routes.blog}>ブログ</Link>
      <Link href={routes.post("hello-world")}>記事</Link>
      <Link href={routes.user(123, { page: 1, sort: "desc" })}>
        ユーザー
      </Link>
    </div>
  );
}

アプローチ4: Zodでバリデーション

パラメータの型をZodで厳密に定義する方法です。

// lib/routes.ts
import { z } from "zod";

// パラメータスキーマ
const postParamsSchema = z.object({
  slug: z.string().min(1),
});

const userParamsSchema = z.object({
  id: z.number().int().positive(),
});

const userSearchParamsSchema = z.object({
  page: z.number().int().positive().optional(),
  sort: z.enum(["asc", "desc"]).optional(),
});

// ルートビルダー
export const routes = {
  home: () => "/",
  blog: () => "/blog",
  post: (params: z.infer<typeof postParamsSchema>) => {
    const validated = postParamsSchema.parse(params);
    return `/blog/${validated.slug}`;
  },
  user: (
    params: z.infer<typeof userParamsSchema>,
    searchParams?: z.infer<typeof userSearchParamsSchema>
  ) => {
    const validatedParams = userParamsSchema.parse(params);
    const validatedSearchParams = searchParams
      ? userSearchParamsSchema.parse(searchParams)
      : undefined;

    let url = `/user/${validatedParams.id}`;

    if (validatedSearchParams) {
      const query = new URLSearchParams();
      if (validatedSearchParams.page) {
        query.set("page", validatedSearchParams.page.toString());
      }
      if (validatedSearchParams.sort) {
        query.set("sort", validatedSearchParams.sort);
      }
      url += `?${query.toString()}`;
    }

    return url;
  },
} as const;

// 使用例
import Link from "next/link";
import { routes } from "@/lib/routes";

export default function Page() {
  return (
    <div>
      {/* 正しい使用 */}
      <Link href={routes.post({ slug: "hello-world" })}>記事</Link>

      {/* 実行時エラー: slugが空文字 */}
      {/* <Link href={routes.post({ slug: "" })}>記事</Link> */}

      {/* TypeScriptエラー: idが文字列 */}
      {/* <Link href={routes.user({ id: "123" })}>ユーザー</Link> */}

      {/* 正しい使用 */}
      <Link href={routes.user({ id: 123 }, { page: 1, sort: "desc" })}>
        ユーザー
      </Link>
    </div>
  );
}

アプローチ5: TanStack Router的アプローチ

TanStack Routerのような完全な型安全ルーターをNext.jsに実装することも可能です。

// lib/router.ts
import { useRouter as useNextRouter } from "next/navigation";
import type { Route } from "next";

type RouteConfig = {
  path: string;
  params?: Record<string, "string" | "number">;
  searchParams?: Record<string, "string" | "number" | "boolean">;
};

type InferParams<T extends RouteConfig> = T["params"] extends Record<
  string,
  infer P
>
  ? {
      [K in keyof T["params"]]: T["params"][K] extends "string"
        ? string
        : T["params"][K] extends "number"
        ? number
        : never;
    }
  : never;

type InferSearchParams<T extends RouteConfig> = T["searchParams"] extends Record<
  string,
  infer P
>
  ? {
      [K in keyof T["searchParams"]]?: T["searchParams"][K] extends "string"
        ? string
        : T["searchParams"][K] extends "number"
        ? number
        : T["searchParams"][K] extends "boolean"
        ? boolean
        : never;
    }
  : never;

// ルート定義
const routeConfig = {
  home: {
    path: "/",
  },
  blog: {
    path: "/blog",
  },
  post: {
    path: "/blog/[slug]",
    params: { slug: "string" as const },
  },
  user: {
    path: "/user/[id]",
    params: { id: "number" as const },
    searchParams: {
      page: "number" as const,
      sort: "string" as const,
    },
  },
} as const;

type RouteKeys = keyof typeof routeConfig;

type BuildRouteParams<K extends RouteKeys> = InferParams<
  (typeof routeConfig)[K]
> extends never
  ? [params?: never]
  : [params: InferParams<(typeof routeConfig)[K]>];

type BuildRouteSearchParams<K extends RouteKeys> = InferSearchParams<
  (typeof routeConfig)[K]
> extends never
  ? [searchParams?: never]
  : [searchParams?: InferSearchParams<(typeof routeConfig)[K]>];

export function buildRoute<K extends RouteKeys>(
  key: K,
  ...args: [...BuildRouteParams<K>, ...BuildRouteSearchParams<K>]
): Route {
  const config = routeConfig[key];
  let path = config.path;

  const [params, searchParams] = args as [any, any];

  // パラメータの置換
  if (params) {
    Object.entries(params).forEach(([key, value]) => {
      path = path.replace(`[${key}]`, String(value));
    });
  }

  // クエリパラメータの追加
  if (searchParams) {
    const query = new URLSearchParams();
    Object.entries(searchParams).forEach(([key, value]) => {
      if (value !== undefined) {
        query.set(key, String(value));
      }
    });
    const queryString = query.toString();
    if (queryString) {
      path += `?${queryString}`;
    }
  }

  return path as Route;
}

// カスタムフック
export function useTypedRouter() {
  const router = useNextRouter();

  return {
    push: <K extends RouteKeys>(
      key: K,
      ...args: [...BuildRouteParams<K>, ...BuildRouteSearchParams<K>]
    ) => {
      router.push(buildRoute(key, ...args));
    },
    replace: <K extends RouteKeys>(
      key: K,
      ...args: [...BuildRouteParams<K>, ...BuildRouteSearchParams<K>]
    ) => {
      router.replace(buildRoute(key, ...args));
    },
  };
}

// 使用例
"use client";

import { buildRoute, useTypedRouter } from "@/lib/router";
import Link from "next/link";

export default function Page() {
  const router = useTypedRouter();

  return (
    <div>
      {/* Link */}
      <Link href={buildRoute("home")}>ホーム</Link>
      <Link href={buildRoute("post", { slug: "hello-world" })}>記事</Link>
      <Link href={buildRoute("user", { id: 123 }, { page: 1, sort: "desc" })}>
        ユーザー
      </Link>

      {/* プログラマティックナビゲーション */}
      <button onClick={() => router.push("post", { slug: "hello-world" })}>
        記事へ移動
      </button>

      <button onClick={() => router.push("user", { id: 123 }, { page: 1 })}>
        ユーザーページへ
      </button>
    </div>
  );
}

比較表

アプローチ型安全性自動生成学習コストおすすめ規模
next-safe-navigation⭐⭐⭐⭐⭐中〜大
pathpida⭐⭐⭐⭐⭐⭐⭐⭐⭐中〜大
独自型定義⭐⭐⭐小〜中
Zod検証⭐⭐⭐⭐⭐中〜大
TanStack Router的⭐⭐⭐⭐⭐

まとめ

Next.jsで型安全なルーティングを実現する方法は複数あります。プロジェクトの規模や要件に応じて選択しましょう。

小規模プロジェクト: 独自の型定義で十分 中規模プロジェクト: next-safe-navigationまたはpathpida 大規模プロジェクト: TanStack Router的アプローチ + Zod検証

どのアプローチを選んでも、型安全性を導入することで、ランタイムエラーを大幅に減らし、開発体験が向上します。