# Page Navigation

Course: [Astro](https://www.devtutorials.ai/learn/astro) · Section: Interactivity
Source: https://www.devtutorials.ai/learn/astro/interactivity/page-navigation

> Full page loads vs client-side navigation, the ClientRouter component, transition:name and transition:animate, transition:persist for islands, scripts and the astro:page-load event, navigate(), data-astro-reload and prefetching strategies. Checkpoint 3.

## Page Navigation

- By default, each link click loads a **whole new page**.
- The browser throws the old page away and builds the new one.
- That is simple and reliable, but the screen flashes and any state is lost.
- Astro's **ClientRouter** swaps pages smoothly instead, with animated **view transitions**.
- In this lesson, you will turn on the router, animate elements between pages, keep islands alive, and make links load early.

**Simple lesson:** ClientRouter swaps pages without a full reload, so navigation feels smooth.

### A Real-World Example

- Asha's journal has a header, a post list and post pages.
- Every click flashes white, and the music player in her header restarts.
- She adds `<ClientRouter />` to her layout once.
- Now the pages slide in, the header stays still, and the player keeps playing.

Press **▶ Run**. Click **About** and **Home** a few times. The **Terminal** shows each page being swapped, not reloaded.

    <head>
      <meta charset="utf-8" />
      <title>{title}</title>
    </head>
    <body>
      <nav><a href="/">Home</a> · <a href="/about">About</a></nav>
      <main>
        <slot />
      </main>
    </body>
</html>
`,
    "src/pages/index.astro": `---
---

    <h1>Asha's Dev Journal</h1>
    <p>Notes about building with Astro.</p>
`,
    "src/pages/about.astro": `---
---

    <h1>About Asha</h1>
    <p>I write about the web.</p>
`,
  }}
/>

What happened:

- `<ClientRouter />` added a small script to every page that uses this layout.
- On a link click, the router fetched the new page and **swapped** the content in place.
- The browser's **View Transitions** feature animated the change.

### What You Will Learn

In this lesson, you will learn:

1. Full page loads vs client-side navigation.
2. How to add `<ClientRouter />`.
3. How to match elements between pages with `transition:name`.
4. How to choose animations with `transition:animate`.
5. How to keep an element alive with `transition:persist`.
6. How scripts behave with the router, and the `astro:page-load` event.
7. How to navigate from code with `navigate()`.
8. How to prefetch pages before the click.

## Full Loads vs ClientRouter

### What Happens on a Click

<figure className="ast-fig ngc-fig">
<svg viewBox="0 0 640 220" role="img" aria-labelledby="fig-nav" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-nav">Animation: on the left, a full page load blanks the whole screen before the new page appears. On the right, the ClientRouter keeps the header and swaps only the main content.</title>
  <text x="160" y="22" textAnchor="middle" style={{ fill: "var(--ngc-slate)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>Full page load</text>
  <rect x="60" y="34" width="200" height="160" rx="8" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} />
  <rect x="60" y="34" width="200" height="160" rx="8" style={{ fill: "var(--ngc-slate)" }}>
    <animate attributeName="opacity" values="0;0;0.85;0;0" keyTimes="0;0.4;0.5;0.6;1" dur="4s" repeatCount="indefinite" />
  </rect>
  <rect x="72" y="46" width="176" height="24" rx="4" style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)" }} />
  <text x="160" y="63" textAnchor="middle" style={{ fill: "var(--ngc-blue)", fontFamily: "var(--sans)", fontSize: 11 }}>header (rebuilt)</text>
  <text x="160" y="130" textAnchor="middle" style={{ fill: "var(--ink)", fontFamily: "var(--sans)", fontSize: 12 }}>everything rebuilt</text>
  <text x="480" y="22" textAnchor="middle" style={{ fill: "var(--ngc-slate)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>ClientRouter</text>
  <rect x="380" y="34" width="200" height="160" rx="8" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-orange)" }} />
  <rect x="392" y="46" width="176" height="24" rx="4" style={{ fill: "var(--ngc-blue-soft)", stroke: "var(--ngc-blue)" }} />
  <text x="480" y="63" textAnchor="middle" style={{ fill: "var(--ngc-blue)", fontFamily: "var(--sans)", fontSize: 11 }}>header (kept)</text>
  <rect x="392" y="82" width="176" height="100" rx="4" style={{ fill: "var(--ngc-orange-soft)", stroke: "var(--ngc-orange)" }}>
    <animate attributeName="x" values="392;392;580;392;392" keyTimes="0;0.4;0.5;0.5;1" dur="4s" repeatCount="indefinite" />
    <animate attributeName="opacity" values="1;1;0;1;1" keyTimes="0;0.4;0.5;0.55;1" dur="4s" repeatCount="indefinite" />
  </rect>
  <text x="480" y="136" textAnchor="middle" style={{ fill: "var(--ngc-orange)", fontFamily: "var(--sans)", fontSize: 12 }}>only main swaps</text>
</svg>
<figcaption>Left: the whole page blinks. Right: only the changed part slides.</figcaption>
</figure>

- **Full page load** (the default): simple, works everywhere, but flashes and loses state.
- **ClientRouter**: smooth, keeps what you ask it to keep, adds a little JavaScript.
- If a browser has no View Transitions support, the router still swaps pages, with a simple fallback animation.

### Adding the Router

1. Import it in the frontmatter: `import { ClientRouter } from "astro:transitions";`
2. Put `<ClientRouter />` inside `<head>`.
3. Put it in your **layout**, so every page has it.

- A page **without** the router does a full load when you leave it or arrive at it.
- To force a full load for one link, add `data-astro-reload`: `<a href="/admin" data-astro-reload>`.

In Astro 4 the component was called `<ViewTransitions />`. In Astro 5 and later it is `<ClientRouter />`.

**Simple lesson:** put `<ClientRouter />` in the layout's `<head>` to turn on smooth navigation.

## Animating Between Pages

### Matching Elements with transition:name

- By default, the router fades the old page out and the new page in.
- Give an element on two pages the same `transition:name`, and the browser **morphs** one into the other.
- Example: the post title in the list grows into the big heading on the post page.

### Choosing an Animation with transition:animate

<div className="tscroll">

| Value | Effect |
|---|---|
| `fade` (default) | Old fades out, new fades in |
| `slide` | Old slides out left, new slides in from the right |
| `initial` | The browser's own default cross-fade |
| `none` | No animation for this element |
| `fade({ duration: "0.6s" })` | Fade with your own timing |

</div>

Press **▶ Run**. Click a post title. The title morphs into the heading, and the main area slides.

    <head>
      <meta charset="utf-8" />
      <title>Journal</title>
    </head>
    <body>
      <nav><a href="/">All posts</a></nav>
      <main transition:animate="slide">
        <slot />
      </main>
    </body>
</html>
`,
    "src/pages/index.astro": `---
const posts = [
    { slug: "islands", title: "Islands explained" },
    { slug: "routing", title: "Routing in Astro" },
];
---

    <h1>All posts</h1>
    <ul>
      {posts.map((p) => (
        <li><a href={"/posts/" + p.slug} transition:name={"title-" + p.slug}>{p.title}</a></li>
      ))}
    </ul>
`,
    "src/pages/posts/[slug].astro": `---
    return [
      { params: { slug: "islands" }, props: { title: "Islands explained" } },
      { params: { slug: "routing" }, props: { title: "Routing in Astro" } },
    ];
}
const { slug } = Astro.params;
const { title } = Astro.props;
---

    <h1 transition:name={"title-" + slug}>{title}</h1>
    <p>The post text goes here.</p>
`,
  }}
/>

- Each `transition:name` must be **unique on a page**, so the names include the slug.

Astro turns animations off for readers who set "reduce motion" in their system settings (`prefers-reduced-motion`). You do not need extra code for that.

**Simple lesson:** `transition:name` links an element across two pages. `transition:animate` picks the effect.

## Keeping Things Alive

### transition:persist

- Normally the router replaces everything inside `<body>`.
- `transition:persist` tells it: **keep this exact element**, do not replace it.
- Use it for an audio player, a video, or an island whose state must survive the trip.
- Give it a name when the element is on different pages: `transition:persist="player"`.

Press **▶ Run**. Click **+1** three times, then go to **About**. The count stays at 3.

    <head>
      <meta charset="utf-8" />
      <title>Journal</title>
    </head>
    <body>
      <header>
        <a href="/">Home</a> · <a href="/about">About</a>
      </header>
      <slot />
    </body>
</html>
`,
    "src/pages/index.astro": `---
---

<Base><h1>Home</h1></Base>
`,
    "src/pages/about.astro": `---
---

<Base><h1>About</h1></Base>
`,
  }}
/>

- Remove `transition:persist="counter"` and try again. The count resets on every page.

**Simple lesson:** `transition:persist` keeps an element, and its state, across page swaps.

## Scripts and Router Events

### Scripts Run Only Once

- With full page loads, a processed `<script>` runs on **every** page load.
- With the router, the page is not reloaded. A processed script runs **once**, the first time.
- So code that finds elements on the page must run again after each swap.

### The astro:page-load Event

- The router fires `astro:page-load` after **every** navigation, and on the first load too.
- Put page setup code in a listener for it.

<div className="tscroll">

| Event | When it fires |
|---|---|
| `astro:before-preparation` | A navigation starts, before the new page is fetched |
| `astro:before-swap` | Just before the old page is replaced |
| `astro:after-swap` | Just after the new page is in place |
| `astro:page-load` | The new page is ready (also on the first load) |

</div>

Press **▶ Run**. Move between pages and press **Say hi** on each one. It works every time because the listener is set up in `astro:page-load`.

    <head>
      <meta charset="utf-8" />
      <title>Journal</title>
    </head>
    <body>
      <nav><a href="/">Home</a> · <a href="/about">About</a></nav>
      <slot />
      <button id="hi">Say hi</button>
      <p id="out"></p>
      <script>
        document.addEventListener("astro:page-load", () => {
          const button = document.getElementById("hi")!;
          button.addEventListener("click", () => {
            document.getElementById("out")!.textContent = "Hi from " + document.querySelector("h1")!.textContent;
          });
          console.log("page ready:", document.title);
        });
      </script>
    </body>
</html>
`,
    "src/pages/index.astro": `---
---

<Base><h1>Home</h1></Base>
`,
    "src/pages/about.astro": `---
---

<Base><h1>About</h1></Base>
`,
  }}
/>

- **[JS]** Leave out the `!` after `getElementById(...)`.
- To run a whole script again after each swap instead, add `data-astro-rerun` to an `is:inline` script.

A click listener added outside `astro:page-load` is attached to the **first** page's button only. After a swap, the new button has no listener, so it silently does nothing.

**Simple lesson:** with the router, set up pages inside an `astro:page-load` listener.

## Navigating from Code and Prefetching

### navigate()

- Sometimes code must change the page, for example after a select box changes.
- `navigate(url)` from `astro:transitions/client` does a router navigation, with animations.

### Prefetching

- **Prefetching** loads a page in the background **before** the click, so it opens almost instantly.
- With `<ClientRouter />`, prefetching is on. Without it, set `prefetch: true` in `astro.config.mjs`.
- Mark a link with `data-astro-prefetch`. Choose when it loads:

<div className="tscroll">

| Strategy | Prefetch when |
|---|---|
| `hover` (default) | The mouse is over the link, or it gets keyboard focus |
| `tap` | Just before the click |
| `viewport` | The link scrolls into view |
| `load` | The page has loaded |

</div>

- `prefetch: { prefetchAll: true }` prefetches every link. `data-astro-prefetch="false"` turns one off.

Press **▶ Run**. The **Terminal** shows two links prefetched on load. Move the mouse over **Hover me** to prefetch it. Then pick a page in the select box.

    <head>
      <meta charset="utf-8" />
      <title>Journal</title>
    </head>
    <body>
      <slot />
    </body>
</html>
`,
    "src/pages/index.astro": `---
---

    <h1>Home</h1>
    <p><a href="/posts" data-astro-prefetch="load">Posts (load)</a></p>
    <p><a href="/about" data-astro-prefetch="load">About (load)</a></p>
    <p><a href="/contact" data-astro-prefetch>Hover me</a></p>
    <label>Jump to:
      <select id="jump">
        <option value="">Choose…</option>
        <option value="/posts">Posts</option>
        <option value="/about">About</option>
      </select>
    </label>
    <script>
      document.addEventListener("astro:page-load", () => {
        const select = document.getElementById("jump") as HTMLSelectElement | null;
        select?.addEventListener("change", () => navigate(select.value));
      });
    </script>
`,
    "src/pages/posts.astro": `---
---

<Base><h1>Posts</h1><a href="/">Back</a></Base>
`,
    "src/pages/about.astro": `---
---

<Base><h1>About</h1><a href="/">Back</a></Base>
`,
    "src/pages/contact.astro": `---
---

<Base><h1>Contact</h1><a href="/">Back</a></Base>
`,
  }}
/>

- **[JS]** Leave out `as HTMLSelectElement | null`.

Prefetch only links people are likely to click. `prefetchAll` on a page with 200 links downloads 200 pages.

**Simple lesson:** `navigate()` moves pages from code. Prefetching loads likely pages before the click.

## Checkpoint 3

- You can now build interactive Astro sites:
  - islands with the right `client:*` directive,
  - shared state between islands,
  - smooth navigation with `<ClientRouter />`,
  - persistent elements and router events,
  - prefetching.
- Try this: give Asha's journal a persistent "reading list" island in the header that keeps its items as you move between posts.

## TL;DR

1. **Default** = full page load on every click.
2. **ClientRouter** = `<ClientRouter />` in the layout `<head>`. Pages swap without a full reload.
3. **transition:name** = links an element on two pages, so it morphs.
4. **transition:animate** = `fade`, `slide`, `initial`, `none`, or `fade({ duration })`.
5. **transition:persist** = keep this element (and island state) across swaps.
6. **Scripts** = run once with the router. Put page setup in `astro:page-load`.
7. **navigate(url)** = router navigation from code.
8. **Prefetch** = `data-astro-prefetch` with `hover`, `tap`, `viewport` or `load`.
9. **data-astro-reload** = force a full load for one link.

## Common mistakes

<div className="tscroll">

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| `<ClientRouter />` on one page only | It works for the whole site | Put it in the shared layout | Some clicks still do full loads |
| Setup code outside `astro:page-load` | Scripts run on every page | Processed scripts run once | Buttons stop working after a swap |
| The same `transition:name` twice on a page | Names can repeat | Each name is unique per page | The animation breaks |
| No `transition:persist` on a player | The router keeps everything | It replaces the body | Music and island state reset |
| Using `<ViewTransitions />` | It is the current name | It is `<ClientRouter />` now | Import error |
| `prefetchAll` on huge pages | More prefetching is always faster | Prefetch likely links only | Wasted data |

</div>

## Check Yourself

## Exercises

## What to Remember

- Astro does full page loads by default. `<ClientRouter />` in the layout makes them smooth swaps.
- `transition:name` morphs matching elements. `transition:animate` picks the effect.
- `transition:persist` keeps elements and island state alive.
- With the router, scripts run once: use `astro:page-load` for page setup.
- `navigate()` moves from code. `data-astro-prefetch` loads pages before the click.

Next: **Endpoints and Server Data**, where your site starts to answer requests with data.
