Vercel Logo

Optimization

The app works. Pages load, filters filter, reviews submit. But we haven't thought about what happens when the dataset grows from 17 springs to 1,700, or when the server route gets hit 10,000 times a minute. Optimization in Nuxt follows the same principle as optimization everywhere: measure first, fix what matters, skip what doesn't.

We'll cover three techniques that apply to almost every Nuxt app: lazy-loaded components, server route caching, and payload optimization. None of these require a rewrite. They're configuration changes and small code tweaks.

Outcome

Optimize the app with lazy loading, caching, and leaner payloads.

Fast Track

  1. Prefix a component with Lazy for on-demand loading
  2. Add defineCachedEventHandler for server route caching
  3. Reduce the payload by selecting only needed fields

Hands-on exercise 5.3

Apply three optimization techniques to the hot springs app.

Requirements:

  1. Use LazySpringCard on the browse page to lazy-load the card component
  2. Add caching to the springs list server route with defineCachedEventHandler
  3. Create a lightweight springs list endpoint that returns only the fields needed for cards

Implementation hints:

  • Any component can be lazy-loaded by prefixing its name with Lazy. <SpringCard> becomes <LazySpringCard>. No import changes, no config
  • defineCachedEventHandler wraps a handler with a server-side cache. Set maxAge in seconds
  • For the browse page, cards only need: id, name, description (truncated), location, temperature, type, and elevation. The full description, features array, and imageUrl aren't needed in the list view

Lazy components:

In Next.js, you lazy-load components with dynamic():

const SpringCard = dynamic(() => import("./SpringCard"));

In Nuxt, prefix the component name with Lazy:

<!-- Eager (loads with the page) -->
<SpringCard :spring="spring" />
 
<!-- Lazy (loads when needed) -->
<LazySpringCard :spring="spring" />

No import, no dynamic(), no Suspense boundary. Nuxt generates the lazy wrapper at build time. The component loads when it enters the viewport or when it's first rendered. For a grid of 17 spring cards, lazy loading means the cards below the fold don't block the initial render.

Update the browse page template:

app/pages/springs/index.vue — template change
<div v-else-if="springs?.length">
  <LazySpringCard v-for="spring in springs" :key="spring.id" :spring="spring" />
</div>

One prefix. That's the change.

Server route caching:

The springs data comes from a JSON file that doesn't change at runtime. We can cache the API response:

server/api/springs/index.get.ts — with caching
import type { Spring } from "~/types/spring";
import springs from "~/server/data/springs.json";
 
export default defineCachedEventHandler(
  (event) => {
    const query = getQuery(event);
 
    let results = springs as Spring[];
 
    if (query.region && typeof query.region === "string") {
      results = results.filter(
        (s) =>
          s.location.region.toLowerCase() ===
          query.region!.toString().toLowerCase()
      );
    }
 
    if (query.type && typeof query.type === "string") {
      results = results.filter((s) => s.type === query.type);
    }
 
    if (query.search && typeof query.search === "string") {
      const term = query.search.toLowerCase();
      results = results.filter(
        (s) =>
          s.name.toLowerCase().includes(term) ||
          s.description.toLowerCase().includes(term)
      );
    }
 
    return results;
  },
  {
    maxAge: 60 * 60, // 1 hour
    varies: ["x-query"],
  }
);

defineCachedEventHandler wraps the handler with Nitro's built-in caching layer. The response is cached for one hour. Different query parameters get different cache entries (the varies option). After an hour, the next request regenerates the cache.

In Next.js, you'd achieve similar caching with export const revalidate = 3600 on a page, or unstable_cache for server-side data, or by setting Cache-Control headers manually on Route Handler responses. Nuxt puts the caching decision on the server route itself, which means the same cache applies regardless of which page calls it.

For our static JSON data, you could set maxAge to a day or more. For a real database, you'd balance freshness against load.

Payload optimization:

When the browse page loads via SSR, useFetch serializes the entire API response into the HTML payload. Seventeen springs with full descriptions, features arrays, and coordinates adds up. The cards only need a subset of fields.

You can use pick on useFetch to select only the fields you need:

app/pages/springs/index.vue — optimized fetch
const { data: springs, status } = useFetch("/api/springs", {
  query: queryParams,
  pick: ["id", "name", "description", "location", "temperature", "type", "elevation"],
});

There's a catch: pick works on the top level of the response object, but our data is an array. For array responses, use transform instead:

app/pages/springs/index.vue — with transform
const { data: springs, status } = useFetch("/api/springs", {
  query: queryParams,
  transform: (data) =>
    data.map(({ id, name, description, location, temperature, type, elevation }) => ({
      id,
      name,
      description,
      location,
      temperature,
      type,
      elevation,
    })),
});

The transform function runs after the fetch and before the data is serialized into the payload. It strips out features and imageUrl, which the card component doesn't use. For 17 springs, the savings are small. For 1,700, they're significant.

Measure before you optimize

Nuxt DevTools has a Payload tab that shows the size of serialized data. Check it before and after optimization to verify you're actually reducing the payload. Premature optimization is still the root of all evil, even in Nuxt.

Caching and authentication

Don't cache routes that return user-specific data. Our favorites and visited routes should NOT use defineCachedEventHandler because the response depends on who's logged in. The springs list is safe to cache because it's the same for everyone.

Try It

  1. Update the browse page to use LazySpringCard. The page should still work the same way, but check the Network tab. The SpringCard chunk should load separately
  2. Add caching to the springs list route. Make two requests to /api/springs. The second should be faster (served from cache)
  3. Add the transform to the browse page fetch. Check the Payload tab in Nuxt DevTools to see the reduced data

Commit

git add -A && git commit -m "feat(perf): add lazy loading, caching, and payload optimization"

Done-When

  • The browse page uses LazySpringCard for lazy-loaded cards
  • The springs list API route uses defineCachedEventHandler with a 1-hour TTL
  • You can explain when to use transform vs pick with useFetch
  • You know which routes should NOT be cached (user-specific ones)

Solution

See the code blocks above for the lazy component prefix change, the cached event handler, and the transform function. The changes are minimal: one prefix, one wrapper function, and one transform.