---
name: nuxt4-patterns
description: Build and fix Nuxt 4 apps with safe SSR, matching hydration, route rules, lazy loading, and data fetching with useFetch and useAsyncData. Use for page data, dynamic routes, browser-only code, caching, and speed work.
origin: ECC
---

# Nuxt 4 Patterns

## Credit

**Original skill by ECC.**

Use this skill when you build or fix a Nuxt 4 app.

## Use This Skill For

- Server HTML that does not match the first browser render
- SSR and browser-only code
- Static pages, SWR, ISR, and route rules
- Slow pages or large page data
- Lazy loading and lazy hydration
- `useFetch`, `useAsyncData`, and `$fetch`
- Bugs tied to route params or middleware

## Safe Hydration

The server and browser must make the same HTML on the first render.

- Do not put `Date.now()` or `Math.random()` in server-rendered HTML.
- Do not read `window`, `document`, storage, or browser size during SSR.
- Move browser-only work into `onMounted()`.
- You may also use `import.meta.client`, `<ClientOnly>`, or a `.client.vue` file.
- Use Nuxt's `useRoute()`. Do not import `useRoute` from `vue-router`.
- Do not use `route.fullPath` to build server HTML. The URL hash only exists in the browser.
- Do not hide a mismatch with `ssr: false` unless the whole route needs the browser.
- Use one time zone and locale when text must match on both sides.
- Check for bad HTML, such as a `<div>` inside a `<p>`. The browser may change it.
- Give lists stable and unique `:key` values.
- Do not show user data from browser storage during the first render. Read it after mount.

## Fetch Data Safely

Use `await useFetch()` for a simple API read in a page or component. Nuxt can pass the server result to the browser. This helps stop a second request.

```ts
const { data, status, error } = await useFetch('/api/products')
```

Use `useAsyncData()` when you need:

- More than one data source
- A custom data function
- A stable cache key
- Data from a library other than `$fetch`

```ts
const route = useRoute()

const slug = computed(() => String(route.params.slug))

const { data: article, status, error, refresh } = await useAsyncData(
  () => `article:${slug.value}`,
  () => $fetch(`/api/articles/${slug.value}`),
  {
    watch: [slug],
  },
)
```

Follow these rules:

- Make each cache key stable and unique.
- Put route params in the key when the data depends on them.
- Add `watch` when the same page can stay open while a param changes.
- Return real data from the handler. Do not return `undefined`.
- Keep the handler free of side effects. Do not send mail, write data, or track events in it.
- Use `$fetch()` for form saves, button actions, and other user actions.
- Do not use top-level `$fetch()` for page data that must come from SSR.
- Use `lazy: true`, `useLazyFetch()`, or `useLazyAsyncData()` for data that can load later.
- Show a loading state when `status === 'pending'`.
- Show a useful error state when a request fails.
- Use `server: false` only when the first page view and search tools do not need the data.
- Use `pick` to keep only fields the page needs.
- Use shallow data when deep updates are not needed.
- Use `runtimeConfig` for private values. Never send private keys to the browser.
- Read request headers and cookies with Nuxt server tools when SSR needs them.

## Route Rules

Set page render and cache rules in `nuxt.config.ts`.

```ts
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/products/**': { swr: 3600 },
    '/blog/**': { isr: true },
    '/admin/**': { ssr: false },
    '/api/**': { cache: { maxAge: 3600 } },
  },
})
```

- `prerender` makes HTML at build time.
- `swr` serves saved content, then updates it in the back end.
- `isr` rebuilds saved pages on hosts that support it.
- `ssr: false` renders the route in the browser.
- `cache` sets rules for server replies.
- `redirect` sends the user to another path.

Pick rules by route group. A shop page, blog, admin page, and API may each need a different rule.

Check these edge cases:

- Do not cache private or user-only data in a shared cache.
- Make sure the host supports the chosen ISR rules.
- Give content a clear refresh time.
- Keep admin routes out of static page builds when they need live user data.
- Test route patterns so broad rules do not catch the wrong pages.

## Lazy Loading

Nuxt already splits page code by route. Keep route boundaries clear before you split small components.

Use the `Lazy` name prefix for parts that are not needed at once.

```vue
<template>
  <LazyRecommendations v-if="showRecommendations" />
  <LazyProductGallery hydrate-on-visible />
</template>
```

- Use `v-if` so a lazy part is not loaded before it is needed.
- Use lazy hydration for parts below the first screen.
- Use `hydrate-on-visible` for a part that can wait until it is seen.
- Use `defineLazyHydrationComponent()` for a custom visible or idle rule.
- Lazy hydration works with Vue single-file components.
- A new prop can cause a lazy component to hydrate at once.
- Use `<NuxtLink>` for links inside the app.
- Keep key content and main actions out of delayed parts.
- Test that delayed controls still work with a keyboard.

## Concrete Example

This page loads a product on the server. Reviews load later in the browser.

```vue
<script setup lang="ts">
const route = useRoute()
const id = computed(() => String(route.params.id))

const {
  data: product,
  status: productStatus,
  error: productError,
} = await useAsyncData(
  () => `product:${id.value}`,
  () => $fetch(`/api/products/${id.value}`),
  {
    watch: [id],
    pick: ['id', 'name', 'price'],
  },
)

const {
  data: reviews,
  status: reviewStatus,
  error: reviewError,
} = await useFetch(
  () => `/api/products/${id.value}/reviews`,
  {
    lazy: true,
    server: false,
    watch: [id],
  },
)
</script>

<template>
  <main>
    <p v-if="productStatus === 'pending'">Loading product...</p>
    <p v-else-if="productError">Could not load this product.</p>

    <article v-else-if="product">
      <h1>{{ product.name }}</h1>
      <p>{{ product.price }}</p>
    </article>

    <section>
      <h2>Reviews</h2>
      <p v-if="reviewStatus === 'pending'">Loading reviews...</p>
      <p v-else-if="reviewError">Could not load reviews.</p>
      <ul v-else>
        <li v-for="review in reviews" :key="review.id">
          {{ review.text }}
        </li>
      </ul>
    </section>
  </main>
</template>
```

## Review List

Before you finish, check that:

- The server and first browser render make the same HTML.
- Browser-only code runs only in the browser.
- Page reads use `useFetch` or `useAsyncData`.
- Data keys include values that change the result.
- Loading, empty, and error states are shown.
- Private data is not put in a shared cache.
- Route rules match search and freshness needs.
- Heavy parts load or hydrate later.
- Main text and actions are ready at first load.
- Dynamic route changes load the right data.