Skip to content

feat(next-typed-href): add $url() helper for absolute URLs via metadataBase #86

@akameco

Description

@akameco

概要

defineTypedHref / defineTypedHrefWithNuqsmetadataBase option$url() helper を追加し、絶対 URL を型安全に生成できるようにする。

背景

現在の $href() は相対パス (/users/42) のみを返す。これは:

  • <Link href> / router.push() / metadata.openGraph.url (Next.js が metadataBase で自動解決) — 問題なし
  • Next.js の URL 解決機構を経由しない出力先 — 手動で process.env.NEXT_PUBLIC_SITE_URL + $href(...) を書く必要があり、型安全性が崩れる

利用用途 (page.tsx 由来の URL に限定)

用途
メール内リンク パスワードリセット、招待、通知
Push / Slack / Discord 通知 bot からの遷移リンク
app/sitemap.ts url フィールドは絶対 URL 仕様
RSS / Atom フィード <link> / <guid>
JSON-LD 構造化データ @id / url
クリップボードコピー / 共有 "リンクをコピー" ボタン
QR コード生成 エンコードする URL
動的 og:image Server Component から組み立てる場合

対象外 (Next.js typegen の AppRoutes に含まれない)

  • API route handler (route.ts) — Stripe webhook 等
  • OAuth callback URL (route handler の場合)
  • Server Action endpoint

これらは process.env.NEXT_PUBLIC_SITE_URL + "/api/..." 等で手動構築する。

提案 API

設定

// lib/href.ts
import { defineTypedHref } from "@plainbrew/next-typed-href";
import type { AppRoutes, ParamsOf } from "@/../.next/types/routes";

type AppRouteParamsMap = { [Route in AppRoutes]: ParamsOf<Route> };

export const { $href, $url } = defineTypedHref
  .routes<AppRoutes, AppRouteParamsMap>()
  .withOptions({ metadataBase: new URL("https://acme.com") });

使用

// 相対パス (Next.js Metadata / <Link> / router で使用)
$href({ route: "/users/[id]", routeParams: { id: "42" } });
// => "/users/42"

// 絶対 URL (メール / 通知 / sitemap 等)
$url({ route: "/users/[id]", routeParams: { id: "42" } });
// => "https://acme.com/users/42"

型レベルの工夫

metadataBase 未設定なら $url を生やさない (呼ぶと型エラー):

.withOptions({})                              // → { $href } のみ
.withOptions({ metadataBase: new URL(...) }); // → { $href, $url }

Next.js Metadata との統合例

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!),
};

// lib/href.ts
export const { $href, $url } = defineTypedHref
  .routes<AppRoutes, AppRouteParamsMap>()
  .withOptions({ metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!) });

// app/posts/[slug]/page.tsx
export async function generateMetadata({ params }) {
  const { slug } = await params;
  return {
    alternates: { canonical: $href({ route: "/posts/[slug]", routeParams: { slug } }) },
    openGraph: { url: $url({ route: "/posts/[slug]", routeParams: { slug } }) },
  };
}

// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
  return posts.map((p) => ({
    url: $url({ route: "/posts/[slug]", routeParams: { slug: p.slug } }),
    lastModified: p.updatedAt,
  }));
}

実装スケッチ

$url は本質的に 1 行:

function $url(opts): string {
  return new URL($href(opts), metadataBase).toString();
}

URL constructor が trailing slash 正規化と相対パス解決をすべて処理する (Next.js の metadataBase 解決ロジックを再実装する必要なし)。

nuqs

defineTypedHrefWithNuqs 側も同様に $url を生やす:

const { $href, $url } = defineTypedHrefWithNuqs
  .routes<AppRoutes, AppRouteParamsMap>()
  .withOptions({ metadataBase: new URL("https://acme.com") })
  .nuqs({ "/search": { q: parseAsString } });

$url({ route: "/search", searchParams: { q: "hello" } });
// => "https://acme.com/search?q=hello"

テスト計画

  • metadataBase 設定時に $url が型に存在し、未設定時に存在しない
  • $url$href + metadataBase の組み合わせを返す
  • 動的セグメント ([id] / [...slug]) を含む route で正しく解決される
  • metadataBase がサブパス付き (例: https://acme.com/app) でも正しく解決される
  • nuqs 側の $urlsearchParams が含まれる
  • README に「対象範囲 (page.tsx のみ・API route は対象外)」セクションを追加

関連

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions