Spotted a bug? Have a great idea? Help us make google.dev great!

In this codelab, you'll learn how to test and improve single-page apps (SPAs) to make them 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

  • How to test a website or web app for potential SEO problems
  • Basic principles of search-friendly web apps
  • How to make a JavaScript web app search-friendly

What you'll need

  • Experience with JavaScript
  • The sample code
  • Chrome (or an equivalent browser that can inspect the JavaScript console)

You'll use a sample website for this codelab. The project files are stored in Glitch, which is a tool for creating web apps and code projects. To create your own, editable copy of the project for this codelab, click Copy the Glitch project:

Copy the Glitch project

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 Mobile-Friendly Test. The Mobile-Friendly Test utility 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 that Googlebot doesn't see the news articles either and that users won't be able to find them in Google Search.

In 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 register the service worker. This is expected because Googlebot renders the page like a first-time visitor would see the page, and doesn't persist data across crawls. The problem isn't that our web app has a service worker, but the way we initialize our web app:

// we want our web app to work offline and load faster, so we try to install a service worker.
if ('serviceWorker' in navigator) {
  // this browser supports service workers!
  navigator.serviceWorker.register('sw.js').then(initApp);
} else {
  // this browser doesn't support service workers, so we don't install one.
  initApp();
}

Our code incorrectly assumes that a browser that supports service workers always succeeds when registering our service worker. There is no error handling in this code snippet.

To fix this problem, we need to handle rejection and exceptions as follows:

// we want our web app to work offline and load faster, so we try to install a service worker.
if ('serviceWorker' in navigator) {
  // this browser supports service workers!
  navigator.serviceWorker.register('sw.js').then(initApp, initApp).catch(initApp);
} else {
  // this browser doesn't support service workers, so we don't install one.
  initApp();
}

Test your changes

Test whether your changes were successful by running 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:

  • Page titles
  • Description snippets

To help users to quickly identify the page that is most relevant to their goals, give each view of your application a unique, helpful title and a 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 just 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 the app.js file 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 the app.js file on line 59:

document.querySelector('meta[name="description"]')
  .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 users 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 then allows Google Search to understand the relationship between them, and maybe show the sample app when users are looking for these articles.

Change the code to use links instead of buttons by changing your code as shown below.

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

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

With this:

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

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

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

With this new code:

item.querySelector('a').setAttribute('href', story.link);

When you click 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:

  • One piece of code handles hashchange events to load the content when the hash in the URL changes.
  • Another piece of code loads the content based on the hash when the page is opened.

To make sure that 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 that is sent to you when the user navigates back to a previous page.

In the index.html file, 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">
  <li>
    <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>
  </li>
  <li>
    <a class="mdc-list-item  spa-link" href="/chrome">
      <span class="mdc-list-item__text">Google Chrome</span>
    </a>
  </li>
  <li>
    <a class="mdc-list-item spa-link" href="/search">
      <span class="mdc-list-item__text">Google Search</span>
    </a>
  </li>
</ul>

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

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

Use this new 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 the app.js file on line 29:

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

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

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

// 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';
  showStoriesForCategory(category);
});

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';
  showStoriesForCategory(category);
});

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 and 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 the index.html file:

<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 of 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 will 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 the server.js file serves the index.html file 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 shown in 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 (e.g., /not-found) and redirect to it from JavaScript if the sample app encounters a problem.

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

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

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

.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 now 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 by Google Search.

What we've covered

  • How to test a website or web app for potential SEO problems
  • Basic principles of search-friendly web apps
  • How to make a JavaScript web app search-friendly

Learn more

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