In this codelab, you'll learn how to test and improve single-page apps (SPAs) to make sure it's search-friendly. To do so, you will check an existing SPA for technical SEO problems. You will then change the code of the SPA to fix these problems.

What you'll learn

What you'll need

The sample web app and its code is available on this website. To make changes to it, you need to create your personal copy.

Clone the sample app

The sample app is a news website that lists stories in different categories and lets the user read individual stories or switch between categories.

The sample app lets users pick a topic and shows a selection of articles for the selected topic.

To check if the sample web app is search-friendly, use the Mobile-Friendly Test. The Mobile-Friendly Test shows you how Googlebot views the web app.

The Mobile-Friendly Test shows that none of the stories are visible to Googlebot.

Take a look at the screenshot on the right - besides the menu bar, the page is blank. It doesn't show any of the articles. This means Googlebot doesn't see the news articles either and users won't be able to find them in Google Search.

In the Mobile-Friendly Test, click View Details and navigate to the JavaScript console messages section.

Fix the JavaScript code

The error you found suggests that Googlebot can't execute ES6 code. This happens because Googlebot uses Chrome 41 and this version of Chrome does not support ES6. One way to fix these errors is to convert the sample app's ES6 code into ES5. There are transpilers, like Babel, that do exactly that. To convert the code with Babel, follow the steps below:

  1. Open Babel REPL in your browser.
  2. In the Babel REPL, copy and paste the code from your Glitch project's app.js file into the Babel code editor.
    The ES5 version of the code generates in the output panel.
  3. Copy the code that was generated from the output panel.
  4. Replace all code in your remixed Glitch app.js file with the new ES5 code you copied from Babel.

Test your changes

Test if your changes were successful by running the Mobile-Friendly Test again. The screenshot should contain at least one article. If you check the "HTML" tab, you should see the HTML for all article cards appear.

The Mobile-Friendly Test shows that Googlebot can now see the articles.

In Search results, users see two things very prominently:

To help users quickly identify the page that is most relevant to their goals, give each view of your application a unique, helpful title and description. Here are some examples of good and bad titles and descriptions:

Search results that use relevant page titles and helpful descriptions are better for representing your content on Google Search. This image shows different search results that have the page title and description for every result.Search results that use relevant page titles and helpful descriptions are better for representing your content on Google Search. This image shows different search results that have unique page title and description for every result.

To set the page title in your remixed Glitch app.js file, add the following lines to the showStoriesForCategory function right before line 70:

  // set the title
  document.title = "News about " + stories[0].category;

To make the meta description more helpful and unique, add the following code to app.js on line 29 to provide a snippet depending on the chosen category:

// Some snippets for the different categories:
var snippets = {
  'the_keyword': 'News and stories around Google products',
  'chrome': 'Articles and insights about new features in Chrome.',
  'search': 'Updates and interesting stories around Google Search'

Next, add the following code to app.js on line 59:

  .setAttribute('content', snippets[category]);

This code updates the meta description snippet with the description of the current category.

Googlebot uses links to discover pages of a website and it does the same to discover individual views in the sample SPA.

Make links that Googlebot can discover

The sample app uses buttons to allow people to read the articles they're interested in. The only thing the button does is redirect the user to the URL of the article, so you're going to change the buttons to links.

Googlebot can then discover the connection between the sample app and the articles which allows Google Search to understand the relationship between them and maybe show the sample app when people are looking for these articles.

Change the code to use links instead of buttons by changing your code as seen below:

Replace this in index.html on lines 82-84:

<button class="mdc-button mdc-button--outlined">
  <span class="mdc-button__label">Learn more</span>

To this:

<a class="mdc-button mdc-button--outlined">Learn more</a>

And replace the following code in app.js on lines 79-81 from this:

item.querySelector('button').addEventListener('click', function () {
  location.href =;

To this new code:


When you click on the navigation links in the sidebar and watch the URL, you will notice that hash URLs are used. As Googlebot discards the hash part of the URL, it only sees the homepage and not the category pages. Googlebot should index the category pages as well, because some users might be searching for those specifically.

In the app.js file, there's code that handles these fragment URLs:

To make sure Googlebot can index the category pages, you can use the History API instead of hash-based routing. The History API allows you to use URLs without fragment identifiers to load the categories. You'll need to do four things:

  1. Change the navigation links in the HTML to use paths instead of hashes.
  2. Load the initial content based on the pathname instead of the hash.
  3. Intercept clicks to the navigation links, so you can load the data with JavaScript.
  4. Handle the popstate event which is sent to you when the user navigates back to a previous page.

In index.html, change the navigation links to use paths instead of hashes. Replace the href attributes in the navigation links to look like this:

<ul class="mdc-list">
    <a class="mdc-list-item mdc-list-item--activated spa-link" href="/" aria-selected="true">
      <span class="mdc-list-item__text">The keyword</span>
    <a class="mdc-list-item  spa-link" href="/chrome">
      <span class="mdc-list-item__text">Google Chrome</span>
    <a class="mdc-list-item spa-link" href="/search">
      <span class="mdc-list-item__text">Google Search</span>

To load the initial content for the given hash URL, replace the code snippet below from app.js on line 39:

// Load the content for the current hash
var category = window.location.hash.slice(1); // remove the leading '#'

with this code to use the path from the URL instead:

// Load the content for the current URL
var category = trimSlashes(window.location.pathname);

To handle clicks on the new navigation links to prevent the browser from doing a full-page refresh, add this code to app.js on line 29:

window.addEventListener('click', function (evt) {
  if (!'spa-link')) return;

  var category = trimSlashes('href'));
  // if the category is empty, show the_keyword as the homepage.
  if (category == '') category = 'the_keyword';
  // update history
  window.history.pushState({category: category}, window.title,'href'));

To load the content for the URL if the user navigates back in the browser history, remove the hashchange event handler from app.js:

// whenever the hash of the URL changes, load the view for the new hash
window.addEventListener('hashchange', function (evt) {
  var category = window.location.hash.slice(1); // removes the leading '#'
  // if the category is empty, show the_keyword as the homepage.
  if (category == '') category = 'the_keyword';

To load the right content for the URL, replace the code you removed with this code that uses the popstate event from the History API:

// The browser navigates through the browser history, time to update our view!
window.addEventListener('popstate', function (evt) {
  // if this history entry has 'state' (that is when you created it), use the state
  // if it's a "real" browser history entry, find out what URL it comes from.
  var category = event.state ? event.state.category : trimSlashes(window.location.pathname);
  if (category == '') category = 'the_keyword';

Congratulations! Your web app now uses proper URLs that Googlebot can see and users can easily link to.

Be descriptive in your link text

Lighthouse is a great tool to get a feeling for the state of your web app's SEO. Run the SEO audit and get a report like this:

Lighthouse SEO audit report with improvements for our web app.

The Lighthouse audit shows that 11 of your links don't have descriptive link text. Good, descriptive link text makes it clear what the link does or where it goes.

Right now the news articles have a "Learn more" link that isn't very descriptive. It's better to describe what the user can do when clicking the link - they can read the article.

To provide better link text, change the following code in index.html:

<a class="mdc-button mdc-button--outlined">Learn more</a>

With this code snippet:

<a class="mdc-button mdc-button--outlined">Read the article</a>

Congratulations! Googlebot can find all your pages and your links are descriptive and helpful.

Error handling is an important part of every web application and yours is no exception.

Whenever you run into a problem or someone tries to load a category that doesn't exist, your sample app shows an error message:

An Oops error in the sample web page.

But what happens if someone links to a page in the sample app that doesn't exist? This can happen if you removed a category or users made a mistake.

When Googlebot crawls a page that links to your sample app, it would discover the link to the invalid URL of your sample app and try to crawl and index it. On a classical website, this isn't a problem because the server would respond with an HTTP 404 status and Googlebot would know that the page doesn't exist.

But in your sample app, the server doesn't know which URL is valid and which isn't, because the client-side JavaScript makes that decision.

The code in server.js serves index.html for all requests:

// Point all routes (e.g. /chrome) to index...
app.get('*', (req, res) => {
  res.sendFile(ROOT_FOLDER + '/index.html');

This is called a Soft 404 and could lead to undesirable Search results. In the following example, the description for the web page is the error message:

A bad example of search result. It shows the error message as the description.

This Search result would take the user to an error page instead of Android news articles. Luckily, Google Search often detects soft 404 pages and won't display them in Search results.

The easiest way to avoid the problem is to give your server a way to properly respond to invalid URLs. You can do that by writing a server that knows which URLs are valid and which aren't. Or, you can provide an error route. For example, /not-found and redirect to it from JavaScript if the sample app encounters a problem.

To make sure Googlebot gets a meaningful HTTP status code (404 in this case), add the code below before the app.get handler in server.js:

// if something redirects here, give it a 404 status and "Not found" message!
app.get('/not-found', (req, res) => {

To redirect to /not-found if an invalid category is being requested, change the code in app.js to:

.then(function (cards) {
  // add the article card to the view
  cards.forEach(function(card) { listContainer.appendChild(card); });
}).catch(function (e) {
  window.location.href = '/not-found'; // new error handler!

Congratulations! Your SPA also handles invalid URLs properly and only valid URLs will be indexed by Googlebot.

Congratulations! You have made a web app search-friendly! It can now be found via Google Search.

What we've covered

Learn more

To keep learning about making search-friendly websites, visit Get started with Search: a developer's guide.