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.htmlfile 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
doGetreadse.parameter.r. A URL ending in?r=clientsgives the routeclients; a bare URL giveshomefromDEFAULT_ROUTE.ROUTESmaps that key to a file name. An unknown route falls back to the default rather than throwing.Layout.htmlis loaded as a template (not plain output) so it can read thebodyandactiveRoutevariables.- The chosen page file is rendered to a string with
getContent()and assigned tolayout.body. The layout drops that string in with<?!= body ?>, which prints unescaped HTML. activeRoutelets the layout add anactiveclass to the current nav link, so the navigation highlights the page you are on.
Example run
| URL | Route | Page shown |
|---|---|---|
.../exec | home | Home.html |
.../exec?r=clients | clients | Clients.html |
.../exec?r=invoices | invoices | Invoices.html |
.../exec?r=nope | nope | Home.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:
- In the Apps Script editor, choose Deploy > New deployment.
- Pick Web app, set who may access it, and click Deploy.
- Open the
/execURL — that is thehomeroute. - 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.runinstead. - The
?r=links are relative. They work from the/execURL 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
ROUTESentry. Forgetting theROUTESentry sends the page to the default route with no error. doGetis the only entry point. Form posts need a separatedoPost; this router does not handle them.
Related
Build a branded approval interface
Approve Northwind requests through a custom UI — clients click, decision is logged.
Updated Nov 8, 2025
Build an interactive quiz or assessment app
Run Northwind tests with scoring and feedback — questions in a Sheet, results in another.
Updated Nov 4, 2025
Build a form-to-PDF web service
Convert Northwind form submissions to PDFs on the fly — POST in, PDF out.
Updated Oct 27, 2025
Build an expiring secure-download generator
Issue time-limited Northwind links via a web app — token in URL, server-side check.
Updated Oct 23, 2025
Build a guided onboarding tour for Sheets
Walk Northwind's first-time users through dialogs — each step explains one feature.
Updated Oct 19, 2025