appscript.dev
Automation Intermediate

Build a multi-page web app with routing

Structure a real Northwind app across views — query-param routing, shared layout.

Published Oct 31, 2025

An Apps Script web app has one entry point — doGet — so it is tempting to cram everything into a single HTML file. Northwind’s internal admin tool did exactly that, and the file grew into an unreadable wall of display:none divs and toggling logic. Adding a page meant editing one giant template and hoping nothing else broke.

The fix is real routing. A query parameter (?r=clients) picks which view to show, each view lives in its own HTML file, and a shared Layout file wraps them all with the same navigation. The result behaves like a small multi-page site: clean URLs, one file per page, and a single place to change the chrome.

What you’ll need

  • An Apps Script project deployed as a web app.
  • One HTML file per page — Home.html, Clients.html, Invoices.html, Settings.html. Each holds only that page’s markup.
  • A Layout.html file holding the shared navigation and a slot for the page body.

The router

// Map of route keys to the HTML file that renders each page.
// The key is what appears in the URL (?r=clients); the value is
// the file name without the .html extension.
const ROUTES = {
  home: 'Home',
  clients: 'Clients',
  invoices: 'Invoices',
  settings: 'Settings',
};

// The route to fall back to when ?r is missing or unknown.
const DEFAULT_ROUTE = 'home';

/**
 * Single entry point for the web app. Reads the ?r query parameter,
 * renders the matching page into the shared layout, and returns it.
 *
 * @param {Object} e - The event object Apps Script passes to doGet.
 * @returns {HtmlOutput} The fully rendered page.
 */
function doGet(e) {
  // 1. Read the requested route, defaulting when none is given.
  const route = (e.parameter && e.parameter.r) || DEFAULT_ROUTE;

  // 2. Resolve it to a file name, falling back if the route is unknown.
  const file = ROUTES[route] || ROUTES[DEFAULT_ROUTE];

  // 3. Load the shared layout as a template so it can read variables.
  const layout = HtmlService.createTemplateFromFile('Layout');

  // 4. Render the page file and pass its HTML into the layout's body slot.
  layout.body = HtmlService.createHtmlOutputFromFile(file).getContent();

  // 5. Tell the layout which nav link is active, then evaluate.
  layout.activeRoute = route;
  return layout.evaluate().setTitle('Northwind — ' + route);
}

The layout (Layout.html)

<nav>
  <!-- Each link sets the ?r parameter; the active class is driven
       by activeRoute, passed in from doGet. -->
  <a href="?r=home"     class="<?= activeRoute === 'home' ? 'active' : '' ?>">Home</a>
  <a href="?r=clients"  class="<?= activeRoute === 'clients' ? 'active' : '' ?>">Clients</a>
  <a href="?r=invoices" class="<?= activeRoute === 'invoices' ? 'active' : '' ?>">Invoices</a>
  <a href="?r=settings" class="<?= activeRoute === 'settings' ? 'active' : '' ?>">Settings</a>
</nav>

<!-- The selected page's HTML is injected here. -->
<main><?!= body ?></main>

How it works

  1. doGet reads e.parameter.r. A URL ending in ?r=clients gives the route clients; a bare URL gives home from DEFAULT_ROUTE.
  2. ROUTES maps that key to a file name. An unknown route falls back to the default rather than throwing.
  3. Layout.html is loaded as a template (not plain output) so it can read the body and activeRoute variables.
  4. The chosen page file is rendered to a string with getContent() and assigned to layout.body. The layout drops that string in with <?!= body ?>, which prints unescaped HTML.
  5. activeRoute lets the layout add an active class to the current nav link, so the navigation highlights the page you are on.

Example run

URLRoutePage shown
.../exechomeHome.html
.../exec?r=clientsclientsClients.html
.../exec?r=invoicesinvoicesInvoices.html
.../exec?r=nopenopeHome.html (fallback)

Every page comes back wrapped in the same <nav>, with the correct link highlighted and the browser tab titled Northwind — clients.

Run it

Deploy the project as a web app to get a routable URL:

  1. In the Apps Script editor, choose Deploy > New deployment.
  2. Pick Web app, set who may access it, and click Deploy.
  3. Open the /exec URL — that is the home route.
  4. Append ?r=clients (or any route key) to jump straight to a page.

Watch out for

  • <?!= body ?> prints HTML unescaped, which is exactly what you want for a trusted page file. Never feed user input through it — use <?= ?> for anything a visitor supplied so it is escaped.
  • All routing happens server-side, so every navigation is a full page reload. That is fine for an internal tool; for snappier transitions you would fetch page bodies with google.script.run instead.
  • The ?r= links are relative. They work from the /exec URL but break if the app is embedded in an iframe with a different base — use absolute links there.
  • Add new pages in two places: a file and a ROUTES entry. Forgetting the ROUTES entry sends the page to the default route with no error.
  • doGet is the only entry point. Form posts need a separate doPost; this router does not handle them.

Related