Build a Sheets-powered mini CRM app
Manage Northwind contacts in a real interface — search, edit, log activity.
Published Aug 28, 2025
Northwind’s contact list lives in a spreadsheet, which is fine for storage but awkward for daily use. Anyone who needs to look someone up has to scroll a wide grid, and logging a call means finding the right row and typing into a cramped cell. People stop bothering, and the activity history quietly goes stale.
This automation puts a proper front end on that same Sheet. A small web app lists every contact, opens a detail page for any one of them, and lets the team add timestamped notes from a form. The data never leaves Sheets — the app is just a friendlier window onto it — so you keep the spreadsheet you already trust and gain an interface people will actually use.
What you’ll need
- A
Contactsspreadsheet with a header row. The script expects anidcolumn plus whatever fields you track —name,email,company, and so on. - A
Notesspreadsheet with three columns:contactId,note,date. Each note row links back to a contact by itsid. - Two HTML files in the same Apps Script project —
CrmList.htmlandCrmDetail.html— for the list and detail views. - The project deployed as a web app (Deploy → New deployment → Web app).
The script
// The two spreadsheets that back the app.
const CONTACTS_SHEET_ID = '1abcContactsId';
const NOTES_SHEET_ID = '1abcNotesId';
/**
* Web app entry point. With no parameters it shows the contact list;
* with ?id=X it shows the detail page for that one contact.
*/
function doGet(e) {
return e.parameter.id
? renderContact(e.parameter.id)
: renderList();
}
/**
* Renders the full contact list into the CrmList template.
*/
function renderList() {
const t = HtmlService.createTemplateFromFile('CrmList');
// Hand the template every contact as an array of plain objects.
t.contacts = readSheet(CONTACTS_SHEET_ID);
return t.evaluate();
}
/**
* Renders the detail page for a single contact, including its notes.
*/
function renderContact(id) {
const contacts = readSheet(CONTACTS_SHEET_ID);
// Keep only the notes that belong to this contact.
const notes = readSheet(NOTES_SHEET_ID).filter((n) => n.contactId === id);
const t = HtmlService.createTemplateFromFile('CrmDetail');
t.contact = contacts.find((c) => c.id === id);
t.notes = notes;
return t.evaluate();
}
/**
* Appends a timestamped note for a contact. Called from the detail
* page via google.script.run.
*/
function addNote(id, text) {
// Bail out early on an empty note so blank rows never land in the sheet.
if (!text || !String(text).trim()) return;
SpreadsheetApp.openById(NOTES_SHEET_ID).getSheets()[0]
.appendRow([id, text, new Date()]);
}
/**
* Reads a sheet and returns its rows as objects keyed by the header row.
* One helper, used for both the contacts and the notes sheets.
*/
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]])));
}
How it works
doGetis the single entry point Apps Script calls for every request. It checks the URL: if there is anidparameter it shows one contact, otherwise it shows the list.renderListreads the contacts spreadsheet, hands the whole array to theCrmListtemplate, and returns the evaluated HTML — a clickable list.renderContactreads both sheets, finds the matching contact, filters the notes down to that contact’scontactId, and passes both to theCrmDetailtemplate.addNoteis called from the detail page throughgoogle.script.run. It guards against empty input, then appends a row ofcontactId, note text, and the current timestamp to the notes sheet.readSheetdoes the shared work — it pairs each header cell with each row cell so the rest of the code works with named objects instead of array indexes.
Example run
The Contacts sheet holds plain rows:
| id | name | company | |
|---|---|---|---|
| c1 | Priya Shah | Lumen Co | [email protected] |
| c2 | Tom Reed | Harbour Ltd | [email protected] |
Visiting the deployed web app URL shows both names as links. Clicking Priya
opens ?id=c1, which renders her details plus any notes already filed against
c1. Submitting “Called re: renewal — happy to extend” through the form appends
a row to the Notes sheet:
| contactId | note | date |
|---|---|---|
| c1 | Called re: renewal — happy to extend | 2026-05-25 14:32 |
Reload the detail page and the new note appears at the bottom of her history.
Run it
This is an on-demand app, so the “running” step is the deployment:
- In the Apps Script editor, choose Deploy → New deployment → Web app.
- Set Execute as to yourself and Who has access to your Northwind domain (or a wider audience if needed).
- Share the deployment URL with the team — that link is the app.
After any code change, redeploy to the same deployment so the URL stays stable.
Watch out for
- Every request re-reads the spreadsheets. That is fine for a few hundred
contacts; for thousands, the list page will feel slow. Cache the contact list
with
CacheServiceor paginate the results. - There is no locking on
addNote.appendRowis safe for occasional use, but if many people file notes at once you may wantLockServiceto serialise the writes. doGetreturns plain HTML — anyone with the URL can read every contact. Set Who has access carefully, and never widen it to “Anyone” for real customer data.- IDs must be unique and stable.
renderContactmatches onid, so reusing or renumbering anidwill point notes at the wrong person. - HtmlService renders inside a sandboxed iframe. Keep the templates simple — some external scripts and styles will not load.
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