Next.js: ナビゲーション: Link とプログラム API
最終更新:2026-08-26
ナビゲーションは地下鉄システムのようなものです。自動化された路線 (Link コンポーネント)、手動制御のオプション (プログラムナビゲーション)、高速通勤のための急行 (ソフトナビゲーション)、乗り換えルート (ハードナビゲーション) があります。
1. 学ぶこと
<Link>コンポーネントのコアな使い方:prefetch、scroll、replaceuseRouterプログラムナビゲーション:push、replace、back、forward、refreshredirect()関数と<Redirect>コンポーネントのユースケース- ソフトナビゲーションとハードナビゲーションの原則と違い
- ナビゲーションローディングインジケータの実装オプション
2. ジュニア開発者の実話
(1) ペインポイント: ページ間のナビゲーション体験が悪い
Charlie は最近 Next.js で e コマースサイトを開発していますが、ナビゲーション体験が悪いことに気づきました:
「ユーザーがホームページから商品リンクをクリックすると、ページが読み込まれるまで1秒間真っ白になります。ユーザーが『戻る』をクリックするたびに、ページ全体がリロードされます。さらにバグもあります。フォームを半分入力した状態で誤って商品リンクをクリックし、『戻る』をクリックするとフォームデータがすべて消えます。」
具体的な問題:
| 問題 | 影響 | ユーザーフィードバック |
|---|---|---|
| ページ切り替え時の空白画面 | ぎこちないナビゲーション | 「リンクをクリックしてもなかなか表示されない」 |
| フォームデータの消失 | ユーザーがデータを再入力 | 「半分書いたのに戻ったら消えてた」 |
| プリロードが賢くない | よく訪問するページをプリロードしない | 「ホームから商品詳細への遷移が一番遅い」 |
| スクロール位置のリセット | リストページでページング後に先頭に戻る | 「毎回下までスクロールし直さないといけない」 |
(2) Next.js ナビゲーションの解決策
Link コンポーネントの自動フェッチ + ソフトナビゲーションの状態保持 + scroll={false} でスクロール位置を保持。
// 最適化された商品一覧ページのナビゲーション
<Link
href={`/products/${product.id}`}
prefetch={true}
scroll={false}
className="block p-4 border rounded hover:shadow"
>
{product.title}
</Link>
(3) 効果
| 次元 | 最適化前 (標準 <a> タグ) |
最適化後 (<Link> コンポーネント) |
|---|---|---|
| ページ読み込み速度 | 1〜2秒 (空白画面) | 即時 (プリフェッチキャッシュ) |
| フォームデータの永続性 | ページアンロード時に消失 | ソフトナビで保持 |
| スクロール位置の保持 | 先頭に戻る | 正確に保持 |
| ユーザー体験評価 | 3.2/5 | 4.8/5 |
3. Link コンポーネント
(1) 基本的な使い方
graph LR
A[Link コンポーネント] --> B[クライアントサイドナビゲーション<br/>フルページリフレッシュなし]
A --> C[自動プリフェッチ<br/>ビューポート内のリンク]
A --> D[スクロール制御<br/>scroll={false}]
A --> E[履歴の置換<br/>replace]
style A fill:#cce5ff
style B fill:#d4edda
▶ サンプル: Link の基本ナビゲーション
Diagram of client-side navigation using Link component.
// ============================================
// Link コンポーネントの基本的な使い方
// ============================================
import Link from "next/link";
export default function Navigation() {
return (
<nav className="flex gap-6 p-4 bg-white shadow-sm">
{/* 基本ナビゲーション */}
<Link href="/" className="text-blue-600 hover:underline">
Home
</Link>
{/* 動的ルーティング */}
<Link href="/products/42" className="text-blue-600 hover:underline">
Product 42
</Link>
{/* クエリパラメータ付き */}
<Link
href="/products?category=electronics&sort=price"
className="text-blue-600 hover:underline"
>
Electronics
</Link>
{/* 完全な URL */}
<Link
href="https://help.example.com"
className="text-blue-600 hover:underline"
>
Help Center
</Link>
</nav>
);
}
Renders: Navigation links using Next.js Link component for client-side routing.
ブラウザに4つの青いリンクが表示:
Home → /
Product 42 → /products/42
Electronics → /products?category=electronics&sort=price
Help Center → https://help.example.com (外部リンク)
(2) prefetch
デフォルトでは、Link コンポーネントはビューポート内のリンクをプリロードします。プリロード動作はサーバーとクライアントで異なります:
| 環境 | prefetch={true} |
prefetch={false} |
|---|---|---|
| サーバーサイドレンダリング | ページデータと RSC ペイロードをプリフェッチ | プリフェッチしない |
| 静的ページ | ページ全体をプリロード | プリロードしない |
| ビューポート内 | デフォルト動作 | プリロードしない |
▶ サンプル: prefetch 動作の制御
The page renders as described above, with the UI updating based on the described behavior.
// ============================================
// Link のプリフェッチ動作を制御
// ============================================
import Link from "next/link";
export default function ProductList() {
const products = [
{ id: 1, name: "Wireless Mouse" },
{ id: 2, name: "Keyboard" },
{ id: 3, name: "Monitor" },
];
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Products</h1>
{/* デフォルト prefetch: ビューポート内のリンクを自動プリロード */}
{products.map(p => (
<Link
key={p.id}
href={`/products/${p.id}`}
className="block p-4 border-b hover:bg-gray-50"
>
{p.name}
</Link>
))}
{/* プリフェッチをオフ: あまり使わないページ向け */}
<Link
href="/archive"
prefetch={false}
className="block mt-4 text-gray-500"
>
View Archive (older products)
</Link>
{/* 強制プリフェッチ: 次に訪問するページ向け */}
<Link
href="/checkout"
prefetch={true}
className="block mt-4 px-6 py-2 bg-blue-600 text-white text-center rounded"
>
Proceed to Checkout
</Link>
</div>
);
}
Maps over products to render list.
Content: Products
ページが読み込まれた後、ネットワークコンソールで観察:
1. /products/1、/products/2、/products/3 がプリフェッチされる (ビューポート内)
2. /archive はプリフェッチされない (prefetch={false})
3. /checkout がプリフェッチされる (prefetch={true}、ビューポート外でも)
ユーザーがリンクをクリックした場合:
- プリフェッチ済みページ → 即時表示 (キャッシュから読み取り)
- プリフェッチされていないページ → ネットワークリクエスト後に表示
(3) スクロール制御
graph LR
A[ナビゲーショントリガー] --> B{scroll プロパティ}
B -->|scroll=true デフォルト| C[新しいページの先頭にスクロール]
B -->|scroll=false| D[現在のスクロール位置を保持]
style C fill:#f8d7da
style D fill:#d4edda
▶ サンプル: scroll={false} でスクロール位置を維持
Diagram: Navigation Trigger; Scroll to the top of the new page; Keep the current scroll position.
// ============================================
// 商品一覧 + モーダルウィンドウ: scroll=false で一覧位置を維持
// ユーザーが商品をクリックして詳細を表示、戻ったときに一覧がスクロールしない
// ============================================
import Link from "next/link";
export default function ProductGrid() {
const products = Array.from({ length: 20 }, (_, i) => ({
id: i + 1,
name: `Product ${i + 1}`,
}));
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Product Catalog</h1>
<div className="grid grid-cols-4 gap-4">
{products.map(p => (
<Link
key={p.id}
href={`/products/${p.id}`}
scroll={false}
className="border p-4 rounded hover:shadow-lg"
>
<div className="h-32 bg-gray-100 rounded" />
<p className="mt-2 font-medium">{p.name}</p>
</Link>
))}
</div>
</div>
);
}
Maps over products to render list.
Content: Product Catalog
1. ユーザーが3ページ目までスクロール (2000px 下方にスクロール)
2. 15番目の商品をクリックして商品詳細ページへ
3. ブラウザの戻るボタンをクリック
4. 一覧ページに戻り、スクロール位置が 2000px に保持される (「先頭に戻る」なし)
5. 比較: scroll={false} なしの場合、毎回先頭に戻る
(4) replace: 履歴の置換
| 操作 | push (デフォルト) |
replace |
|---|---|---|
| ブラウザ履歴 | 新しいエントリを追加 | 現在のエントリを置換 |
| 戻るボタンの動作 | 前のページに戻る | 置換されたページに移動 |
| フォーム送信後 | フォームページに戻れない | 戻れる (フォームページをスキップ) |
▶ サンプル: フォームシナリオでの replace 使用
The component renders the described UI in the browser.
// ============================================
// replace で履歴を置換: 送信後にフォームページに戻れないようにする
// ============================================
import Link from "next/link";
export default function CheckoutPage() {
return (
<div className="max-w-md mx-auto p-8">
<h1 className="text-2xl font-bold mb-6">Checkout</h1>
<div className="space-y-4">
<input placeholder="Card number" className="w-full p-3 border rounded" />
<input placeholder="Expiry date" className="w-full p-3 border rounded" />
<input placeholder="CVV" className="w-full p-3 border rounded" />
</div>
{/* replace を使用: 送信後、このページに戻れなくなる */}
<Link
href="/order-confirmation"
replace
className="block mt-6 w-full p-3 bg-blue-600 text-white text-center rounded"
>
Place Order
</Link>
<p className="text-sm text-gray-500 mt-2 text-center">
After placing order, back button will skip this page
</p>
</div>
);
}
Content: Checkout | Place Order | After placing order, back button wi
ユーザーフロー:
1. ホーム → ショッピングカート → チェックアウト → 確認ページ
2. チェックアウトページで「Place Order」をタップ
3. ブラウザのアドレスが /order-confirmation に変わる
4. ユーザーが戻るボタンをクリック
5. チェックアウトページをスキップして、ショッピングカートページに直接移動
6. ユーザーが誤ってチェックアウトページに戻って再度注文するのを防止
4. useRouter によるプログラムナビゲーション
(1) API クイックリファレンス
graph TB
A[useRouter] --> B[push(url) - 新しいページにナビゲート]
A --> C[replace(url) - 現在の履歴を置換]
A --> D[back() - 戻る]
A --> E[forward() - 進む]
A --> F[refresh() - このページを更新]
A --> G[prefetch(url) - プログラム的プリフェッチ]
style A fill:#cce5ff
| メソッド | パラメータ | 説明 | ブラウザ動作 |
|---|---|---|---|
push |
href: string |
新しい URL にナビゲート | 履歴に追加 |
replace |
href: string |
現在の履歴を置換 | 履歴を置換 |
back |
なし | ブラウザの戻る | history.back() と同じ |
forward |
なし | ブラウザの進む | history.forward() と同じ |
refresh |
なし | このページを更新 | サーバーサイドレンダリング (RSC) |
prefetch |
href: string |
ページをプリフェッチ | RSC ペイロードをキャッシュ |
▶ サンプル: プログラムナビゲーション
Diagram of client-side navigation using Link component.
// ============================================
// useRouter プログラムナビゲーションの完全な例
// ============================================
'use client';
import { useRouter } from "next/navigation";
export default function NavigationBar({ userId }: { userId: string }) {
const router = useRouter();
return (
<div className="p-4 bg-white shadow-sm">
<div className="flex gap-4 max-w-4xl mx-auto">
{/* push: ホームページに移動 */}
<button
onClick={() => router.push("/")}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Home
</button>
{/* push: 動的ルーティング */}
<button
onClick={() => router.push(`/users/${userId}`)}
className="px-4 py-2 bg-gray-600 text-white rounded"
>
Profile
</button>
{/* replace: 現在のページを置換 */}
<button
onClick={() => router.replace("/login")}
className="px-4 py-2 bg-red-600 text-white rounded"
>
Logout
</button>
{/* back/forward: 閲覧履歴 */}
<button
onClick={() => router.back()}
className="px-4 py-2 border rounded"
>
← Back
</button>
<button
onClick={() => router.forward()}
className="px-4 py-2 border rounded"
>
Forward →
</button>
{/* refresh: このページを更新 (サーバーサイド再レンダリング) */}
<button
onClick={() => router.refresh()}
className="px-4 py-2 border rounded"
>
Refresh ↻
</button>
</div>
</div>
);
}
NavigationBar renders its UI.
各ボタンをクリック:
[Home] → / にナビゲート
[Profile] → /users/123 にナビゲート
[Logout] → 現在の履歴を /login で置換 (戻れない)
[← Back] → ブラウザの戻るボタン
[Forward] → ブラウザの進むボタン
[Refresh] → このページを更新 (RSC 再レンダリング、ページ全体のリロードは発生しない)
▶ サンプル: フォーム送信後のナビゲーション
The page renders as described above, with the UI updating based on the described behavior.
// ============================================
// フォーム送信: 検証 → 保存 → プログラムナビゲーション
// ============================================
'use client';
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function CreateProjectForm() {
const router = useRouter();
const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
// API 呼び出しをシミュレート
await new Promise(resolve => setTimeout(resolve, 1500));
// 成功後、新しいプロジェクトページにナビゲート
router.push("/projects/42");
// データキャッシュをリフレッシュ
router.refresh();
} catch (err) {
console.error("Failed to create project:", err);
alert("Failed to create project. Please try again.");
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSubmit} className="max-w-lg mx-auto p-8 space-y-4">
<h1 className="text-2xl font-bold">Create Project</h1>
<input
name="name"
placeholder="Project name"
className="w-full p-3 border rounded"
required
/>
<textarea
name="description"
placeholder="Description"
className="w-full p-3 border rounded h-32"
/>
<button
type="submit"
disabled={saving}
className="w-full p-3 bg-blue-600 text-white rounded disabled:opacity-50"
>
{saving ? "Creating..." : "Create Project"}
</button>
</form>
);
}
Form with: name.
Content: Create Project
1. ユーザーがプロジェクト名と説明を入力
2. "Create Project" ボタンをクリック
3. ボタンが "Creating..." に変わる (無効化)
4. 1.5秒後 (シミュレートされた API 遅延)
5. ページが /projects/42 にナビゲート
6. プロジェクト一覧データがリフレッシュされる (新規作成されたプロジェクトを含む)
5. redirect() と <Redirect>
(1) 2種類のリダイレクト
graph TB
A[リダイレクトリクエスト] --> B[サーバーサイド<br/>redirect()]
A --> C[クライアントサイド<br/><Redirect>]
B --> D[サーバーアクション / ルートハンドラ]
B --> E[サーバーコンポーネント]
C --> F[クライアントコンポーネント]
C --> G[条件付きレンダリング時]
style B fill:#d4edda
style C fill:#cce5ff
| メソッド | 使用場所 | トリガータイミング | パフォーマンス |
|---|---|---|---|
redirect() |
サーバーアクション / サーバーコンポーネント | サーバーレスポンス時 | サーバーリダイレクト、クライアントオーバーヘッドゼロ |
<Redirect> |
クライアントコンポーネント | レンダリング時 | クライアントサイドリダイレクト、ルート変更 |
6. ソフトナビゲーション vs ハードナビゲーション
(1) 比較
graph TB
subgraph "ソフトナビゲーション (Link/useRouter)"
A[クライアントサイドルーティング] --> B[ページナビゲーション<br/>フルページリフレッシュなし]
B --> C[レイアウトはマウント維持]
B --> D[React 状態保持]
B --> E[RSC ペイロード置換]
end
subgraph "ハードナビゲーション (ブラウザ全体のリフレッシュ)"
F[ブラウザのフルページローディング] --> G[ページナビゲーション<br/>ページ全体をリフレッシュ]
G --> H[レイアウト再マウント]
G --> I[全状態リセット]
G --> J[全 JS/CSS ローディング]
end
style A fill:#d4edda
style F fill:#f8d7da
| 特性 | ソフトナビゲーション | ハードナビゲーション |
|---|---|---|
| トリガー方法 | <Link>、useRouter() |
ブラウザリフレッシュ、<a> タグ、window.location |
| ページ全体のリフレッシュ | ❌ いいえ | ✅ はい |
| レイアウト保持 | ✅ 保持される | ❌ 再マウント |
| RSC ペイロード | 増分更新 | フルダウンロード |
| パフォーマンス | 瞬時 | 500ms〜2s |
| 状態維持 | ✅ | ❌ |
7. ナビゲーションローディングインジケータ
(1) 実装計画
graph TB
A[ナビゲーショントリガー] --> B[ローディングインジケータ]
B --> C[トッププログレスバー<br/>NProgress スタイル]
B --> D[Suspense フォールバック<br/>スケルトン表示]
B --> E[loading.tsx<br/>ページレベルローディング]
style A fill:#cce5ff
style B fill:#f8d7da
8. 完全な例: e コマースナビゲーションシステム
// ============================================
// 総合例: e コマースの包括的なナビゲーションシステム
// Link、useRouter、redirect、ナビゲーションローディング状態をカバー
// ============================================
// src/app/layout.tsx — グローバルナビゲーションバー
import Link from "next/link";
import { CartCount } from "./CartCount";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<header className="bg-white shadow-sm sticky top-0 z-40">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<Link href="/" className="text-2xl font-bold text-blue-600">
ShopHub
</Link>
<nav className="hidden md:flex gap-6">
<Link href="/products" className="hover:text-blue-600">
Products
</Link>
<Link href="/categories" className="hover:text-blue-600">
Categories
</Link>
<Link href="/deals" prefetch={false} className="hover:text-blue-600">
Deals
</Link>
<Link href="/about" className="hover:text-blue-600">
About
</Link>
</nav>
<div className="flex items-center gap-4">
<Link href="/search" className="text-gray-600 hover:text-blue-600">
Search
</Link>
<Link href="/cart" className="relative text-gray-600 hover:text-blue-600">
Cart
<CartCount />
</Link>
<Link
href="/account"
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm"
>
Sign In
</Link>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-4 py-8">
{children}
</main>
</body>
</html>
);
}
// src/app/products/[id]/page.tsx — 商品詳細 (ナビゲーションロジックを含む)
'use client';
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function ProductDetail({ params }) {
const router = useRouter();
async function handleAddToCart() {
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId: params.id }),
});
// カートに追加後、カート内の数量をリフレッシュ
router.refresh();
}
async function handleBuyNow() {
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId: params.id, quantity: 1 }),
});
// 今すぐ購入 → チェックアウトに進む (replace で商品ページに戻れないようにする)
router.replace("/checkout");
}
return (
<div>
{/* パンくずナビゲーション */}
<nav className="text-sm text-gray-500 mb-6">
<Link href="/" className="hover:text-blue-600">Home</Link>
<span className="mx-2">/</span>
<Link href="/products" className="hover:text-blue-600">Products</Link>
<span className="mx-2">/</span>
<span className="text-gray-900">{params.id}</span>
</nav>
<div className="flex gap-8">
<div className="w-1/2">
<img
src={`https://picsum.photos/seed/${params.id}/400/400`}
alt="Product"
className="w-full rounded-lg"
/>
</div>
<div className="w-1/2">
<h1 className="text-3xl font-bold">Product #{params.id}</h1>
<p className="text-2xl text-green-600 font-bold mt-4">$49.99</p>
<p className="text-gray-600 mt-4">
High-quality product with premium features.
</p>
<div className="flex gap-4 mt-8">
<button
onClick={handleAddToCart}
className="flex-1 px-6 py-3 border-2 border-blue-600 text-blue-600 rounded-lg hover:bg-blue-50"
>
Add to Cart
</button>
<button
onClick={handleBuyNow}
className="flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Buy Now
</button>
</div>
<div className="mt-8 border-t pt-6">
<Link
href={`/products/${Number(params.id) - 1}`}
scroll={false}
className="text-blue-600 hover:underline"
>
← Previous Product
</Link>
<Link
href={`/products/${Number(params.id) + 1}`}
scroll={false}
className="text-blue-600 hover:underline float-right"
>
Next Product →
</Link>
</div>
</div>
</div>
</div>
);
}
// src/app/CartCount.tsx — ショッピングカート数量表示
export async function CartCount() {
const cart = await fetch("https://api.example.com/cart");
const itemCount = cart?.items?.length ?? 0;
if (itemCount === 0) return null;
return (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
{itemCount}
</span>
);
}
期待される出力:
ナビゲーションバー (上部固定):
[ShopHub] Products Categories Deals About [Search] Cart(3) [Sign In]
商品詳細ページ (/products/1):
Home / Products / 1
[商品画像] [商品詳細]
Product #1
$49.99
[Add to Cart] [Buy Now]
← Previous Product | Next Product →
ナビゲーション動作:
- Products をクリック → ソフトナビ、瞬時にページ切り替え
- Deals をクリック → 事前プリフェッチなし (prefetch=false)、ただしナビゲーションは依然高速
- カートに追加 → router.refresh()、カートアイコン更新
- 今すぐ購入 → router.replace("/checkout")、履歴を置換
❓ よくある質問
Link コンポーネントと <a> タグの違いは何ですか?Link コンポーネントはクライアントサイドでルーティングを処理し、ページ全体のリフレッシュをトリガーせず、レイアウトを保持し、プリロードをサポートします。<a> タグはページ全体のリロード (ハードナビゲーション) をトリガーし、すべての React 状態が失われます。Next.js はすべての内部ルーティングに Link を使用します。useRouter を使用するコンポーネントに 'use client' を含める必要があるのはなぜですか?useRouter は React フックであり、クライアントコンポーネントでのみ使用できます。サーバーコンポーネントにはブラウザ API (history や location など) へのアクセスがないため、ルーティング関連のフックはクライアントサイドで実行されます。router.refresh() と window.location.reload() の違いは何ですか?router.refresh() は Next.js のソフトリフレッシュで、サーバーサイドの RSC ペイロードを再リクエストし、変更された部分のみを更新しながらクライアントサイドの状態 (useState や Context など) を保持します。window.location.reload() はページ全体のリフレッシュを実行し、すべての状態が失われ、JS と CSS の完全な再読み込みが必要です。prefetch={false} で手動制御することもできます。redirect() と <Redirect> はいつ使用すべきですか?redirect() はサーバーサイド (サーバーアクション、サーバーコンポーネント、ルートハンドラ) で使用され、303/307 レスポンスを送信します。クライアントサイドの条件付きリダイレクトには、<Redirect> コンポーネントの代わりにアクセス制御ロジック (「アクセス拒否」メッセージ + ナビゲーションボタンの表示) を使用します。router.refresh() を呼び出してサーバーサイドデータをリフレッシュ; 3) データがレイアウトによってキャッシュされているかどうかを確認。📖 まとめ
<Link>コンポーネントは Next.js の推奨ナビゲーションソリューションで、自動プリロードとクライアントサイド遷移をサポートしますprefetch={true}はビューポート内のリンクをプリロードし、prefetch={false}はプリロードを無効にしますscroll={false}はスクロール位置を維持します。一覧ページと詳細ページに適していますreplaceはブラウザ履歴を上書きし、フォームページからの誤った戻りナビゲーションを防止しますuseRouterはプログラムナビゲーションを提供します:push、replace、back、forward、refreshredirect()はサーバーサイドでの使用向けで、303/307 リダイレクトレスポンスを送信します- ソフトナビゲーションはレイアウトと状態を保持し、ハードナビゲーションはページ全体をリフレッシュします
router.refresh()は特別なソフトナビゲーション機能で、URL を変更せずに RSC データをリフレッシュします
📝 練習問題
-
基礎問題 (⭐): ページに5つのリンクを作成し (Home, About, Contact, Product List, Product Details #42)、ブラウザのネットワークタブでどの URL がプリロードされるか観察してください。
-
発展問題 (⭐⭐): 「ログイン → ダッシュボード」ワークフローを実装してください。サーバーアクションを使用してログインを検証し、成功した場合は
redirect()でダッシュボードにリダイレクトし、失敗した場合はredirect()でログインページに戻りエラーパラメータを含めてください。 -
チャレンジ (⭐⭐⭐): 無限スクロールのリストページを実装してください。ユーザーが商品をクリックして詳細ページに移動する際、
scroll={false}でスクロール位置を保持し、戻ったときにリストが以前の位置に留まるようにしてください。戻るナビゲーションにはrouter.back()を使用してください。