Create a searchable, filterable directory
Serve a Northwind staff or client directory as a web app — search box, type-ahead, filtered list.
Published Jul 23, 2025
Northwind’s staff directory lives in a Sheet, which is fine until someone needs to find Priya in Operations and there are 240 rows. Sorting by name works if you remember the spelling; filter views work if you remember they exist. Neither helps the new starter trying to figure out who owns what.
This is a directory web app: one text box, one list, type-ahead filtering on every field. The data still lives in the Sheet — you edit it there — but the page renders a tiny client-side index so search feels instant. There is no database, no API, and nothing to keep in sync.
What you’ll need
- A Google Sheet acting as the directory source. Headers in row 1 — at
minimum
nameandrole; add any columns you like and they become searchable automatically. - An Apps Script project with one
.gsfile and one HTML file calledDirectory.html. - Editor access to the directory sheet from the account that deploys the web app.
The HTML (Directory.html)
<!-- The whole UI: one search box, one list. The data is injected at
render time so the page works with no further server calls. -->
<input id="q" placeholder="Search..." autofocus>
<ul id="list"></ul>
<script>
// Server-side data, baked into the page once on render. Edits to the
// sheet show up the next time someone loads the URL — there is no
// client polling.
const items = <?!= JSON.stringify(items) ?>;
const ul = document.getElementById('list');
// Renders the list filtered by `term`. We serialise the whole row to
// JSON before searching so the query matches across every column —
// name, role, team, anything else the sheet contains.
const render = (term) => {
const matches = items.filter((i) =>
JSON.stringify(i).toLowerCase().includes(term)
);
ul.innerHTML = matches
.map((i) => '<li>' + escape(i.name) + ' — ' + escape(i.role) + '</li>')
.join('');
};
// Minimal HTML escape — sheet data is trusted-ish but never trustworthy
// enough to interpolate raw.
const escape = (s) => String(s == null ? '' : s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
document.getElementById('q')
.addEventListener('input', (e) => render(e.target.value.toLowerCase()));
// Render once on load so the full list is visible before anyone types.
render('');
</script>
The script
// The sheet that holds the directory rows. Headers in row 1; one row
// per person.
const DIRECTORY_SHEET_ID = '1abcDirectoryId';
/**
* Serves the directory page with the whole roster pre-loaded. We do
* this once at render time rather than on every keystroke so the page
* is responsive and the script quota stays small.
*/
function doGet() {
const t = HtmlService.createTemplateFromFile('Directory');
t.items = readDirectory();
return t.evaluate()
.setTitle('Northwind directory')
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
/**
* Reads the directory sheet into an array of objects keyed by header.
* Drops blank rows so the rendered list never includes empty lines.
*
* @returns {Object[]} Directory rows.
*/
function readDirectory() {
const [headers, ...rows] = SpreadsheetApp.openById(DIRECTORY_SHEET_ID)
.getSheets()[0]
.getDataRange()
.getValues();
return rows
.filter((r) => r.some((cell) => cell !== '' && cell !== null))
.map((r) => Object.fromEntries(headers.map((k, i) => [k, r[i]])));
}
How it works
doGetopensDirectory.htmlas a template, setsitemsto the parsed directory rows, and evaluates the template. The whole roster is baked into the page as JSON.readDirectoryreads the sheet once withgetDataRange().getValues()and turns each row into a{header: value}object — adding a column to the sheet automatically makes it searchable without a code change.- The page renders the full list on load, then re-filters on every
keystroke. Filtering is client-side: it serialises each row to JSON
and runs a case-insensitive
includeson the lower-cased query. - Because every column is included in the search string, typing
opsmatches anyone whose row contains the word — name, team, role, notes. - Output is escaped before being injected into the DOM. Sheet data is not user-submitted in this example, but the habit is cheap and the bug it prevents is expensive.
Example run
The directory sheet:
| name | role | team |
|---|---|---|
| Ada Lovelace | Lead engineer | Platform |
| Priya Patel | Operations | Ops |
| Marco Rossi | Account manager | Sales |
| Holt & Sons | Customer | — |
Typing ops filters the list to one entry: “Priya Patel — Operations”.
Clearing the box restores all four rows. Typing lovelace matches Ada by
name; typing platform matches Ada by team. The filter is instant — no
network roundtrip.
Deploy it
- Click Deploy → New deployment, choose Web app, set Execute as to your account and Who has access to Anyone in your domain so only staff can open the directory.
- Open the
/execURL. The directory loads once with every row inlined; typing into the box filters without any further network calls. - To pick up sheet edits, just reload the page. There is no cache to
invalidate — the data is whatever
getDataRangereturned at render time.
Watch out for
- The full roster is sent to the browser on every page load. For a few
hundred people that is a few kilobytes of JSON — fine. Past a couple of
thousand rows, switch to server-side search (a
google.script.runcall per query) or paginate. - Search is a plain substring match, not fuzzy. Misspellings will not match. For typo-tolerant search, ship a small Levenshtein helper to the page — but resist the urge to install a full search library for what is essentially a table.
- HTML templating with
<?!= ?>does not auto-escape. Always escape before injecting intoinnerHTML, even when the data looks safe. Theescapehelper above is the minimum. - Sensitive columns get shipped to the browser if you read them. Trim
readDirectoryto only the fields you want exposed — phone numbers, internal IDs and the like should stay on the server side. - Anyone in your domain can view; nobody can edit through the page. If
you need self-service updates (a person updating their own role), wire
a second
google.script.runcall that writes back to the sheet and check the caller’s email against the row they are editing.
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