链接与导航

Next.js 的路由默认在服务端渲染,为了让用户在路由切换时也感觉流畅,框架内置了 预取流式传输客户端过渡 三大优化。本章讲透它们是如何协作的,以及怎样为慢网络和动态路由做优化。

导航的四层原理

  1. Server Rendering:布局和页面默认是 RSC(React Server Components),服务端生成 RSC 载荷后发给客户端。
  2. Prefetching:当链接进入视口时,Next.js 自动在后台预取目标路由。
  3. Streaming:允许服务端边渲染边发送,配合 loading.tsx 立即显示加载骨架。
  4. Client-side Transitions<Link> 使用客户端过渡替换页面内容,保留共享布局、滚动状态与交互。

<Link> 替代 <a>

import Link from 'next/link';

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <nav>
          <Link href="/blog">博客</Link>        {/* 自动预取 */}
          <a href="/contact">联系我们</a>        {/* 不会预取 */}
        </nav>
        {children}
      </body>
    </html>
  );
}
  • 静态路由:整条路由都会被预取。
  • 动态路由:默认不预取;若存在 loading.tsx,则会部分预取(共享布局 + 骨架)。

loading.tsx 开启流式渲染

在路由文件夹里放 loading.tsx,Next.js 会自动把 page.tsx 包在 <Suspense> 里:

// app/blog/loading.tsx
export default function Loading() {
  return <BlogListSkeleton />;
}

效果:用户点击链接后立即看到骨架屏,真实内容准备好再替换进来。对 Core Web Vitals(TTFB / FCP / TTI)都有明显提升。

动态段建议加 generateStaticParams

若动态段本可以在构建期预渲染,却没有 generateStaticParams,每次访问都会退化为请求时渲染:

export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then(r => r.json());
  return posts.map((p: any) => ({ slug: p.slug }));
}

慢网络下的体验优化

网络不稳时预取可能没跑完,骨架也来不及显示。用 useLinkStatus 立刻给用户一个视觉反馈:

'use client';
import { useLinkStatus } from 'next/link';

export default function LinkIndicator() {
  const { pending } = useLinkStatus();
  return <span aria-hidden className={pending ? 'is-pending' : ''} />;
}

结合 CSS 延迟动画(比如 100ms 才显示)可以避免快速导航时的「闪屏」。

按需关闭预取

长列表(无限滚动表格等)预取会很浪费,可以直接关掉:

<Link prefetch={false} href="/blog">博客</Link>

或者只在鼠标悬停时预取,兼顾资源与体验:

'use client';
import Link from 'next/link';
import { useState } from 'react';

function HoverPrefetchLink({ href, children }: { href: string; children: React.ReactNode }) {
  const [active, setActive] = useState(false);
  return (
    <Link
      href={href}
      prefetch={active ? null : false}
      onMouseEnter={() => setActive(true)}
    >
      {children}
    </Link>
  );
}

使用原生 History API

不想触发 UI 重新渲染但又想同步 URL,可以直接用 pushState

'use client';
import { useSearchParams } from 'next/navigation';

export default function SortProducts() {
  const searchParams = useSearchParams();
  function update(order: string) {
    const params = new URLSearchParams(searchParams.toString());
    params.set('sort', order);
    window.history.pushState(null, '', `?${params.toString()}`);
  }
  return (
    <>
      <button onClick={() => update('asc')}>升序</button>
      <button onClick={() => update('desc')}>降序</button>
    </>
  );
}

pushState / replaceState 都会与 usePathnameuseSearchParams 同步。

連結與導航

Next.js 的路由預設在伺服器端渲染,為了讓使用者在切換路由時也感覺流暢,框架內建了 預取串流客戶端過渡 三大優化。本章講透它們如何協作,以及怎樣為慢網路與動態路由做優化。

導航的四層原理

  1. Server Rendering:佈局與頁面預設為 RSC(React Server Components),伺服器端產出 RSC Payload 後發給客戶端。
  2. Prefetching:當連結進入視窗時,Next.js 自動在背景預取目標路由。
  3. Streaming:允許伺服器邊渲染邊傳送,配合 loading.tsx 立刻顯示載入骨架。
  4. Client-side Transitions<Link> 使用客戶端過渡替換頁面內容,保留共用佈局、捲動狀態與互動。

<Link> 取代 <a>

import Link from 'next/link';

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <nav>
          <Link href="/blog">網誌</Link>        {/* 自動預取 */}
          <a href="/contact">聯絡我們</a>        {/* 不會預取 */}
        </nav>
        {children}
      </body>
    </html>
  );
}
  • 靜態路由:整條路由都會被預取。
  • 動態路由:預設不預取;若存在 loading.tsx,則會部分預取(共用佈局 + 骨架)。

loading.tsx 開啟串流渲染

在路由資料夾裡放 loading.tsx,Next.js 會自動把 page.tsx 包在 <Suspense> 內:

// app/blog/loading.tsx
export default function Loading() {
  return <BlogListSkeleton />;
}

效果:使用者點擊連結後立刻看到骨架屏,真實內容準備好再替換進來。對 Core Web Vitals(TTFB / FCP / TTI)都有顯著提升。

動態段建議加 generateStaticParams

若動態段本可以在建置期預渲染,卻沒有 generateStaticParams,每次存取都會退化為請求時渲染:

export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then(r => r.json());
  return posts.map((p: any) => ({ slug: p.slug }));
}

慢網路下的體驗優化

網路不穩時預取可能未完成,骨架也來不及顯示。用 useLinkStatus 立即給使用者視覺回饋:

'use client';
import { useLinkStatus } from 'next/link';

export default function LinkIndicator() {
  const { pending } = useLinkStatus();
  return <span aria-hidden className={pending ? 'is-pending' : ''} />;
}

配合 CSS 延遲動畫(例如 100ms 才顯示)可避免快速導航時的「閃屏」。

按需關閉預取

長清單(如無限捲動表格)預取會浪費資源,可直接關閉:

<Link prefetch={false} href="/blog">網誌</Link>

或只在滑鼠懸停時預取,兼顧資源與體驗:

'use client';
import Link from 'next/link';
import { useState } from 'react';

function HoverPrefetchLink({ href, children }: { href: string; children: React.ReactNode }) {
  const [active, setActive] = useState(false);
  return (
    <Link
      href={href}
      prefetch={active ? null : false}
      onMouseEnter={() => setActive(true)}
    >
      {children}
    </Link>
  );
}

使用原生 History API

不想觸發 UI 重新渲染但又想同步 URL,可直接使用 pushState

'use client';
import { useSearchParams } from 'next/navigation';

export default function SortProducts() {
  const searchParams = useSearchParams();
  function update(order: string) {
    const params = new URLSearchParams(searchParams.toString());
    params.set('sort', order);
    window.history.pushState(null, '', `?${params.toString()}`);
  }
  return (
    <>
      <button onClick={() => update('asc')}>升序</button>
      <button onClick={() => update('desc')}>降序</button>
    </>
  );
}

pushState / replaceState 都會與 usePathnameuseSearchParams 同步。

Linking and Navigating

Next.js renders routes on the server by default. To keep transitions feeling instant, it bakes in prefetching, streaming, and client-side transitions. This chapter unpacks how they work together and how to tune them for slow networks and dynamic routes.

The four layers of navigation

  1. Server Rendering: layouts and pages are React Server Components by default; the server generates an RSC payload and sends it to the client.
  2. Prefetching: when a <Link> enters the viewport, Next.js fetches the target route in the background.
  3. Streaming: the server sends HTML progressively; loading.tsx lets you show a skeleton immediately.
  4. Client-side transitions: <Link> swaps page content without a full reload — preserving shared layouts, scroll position, and interactivity.

Use <Link> instead of <a>

import Link from 'next/link';

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <nav>
          <Link href="/blog">Blog</Link>         {/* prefetched */}
          <a href="/contact">Contact</a>          {/* not prefetched */}
        </nav>
        {children}
      </body>
    </html>
  );
}
  • Static routes: the entire route is prefetched.
  • Dynamic routes: prefetching is skipped, or partial if a loading.tsx exists (shared layout + skeleton).

Stream with loading.tsx

Drop a loading.tsx inside a route folder and Next.js will automatically wrap page.tsx in a <Suspense> boundary:

// app/blog/loading.tsx
export default function Loading() {
  return <BlogListSkeleton />;
}

The user sees the skeleton instantly and real content swaps in when ready. This directly improves Core Web Vitals (TTFB / FCP / TTI).

Add generateStaticParams for dynamic segments

If a dynamic segment could be prerendered but isn't, every request falls back to dynamic rendering. Fix it:

export async function generateStaticParams() {
  const posts = await fetch('https://.../posts').then(r => r.json());
  return posts.map((p: any) => ({ slug: p.slug }));
}

Optimizing for slow networks

On flaky connections, prefetch may not finish before a click. Use useLinkStatus to show immediate feedback:

'use client';
import { useLinkStatus } from 'next/link';

export default function LinkIndicator() {
  const { pending } = useLinkStatus();
  return <span aria-hidden className={pending ? 'is-pending' : ''} />;
}

Pair it with a CSS delay (e.g. show only after 100ms) to avoid flashes on fast transitions.

Disabling prefetch selectively

In huge lists (infinite-scroll tables) prefetching wastes resources. Turn it off:

<Link prefetch={false} href="/blog">Blog</Link>

Or only prefetch on hover:

'use client';
import Link from 'next/link';
import { useState } from 'react';

function HoverPrefetchLink({ href, children }: { href: string; children: React.ReactNode }) {
  const [active, setActive] = useState(false);
  return (
    <Link
      href={href}
      prefetch={active ? null : false}
      onMouseEnter={() => setActive(true)}
    >
      {children}
    </Link>
  );
}

Native History API

Want to update the URL without re-rendering the UI? Use pushState directly — it integrates with usePathname and useSearchParams:

'use client';
import { useSearchParams } from 'next/navigation';

export default function SortProducts() {
  const searchParams = useSearchParams();
  function update(order: string) {
    const params = new URLSearchParams(searchParams.toString());
    params.set('sort', order);
    window.history.pushState(null, '', `?${params.toString()}`);
  }
  return (
    <>
      <button onClick={() => update('asc')}>Ascending</button>
      <button onClick={() => update('desc')}>Descending</button>
    </>
  );
}