Build a client-facing progress tracker
Show Northwind project status through a web app — clients self-serve their updates.
Published Oct 7, 2025
Northwind clients want to know where their projects stand, and the answer is always the same email: “everything is on track, here is what is next”. Writing those updates by hand is repetitive, and a client who asks twice in a week gets two slightly different stories depending on who replied.
This script gives each client a private status page instead. They open a link, the web app reads their projects straight from the tracking sheet, and they see the current status for themselves — no email, no chasing, and the single source of truth is the same sheet the team already updates.
What you’ll need
- A
ProjectsGoogle Sheet with a header row including aclientIdcolumn plus whatever status fields you want to show — for exampleprojectName,status,nextMilestoneanddueDate. - A short, unguessable value in the
clientIdcolumn for each client — a random string works well, so one client’s link cannot reveal another’s. - An HTML file named
Progressin the same Apps Script project (see below). - The project deployed as a web app so clients can open it.
The script
// The spreadsheet that holds the project status rows.
const PROJECTS_SHEET_ID = '1abcProjectsId';
/**
* Web app entry point. Reads the client id from the URL, filters the
* Projects sheet to that client, and renders their status page.
*/
function doGet(e) {
const clientId = e.parameter.c;
// Bail out early if the link is missing its client id.
if (!clientId) return HtmlService.createHtmlOutput('Missing client id');
// Keep only the rows that belong to this client.
const projects = readSheet(PROJECTS_SHEET_ID)
.filter((p) => p.clientId === clientId);
// Pass the filtered projects into the Progress template and render it.
const t = HtmlService.createTemplateFromFile('Progress');
t.projects = projects;
return t.evaluate();
}
/**
* Reads a sheet into an array of objects keyed by the header row.
*/
function readSheet(id) {
const [h, ...rows] = SpreadsheetApp.openById(id).getSheets()[0]
.getDataRange().getValues();
return rows.map((r) => Object.fromEntries(h.map((k, i) => [k, r[i]])));
}
The Progress HTML file renders the status list. A minimal version:
<!DOCTYPE html>
<html>
<body style="font-family: Arial, sans-serif; max-width: 560px; margin: 40px auto;">
<h2>Your projects</h2>
<? if (projects.length === 0) { ?>
<p>No projects found for this link.</p>
<? } else { ?>
<? for (const p of projects) { ?>
<div style="border-bottom: 1px solid #ddd; padding: 12px 0;">
<strong><?= p.projectName ?></strong>
<p>Status: <?= p.status ?></p>
<p>Next: <?= p.nextMilestone ?> (due <?= p.dueDate ?>)</p>
</div>
<? } ?>
<? } ?>
</body>
</html>
How it works
doGetruns whenever a client opens the web app URL. It reads thecquery parameter — the client id, the part after?c=in the link.- If there is no
c, it returns a short message instead of an empty page. readSheetreads the wholeProjectssheet into objects keyed by header name, so each row is easy to work with.- The rows are filtered down to only those whose
clientIdmatches the link, so a client never sees another client’s projects. - The filtered list is passed into the
Progresstemplate. - The template loops over the projects and renders each one’s status and next milestone — or a friendly message if the client has no projects.
Example run
The Projects sheet holds rows for several clients:
| clientId | projectName | status | nextMilestone | dueDate |
|---|---|---|---|---|
| x9k2 | Brand refresh | In progress | Final artwork | 2026-06-10 |
| x9k2 | Site rebuild | Awaiting content | Homepage draft | 2026-06-20 |
| m4p7 | Print campaign | Complete | — | — |
The client behind x9k2 opens https://script.google.com/.../exec?c=x9k2
and sees just their two projects:
Brand refresh — Status: In progress — Next: Final artwork (due 2026-06-10)
Site rebuild — Status: Awaiting content — Next: Homepage draft (due 2026-06-20)
The m4p7 row never appears on that page.
Run it
This is an on-demand web app, so there is no trigger to set:
- In the Apps Script editor, click Deploy > New deployment.
- Choose Web app, set Execute as to yourself and Who has access to Anyone so external clients can open the link.
- Copy the web app URL and append
?c=plus the client’s id — that full link is what you send each client.
Pair with Build a branded approval interface so clients can also approve deliverables.
Watch out for
- The
clientIdis the only thing keeping pages private. Use long, random values, not sequential numbers, or a client could view another’s status by editing the URL. - The page is read-only and reflects the sheet exactly. If the team has not updated a row, the client sees stale information — keep the sheet current.
- Every page load reads the entire
Projectssheet. That is fine for hundreds of rows; for a very large sheet, consider caching or splitting by client. - “Execute as me” means the web app reads the sheet with your permissions, so
clients never need access to the spreadsheet — but be careful what columns
the
Progresstemplate renders, as anything in a client’s rows is visible. - There is no login. Anyone with the link can open it, so treat the URL like a password and send it over a channel the client controls.
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 multi-page web app with routing
Structure a real Northwind app across views — query-param routing, shared layout.
Updated Oct 31, 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