How to deal with caching and dynamic content in Nuxt?
Filip Rakowski
Posted on October 5, 2022
The cache is one of the most powerful weapons when you want to make your web application fast. Delivering static, pre-rendered pages from the closest location can result in a great performance, but setting it up on the server without making your frontend application ready to be cached can lead to unpleasant outcomes. In the best-case scenario, you will annoy your users by breaking the app. Worst-case scenario, you will violate GDPR rules. This is certainly a situation that none of us want to experience! Don't worry - from this article, you will learn everything you need to know to make your Server-Side or Statically generated Single Page Applications ready to be cached (and hopefully distributed through a CDN)!
⚠️ Side Note: I use Nuxt.js in code examples, but the concepts and solutions are not tied to any framework.
We cache HTML templates to improve performance.
Let's learn some theory first and familiarize ourselves with the concept of full-page caching. I found a very good one on Branch CMS documentation:
"Full page cache" means that the entire HTML output for the page will be cached. Subsequent requests for the page will return the cached HTML instead of trying to process and re-build the page, thus returning a response to the browser much faster
When you open a Server-Side Rendered application, the JavaScript code first runs on the server to generate the HTML file containing all your components and data rendered. This static file is then sent to the browser. Single-Page Application framework like Vue takes control over it and makes it dynamic. This process is called hydration. At this stage your application is fully interactive.
Generating the static HTML file on the server can take a few seconds. A few seconds when your user sees a blank screen and potentially leaves your website. According to Google's study, 1-3 seconds of load time increases the bounce rate probability by 32%!
To deal with this significant performance downside of Server-Side Rendering, developers started caching the first generated response and sending it to others. Thanks to that, we perform the time-consuming rendering step only once and send its outcome immediately to all users requesting it.
The cached template has to be generic.
When we cache an HTML page generated by a SPA SSR framework like Nuxt.js it contains both the generated HTML and application state as inlined JavaScript object after the server-side code execution.
Because of that you need to ensure that there is no session-specific content in both template and application state after server-side code execution. Otherwise, it will be cached and served to all users.
A session-specific content contains data and markup that can be different depending on a user or device - for example: displayed user or device name, conditional rendering statement for specific devices etc.
If you don’t remove session-specific content from the cached template, everyone will see a page with the data of the first user.
Execute session-specific content in the browser
Our applications are rarely entirely generic in the real world, though. There is nothing wrong with it - almost every website has some content that is dynamic and depends on a current user session.
it’s important to make the dynamic content rendering happen in an environment that is isolated for each user like their browser.
This way the generic content, generated on the server is distributed immediately to all of your users and then, in their browsers, parts that make the experience tailored specifically for them are injected.
How to deal with session-specific templates
The first thing that comes to mind when thinking about session-specific content is the one of the currently authenticated user. We obviously don't want other users to receive a page that is filled with someone else's data.
Let’s see an example of excluding session-specific parts of AppHeader
component from rendering on the server.
In Nuxt.js you can skip the server-side rendering of components wrapped with a built-in ClientOnly component.
<!-- components/AppHeader.vue -->
<header>
<AppLogo />
<!-- Keep the elements that are session-specific as client-only -->
<ClientOnly>
<button v-if="!isLoggedIn" @click="LogIn()">Log in</button>
<button v-else @click="logOut()" >Log out</butt>
<AppCart />
<AppWishlist />
</ClientOnly>
</header>
The server-side-rendered code of the above component will look more or less like this:
<header>
<img src="./assets/logo.png">
</header>
Then in the browser, the rest of the elements are added dynamically.
Improving the user experience with loading indicators and placeholders
Usually, the hydration happens in milliseconds, but on some devices, it could take even more than 10 seconds. Seeing empty elements on the template can be misleading and deliver a bad user experience. Users can feel that the website is broken, or they could miss some important elements that are loaded later. We should let them know that some elements are not there yet.
The most common and effective technique of indicating yet-to-be-loaded content are skeletons.
Skeleton screens **are blank pages that are progressively populated with content, such as text and images, as they become available.* (from ***uxdesign.cc)
Skeletons are much better option than spinners because they give user a hint of what content they can expect.
Let's take a look at a practical example of Linkedin. When you enter the page, not everything is loaded yet. You immediately receive a cached skeleton home screen, and the data is loaded progressively in the browser:
The Nuxt.js ClientOnly
component has a placeholder slot we can use to display a skeleton for the content that will gradually load on the client side. I used SfSkeleton component example from Storefront UI - eCommerce UI library we’ve built in Vue Storefront for our users.
<!-- components/AppHeader.vue -->
<header>
<AppLogo />
<!-- Keep the elements that are session-specific as client-only -->
<ClientOnly>
<!-- Display a placeholder until the page is hydrated -->
<template #placeholder>
<SfSkeleton type="paragraph" />
</template>
<button v-if="!isLoggedIn" @click="LogIn()">Log in</button>
<button v-else @click="logOut()" >Log out</butt>
<AppCart />
<AppWishlist />
</ClientOnly>
</header>
💡 Sometimes it is better to just use the generic state of the component as placeholder
What about the data?
Until now we talked only about the session-specific templates but what about data fetched inside components?
Most of the SSR frameworks like Nuxt or Next send the server-side state at the bottom of index.html
file that comes with the rendered HTML, so you don’t have to fetch the data twice - on the server and then on the client.
If you inspect your Nuxt or Next SSR response, you will see a similar piece of code injected at the end of the body
tag:
<script>window.__NUXT__={layout:"default",data:[{},{posts:[{userId:1,id:1,title:"sunt aut facere repellat provident occaecati excepturi optio reprehenderit",body:"quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"},{userId:1,id:2,title:"qui est esse",body:"est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"},{userId:1,id:3,title:"ea molestias quasi exercitationem repellat qui ipsa sit aut",body:"et iusto sed quo iure\nvoluptatem occaecati omnis eligendi aut [...]"}]}],error:null,serverRendered:!0}</script>
Because of that, we have to ensure that no session-specific data is in the cached HTML just like we did with the templates.
The data can be fetched from two places:
Inside the component
When the data is fetched inside the component, the case is simple. If the whole component is wrapped with ClientOnly
like in the example above the execution of the component code is completely skipped on the server, so the data is fetched for the first time when its executed in the browser.
<!-- components/AppHeader.vue -->
<header>
<AppLogo />
<!-- None of the code from components inside ClientOny will execute on the server -->
<ClientOnly>
<!-- ... -->
<AppCart :items="user.cart.items" />
</ClientOnly>
</header>
<!-- components/AppCart.vue -->
<template>
<!-- cart template -->
</template>
<script setup>
import { ref } from 'vue'
const cart = ref({})
async function fetchCart () {
const res = await fetch('...')
return res.json()
}
cart.value = await fetchCart()
</script>
If you’re REST APIs and have multiple requests on your page, executing the fetching logic inside the components is best. It’s much harder to miss the session-specific data if it’s not all around your app.
In the parent component
If you follow a smart/dumb components pattern aka. presentational/container (explained well by Dan Abramov here, and no longer a go-to approach since the introduction of React Hooks/Composition API) and fetch all the logic in the parent component, you have to take care of the session-specific data separately in the parent component.
<!-- components/AppHeader.vue -->
<AppHeader>
<AppLogo />
<!-- None of the code from components inside ClientOny will execute on the server -->
<ClientOnly>
<!-- ... -->
<AppCart :items="user.cart.items" />
</ClientOnly>
</AppHeader>
<script>
import { ref } from 'vue'
const cart = ref({})
async function fetchCart () {
const res = await fetch('...')
return res.json()
}
// onMounted runs only in the browser (client context)
onMounted(() => {
cart.value = await fetchCart()
})
// ...other logic
</script>
Summary
To make your app cacheable and CDN-ready you have to avoid session-specific content in the cached content rendered on the server.
You have to render your application in a few steps - a generic one that renders most of the page and a session-specific one that injects dynamic parts.
Liked the article? Follow me on Twitter to get daily tips about web development.
Posted on October 5, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 7, 2020