1. Before you begin
This codelab teaches you how to add instant navigation and seamless page transitions to a sample web app with the latest APIs that Google Chrome natively supports.
The sample web app checks the nutritional values of popular fruits and vegetables. The fruit-list and fruit-details pages are built as a single-page app (SPA), and the vegetable-list and vegetable-details pages are built as a traditional multiple-page app (MPA).
Specifically, you implement prerendering, back/forward cache (bfcache), and Private Prefetch Proxy for the instant navigation, and root/shared element transitions for the seamless page transitions. You implement prerendering and bfcache for the MPA pages, and shared element transitions for the SPA pages.
Site speed is always an important aspect of the user experience, which is why Google introduced Core Web Vitals, a set of metrics that measure load performance, interactivity, and visual stability of web pages to gauge the real-world user experience. The latest APIs help you improve the Core Web Vitals score of your website in the field, especially for load performance.
Demo from Mindvalley
Users are also accustomed to the use of transitions to make navigations and state changes extremely intuitive in mobile native apps. Unfortunately, the replication of such user experiences isn't straightforward on the web. While you might be able to achieve similar effects with current web-platform APIs, development may be too difficult or complex, especially when compared to feature counterparts in Android or iOS apps. Seamless APIs are designed to fill this user and developer experience gap between app and web.
Demos from pixiv and Tokopedia
Prerequisites
Knowledge of:
- HTML
- CSS
- JavaScript
- Google Chrome Developer Tools
What you'll learn:
How to implement:
- Prerendering
- bfcache
- Private Prefetch Proxy
- Root/shared element transitions
What you'll build
A sample web app built with Next.js that's enriched with the latest instant and seamless browser capabilities:
- Near-instantaneous navigation with prerendering
- bfcache for instant loads with the browser's backward and forward buttons
- Great first impressions from cross-origin navigation with Private Prefetch Proxy or signed exchange (SXG)
- A seamless transition between pages with root/shared elements transition
What you'll need
- Chrome version 101 or higher
2. Get started
Enable Chrome flags
- Navigate to about://flags, and then enable the
Prerender2
anddocumentTransition API
runtime flags. - Restart your browser.
Get the code
- Open the code from this GitHub repository in your favorite development environment:
git clone -b codelab git@github.com:googlechromelabs/instant-seamless-demo.git
- Install the dependencies required to run the server:
npm install
- Start the server on port 3000:
npm run dev
- Navigate to http://localhost:3000 in your browser.
Now you can edit and improve your app. Whenever you make changes, the app reloads and your changes are directly visible.
3. Integrate prerendering
For the purpose of this demo, the load time of the vegetable-details page in the sample app is very slow due to an arbitrary delay on the server side. You eliminate this wait time with prerendering.
To add prerender buttons to the vegetable-list page and let them trigger prerendering after the user clicks:
- Create a button component, which inserts the speculation-rules script tag dynamically:
components/prerender-button.js
import { useContext } from 'react'
import ResourceContext from './resource-context'
// You use resource context to manage global states.
// In the PrerenderButton component, you update the prerenderURL parameter when the button is clicked.
export default function PrerenderButton() {
const { dispatch } = useContext(ResourceContext)
const handleClick = (e) => {
e.preventDefault()
e.stopPropagation()
const parent = e.target.closest('a')
if (!parent) {
return
}
const href = parent.getAttribute('href')
dispatch({ type: 'update', prerenderURL: href })
}
return (
<button className='ml-auto bg-gray-200 hover:bg-gray-300 px-4 rounded' onClick={handleClick}>
Prerender
</button>
)
}
- Import the
PrerenderButton
component in thelist-item.js
file.
components/list-item.js
// Codelab: Add a PrerenderButton component.
import PrerenderButton from './prerender-button'
...
function ListItemForMPA({ item, href }) {
return (
<a href={href} className='block flex items-center'>
<Icon src={item.image} />
<div className='text-xl'>{item.name}</div>
{/* Codelab: Add PrerenderButton component. */}
<PrerenderButton />
</a>
)
}
- Create a component to add Speculation Rules API.
The SpeculationRules
component dynamically inserts a script tag into the page when the app updates the prerenderURL
state.
components/speculationrules.js
import Script from 'next/script'
import { useContext, useMemo } from 'react'
import ResourceContext from './resource-context'
export default function SpeculationRules() {
const { state } = useContext(ResourceContext)
const { prerenderURL } = state
return useMemo(() => {
return (
<>
{prerenderURL && (
<Script id='speculationrules' type='speculationrules'>
{`
{
"prerender":[
{
"source": "list",
"urls": ["${prerenderURL}"]
}
]
}
`}
</Script>
)}
</>
)
}, [prerenderURL])
}
- Integrate the components with the app.
pages/_app.js
// Codelab: Add the SpeculationRules component.
import SpeculationRules from '../components/speculationrules'
function MyApp({ Component, pageProps }) {
useAnalyticsForSPA()
return (
<ResourceContextProvider>
<Layout>
<Component {...pageProps} />
</Layout>
{/* Codelab: Add SpeculationRules component */}
<SpeculationRules />
<Script id='analytics-for-mpa' strategy='beforeInteractive' src='/analytics.js' />
</ResourceContextProvider>
)
}
export default MyApp
- Click Prerender.
Now you can see the significant loading improvement. In the real use case, prerendering is triggered for the page that the user is likely to visit next by some heuristics.
Analytics
By default, the analytics.js
file in the sample web app sends a page-view event when the DOMContentLoaded
event happens. Unfortunately, this isn't wise because this event fires during the prerendering phase.
To introduce a document.prerendering
and prerenderingchange
event to fix this issue:
- Rewrite the
analytics.js
file:
public/analytics.js
const sendEvent = (type = 'pageview') => {
// Codelab: Make analytics prerendering compatible.
// The pageshow event could happen in the prerendered page before activation.
// The prerendered page should be handled by the prerenderingchange event.
if (document.prerendering) {
return
}
console.log(`Send ${type} event for MPA navigation.`)
fetch(`/api/analytics?from=${encodeURIComponent(location.pathname)}&type=${type}`)
}
...
// Codelab: Make analytics prerendering compatible.
// The prerenderingchange event is triggered when the page is activated.
document.addEventListener('prerenderingchange', () => {
console.log('The prerendered page was activated.')
sendEvent()
})
...
Awesome, you successfully modified your analytics so that they're compatible with prerendering. Now you can see the page-view logs with the right timing in the browser console.
4. Remove bfcache blockers
Remove the unload
event handler
Having an unnecessary unload
event is a very common mistake that's not recommended anymore. Not only does it prevent bfcache from working, but it's also unreliable. For example, it doesn't always fire on mobile and Safari.
Instead of an unload
event, you use the pagehide
event, which fires in all cases when the unload
event fires and when a page is put in the bfcache.
To remove the unload
event handler:
- In the
analytics.js
file, replace the code for theunload
event handler with the code for thepagehide
event handler:
public/analytics.js
// Codelab: Remove the unload event handler for bfcache.
// The unload event handler prevents the content from being stored in bfcache. Use the pagehide event instead.
window.addEventListener('pagehide', () => {
sendEvent('leave')
})
Update the cache-control header
Pages served with a Cache-control: no-store
HTTP header don't benefit from the browser's bfcache feature, so it's good practice to be frugal with this header. In particular, if the page doesn't contain personalized or critical information, such as logged in state, you probably don't need to serve it with the Cache-control: no-store
HTTP header.
To update the cache-control header of the sample app:
- Modify the
getServerSideProps
code:
pages/vegetables/index.js
export const getServerSideProps = middleware(async (ctx) => {
const { req, res } = ctx
// Codelab: Modify the cache-control header.
res.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59')
...
pages/vegetables/[name].js
export const getServerSideProps = middleware(async (ctx) => {
const { req, res, query } = ctx
// Codelab: Modify the cache-control header.
res.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59')
...
Determine whether a page is restored from bfcache
The pageshow
event fires right after the load
event when the page initially loads and any time that the page is restored from bfcache. The pageshow
event has a persisted
property, which is true if the page was restored from bfcache and false if it wasn't. You can use the persisted
property to distinguish regular page loads from bfcache restores. Major analytics services should be aware of bfcache, but you can check whether the page is restored from bfcache and send events manually.
To determine whether a page is restored from bfcache:
- Add this code to the
analytics.js
file.
public/analytics.js
// Codelab: Use the pageshow event handler for bfcache.
window.addEventListener('pageshow', (e) => {
// If the persisted flag exists, the page was restored from bfcache.
if (e.persisted) {
console.log('The page was restored from bfcache.')
sendEvent()
}
})
Debug a web page
Chrome Developer Tools can help you test your pages to ensure that they're optimized for bfcache and identify any issues that may make them ineligible.
To test a particular page:
- Navigate to the page in Chrome.
- In Chrome Developer Tools, click Application > Back-forward Cache > Run Test.
Chrome Developer Tools attempts to navigate away and then back to determine whether the page could be restored from bfcache.
If successful, the panel tells you that the the page was restored from back-forward cache:
If unsuccessful, the panel tells you that the page wasn't restored and the reason why. If the reason is something that you can address as a developer, the panel also tells you so.
5. Enable cross-site prefetching
Prefetching starts fetches early so that the bytes are already at the browser when the user navigates, which accelerates navigation. It's an easy way to improve Core Web Vitals and offset some network activity ahead of the navigation. This directly accelerates the Largest Contentful Paint (LCP), and gives more room for First Input Delay (FID) and Cumulative Layout Shift (CLS) upon navigation.
Private Prefetch Proxy enables cross-site prefetch, but doesn't reveal private information about the user to the destination server.
Enable cross-site prefetching with Private Prefetch Proxy
Website owners retain control of prefetching through a well-known traffic-advice resource, analogous to /robots.txt
for web crawlers, which lets an HTTP server declare that implementing agents should apply the corresponding advice. Currently, website owners can advise the agent to disallow or throttle network connections. In the future, other advice may be added.
To host a traffic-advice resource:
- Add this JSON-like file:
public/.well-known/traffic-advice
[
{
"user_agent": "prefetch-proxy",
"google_prefetch_proxy_eap": {
"fraction": 1
}
}
]
The google_prefetch_proxy_eap
field is a special field for the early-access program and the fraction
field is a field to control the fraction of requested prefetches that the Private Prefetch Proxy sends.
Traffic advice should be returned with application/trafficadvice+json
MIME type.
- In the
next.config.js
file, configure the response header:
next.config.js
const nextConfig = {
// Codelab: Modify content-type for traffic advice file.
async headers() {
return [
{
source: '/.well-known/traffic-advice',
headers: [
{
key: 'Content-Type',
value: 'application/trafficadvice+json',
},
],
},
]
},
}
module.exports = nextConfig
6. Integrate Shared Element Transitions API
When a user navigates on the web from one page to another, the content that they see changes suddenly and unexpectedly as the first page disappears and the new page appears. This sequenced, disconnected user experience is disorienting and results in a higher-cognitive load because the user is forced to piece together how they got to where they are. Additionally, this experience increases how much users perceive the page loading while they wait for the desired destination to load.
Smooth loading animations lower the cognitive load because users stay in context while they navigate between the pages, and reduce the perceived latency of loading because users see something engaging and delightful in the meantime. For these reasons, most platforms provide easy-to-use primitives that let developers build seamless transitions, such as Android, iOS, MacOS, and Windows.
Shared Element Transitions API provides developers with the same capability on the web, irrespective of whether the transitions are cross-document (MPA) or intra-document (SPA).
Demos from pixiv and Tokopedia
To integrate Shared Element Transitions API for the SPA part of the sample app:
- Create a custom hook to manage the transition in the
use-page-transition.js
file:
utils/use-page-transition.js
import { useEffect, useContext, useRef, useCallback } from 'react'
import ResourceContext from '../components/resource-context'
// Call this hook on this first page before you start the page transition. For Shared Element Transitions, you need to call the transition.start() method before the next page begins to render, and you need to do the Document Object Model (DOM) modification or setting of new shared elements inside the callback so that this hook returns the promise and defers to the callback resolve.
export const usePageTransitionPrep = () => {
const { dispatch } = useContext(ResourceContext)
return (elm) => {
const sharedElements = elm.querySelectorAll('.shared-element')
// Feature detection
if (!document.createDocumentTransition) {
return null
}
return new Promise((resolve) => {
const transition = document.createDocumentTransition()
Array.from(sharedElements).forEach((elm, idx) => {
transition.setElement(elm, `target-${idx}`)
})
transition.start(async () => {
resolve()
await new Promise((resolver) => {
dispatch({ type: 'update', transition: { transition, resolver } })
})
})
})
}
}
// Call this hook on the second page. Inside the useEffect hook, you can refer to the actual DOM element and set them as shared elements with the transition.setElement() method. When the resolver function is called, the transition is initiated between the captured images and newly set shared elements.
export const usePageTransition = () => {
const { state, dispatch } = useContext(ResourceContext)
const ref = useRef(null)
const setRef = useCallback((node) => {
ref.current = node
}, [])
useEffect(() => {
if (!state.transition || !ref.current) {
return
}
const { transition, resolver } = state.transition
const sharedElements = ref.current.querySelectorAll('.shared-element')
Array.from(sharedElements).forEach((elm, idx) => {
transition.setElement(elm, `target-${idx}`)
})
resolver()
return () => {
dispatch({ type: 'update', transition: null })
}
})
return setRef
}
- Call the
usePageTransitionPrep()
custom hook in the list page and then call the async function to trigger thetransition.start()
method inside theclick
event.
Inside the function, the shared-element
class elements are collected and registered as shared elements.
components/list-item.js
// Codelab: Add the Shared Element Transitions API.
import { usePageTransitionPrep } from '../utils/use-page-transition'
...
function ListItemForSPA({ item, href }) {
// Codelab: Add Shared Element Transitions.
const transitionNextState = usePageTransitionPrep()
const handleClick = async (e) => {
const elm = e.target.closest('a')
await transitionNextState(elm)
}
return (
<Link href={href}>
<a className='block flex items-center' onClick={handleClick}>
<Icon src={item.image} name={item.name} className='shared-element' />
<div className='text-xl'>{item.name}</div>
</a>
</Link>
)
}
- On the details page, call the
usePageTransition()
hook to finish thetransition.start()
callback function.
In this callback, shared elements in the detail page are also registered.
pages/fruits/[name].js
// Codelab: Add the Shared Element Transitions API.
import { usePageTransition } from '../../utils/use-page-transition'
const Item = ({ data }) => {
const { name, image, amountPer, nutrition } = data
// Codelab: Add the Shared Element Transitions API.
const ref = usePageTransition()
return (
<div className={'flex flex-col items-center justify-center py-4 px-4 sm:flex-row'} ref={ref}>
<div className='flex flex-col items-center sm:w-2/4'>
<Image
className='object-cover border-gray-100 border-2 rounded-full shared-element'
src={image}
width='240'
height='240'
alt={`picture of ${name}`}
/>
<h1 className='text-4xl font-bold mt-4'>{name}</h1>
</div>
<div className='sm:w-2/4 w-full'>
<Nutrition amountPer={amountPer} nutrition={nutrition} />
</div>
</div>
)
...
}
Now you can see that the image elements are shared on the list and detail pages, and seamlessly connected in the page transition. You can even customize the animation to make it fancier with CSS pseudo-elements.
7. Congratulations
Congratulations! You created an instant and seamless web app with a low-friction, engaging, and intuitive user experience.
Learn more
Prerendering
- Prerendering, revamped
- Bringing instant page-loads to the browser through speculative prerendering
- quicklink
bfcache
Cross-site prefetching
Signed exchanges
Root/shared element transitions
These APIs are still in the early stages of development, so please share your feedback at crbug.com or as issues in the Github repository of the relevant APIs.