Add instant navigation and seamless page transitions to a web app

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).

The sample app screenshot on mobile The sample app screenshot on mobile

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.

the demo image how bfcache improve the loading time

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.

Shared Element Transitions API demo from pixiv Shared Element Transitions API demo from Tokopedia

Demos from pixiv and Tokopedia

Prerequisites

Knowledge of:

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

  1. Navigate to about://flags, and then enable the Prerender2 and documentTransition API runtime flags.
  2. Restart your browser.

Get the code

  1. Open the code from this GitHub repository in your favorite development environment:
git clone -b codelab git@github.com:googlechromelabs/instant-seamless-demo.git
  1. Install the dependencies required to run the server:
npm install
  1. Start the server on port 3000:
npm run dev
  1. 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:

  1. 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>
  )
}
  1. Import the PrerenderButton component in the list-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>
  )
}
  1. 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])
}
  1. 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
  1. 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.

The sample app demo video for prerendering

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 the unload event handler with the code for the pagehide 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:

  1. Navigate to the page in Chrome.
  2. 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.

49bf965af35d5324.png

If successful, the panel tells you that the the page was restored from back-forward cache:

47015a0de45f0b0f.png

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.

dcf0312c3fc378ce.png

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.

How Private Prefetch Proxy works

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:

  1. 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.

  1. 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).

Shared Element Transitions API demo from pixiv Shared Element Transitions API demo from Tokopedia

Demos from pixiv and Tokopedia

To integrate Shared Element Transitions API for the SPA part of the sample app:

  1. 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
}
  1. Call the usePageTransitionPrep() custom hook in the list page and then call the async function to trigger the transition.start() method inside the click 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>
  )
}
  1. On the details page, call the usePageTransition() hook to finish the transition.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.

The sample app demo video without Shared Element Transition The sample app demo video with Shared Element Transition

7. Congratulations

Congratulations! You created an instant and seamless web app with a low-friction, engaging, and intuitive user experience.

Learn more

Prerendering

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.