One-time setup to stand up the site from scratch. Reproducible in ~60 minutes. Once done, see MAINTENANCE.md for day-to-day operation.
- Prerequisites
- Google Sheet (the CMS)
- Google Form (public submissions)
- MapTiler (geocoding)
- Cloudflare Pages (hosting)
- Apps Script (automation)
You'll need free accounts on:
| Service | Used for | Free tier ceiling |
|---|---|---|
| Sheet (CMS), Form (submissions), Apps Script (automation) | Generous — nothing to worry about at this scale | |
| MapTiler Cloud | Geocoding only | 100k geocodes/month |
| Cloudflare | Hosting (Pages) | Unlimited bandwidth, 500 builds/month |
| GitHub | Source control + Pages connector | Free for private repos |
Local dev needs:
- Node.js 20+
- A clone of this repo
- A copy of
.env.examplesaved as.envand filled in (you do this as you work through this guide)
The sheet is the only place the admin ever touches.
-
Create a new Google Sheet at sheets.new, titled Social Impact Businesses.
-
Rename the first tab to
Locations(right-click the tab at the bottom → Rename). This name is load-bearing:- The build reads the sheet via
?sheet=Locationsin the CSV export URL. Gviz silently falls back to the first tab if the name doesn't match, so the build might "work" on the wrong data without complaining — you'll notice only when rows don't appear. - The Apps Script's
SHEET_NAMEconstant matches exactlyLocations. If your tab isSheet1, the Apps Script will log a loud warning in its Executions log but won't update the sheet or trigger deploys. - If you need a different tab name, update three places in lockstep: the tab itself,
SHEET_NAMEinscripts/apps-script.gs, andSHEET_TABin your.env/ Cloudflare env vars.
- The build reads the sheet via
-
Add these headers in row 1, in any order you like (code reads by name):
Name | Slug | Address | City | State | Lat | Lng | Geocoded Address | Website | Mission | Populations Served | Hiring Model | Photo URL | Type | Featured | Status | Notes | Last Modified -
Freeze row 1 (View → Freeze → 1 row) and color it gray so it's visually obvious these are code-referenced.
-
Paste this into the sheet description (⋮ menu on the tab → "Description"):
⚠️ The site reads this sheet by column header name. You can reorder columns freely but renaming a header will break the next build (site stays up on the previous build until fixed). Same goes for renaming theLocationsorTypestabs.
The Type column on the Locations tab references values defined in a second tab called Types. This keeps the type catalog (labels, colors, icons) admin-editable without a code change.
-
At the bottom of the sheet, click + to add a second tab. Rename it to
Types(exact spelling). -
Add these headers in row 1 (order doesn't matter, code reads by name):
key | label | color | icon -
Freeze row 1 and color it gray, same as the Locations tab.
-
Seed the catalog with the four default rows below. The
iconvalue must be a slug that exists insrc/lib/location-type-icons.ts(currently:coffee,restaurant,shop,bag,bakery,heart,book,leaf,sparkle,hands). The build fails loudly on an unknown slug:key label color icon coffee-shopCoffee shop #5d4037coffeecafeCafe #c97551restaurantbakeryBakery #8d6e63bakeryotherOther #4a7c59heartkeymust be kebab-case (lowercase letters, digits, hyphens). It's what the Locations tab'sTypecolumn stores.labelis what users see in the filter picker and pill — plain title-case text.colormust be a 6-digit hex (#RRGGBB). This colors the pin on the map and the dot on the filter pill.iconis a bare slug — no.svgextension, no path.
Select whole columns on the Locations tab and add validation rules (Data → Data validation):
- Slug (col B): formula — set cell B2 to
=ARRAYFORMULA(IF(A2:A="", "", LOWER(REGEXREPLACE(TRIM(REGEXREPLACE(A2:A, "[^A-Za-z0-9]+", " ")), " ", "-"))))so slugs auto-derive from Name. - Type (col N): Dropdown with Criteria: Dropdown (from a range), range =
Types!A2:A. Set "Reject input" on invalid. This keeps the Type column in sync with whatever you have on the Types tab — add a row there, and it shows up in the dropdown here. - Populations Served (col K): Dropdown, Allow multiple selections checked, values like
Recovery,Reentry,Disability employment,Youth,Veterans(your list). - Hiring Model (col L): Dropdown, values like
Direct hire,Training program,Apprenticeship. - Featured (col O): Checkbox.
- Status (col P): Dropdown with
Pending,Approved,Hidden. Set "Reject input".
File → Share → Anyone with the link → Viewer.
Reasoning: the Cloudflare build reads the sheet via its public CSV export. The URL acts as a weak secret — don't commit it to a public place. Personal data (submitter emails) is not in the sheet (it stays inside Google Forms' own response view), so the worst-case disclosure is your Notes column. Don't write secrets in Notes.
The sheet ID is the long string between /d/ and /edit in the URL. Paste it into .env:
SHEET_ID=14vfJwho2stohQTcERMvdCdNzran5P60rv8WdMIfy3sE
-
In the sheet, Tools → Create a new form. Google auto-links the form to the sheet — submissions append as new rows.
-
Form title:
Suggest a location. Add these questions, mapping 1:1 to sheet columns:Name(short answer, required)Address,City,State(short answer each)Website(short answer, optional)Mission(paragraph, required)Populations Served(checkboxes — values match your sheet multi-select)Hiring Model(dropdown — values match your sheet dropdown)Photo URL(short answer, optional — paste a Google Drive or hosted image URL)Type(multiple choice — values must match thekeycolumn of yourTypestab; e.g.coffee-shop,cafe,bakery,other). Google Forms can't data-validate against a sheet range, so keep this list synced manually: whenever you add a row to theTypestab, also add the option here. If the admin wants looser validation, a short-answer field also works — the admin re-maps it to a valid key when approving.Are you suggesting an edit to a location already on the map?(short answer, optional — paste URL or location name if yes)
-
Do not include a Status question — the form leaves Status blank, and the admin sets it to Pending/Approved manually (or you can have Apps Script default it to Pending on form submit).
-
Form → Responses → ⚙ → Collect email addresses — keeps submitter emails inside the form's response view only (never lands in the sheet).
-
Form → Send → Embed HTML → copy the
<iframe src="…">URL and paste into.env:PUBLIC_FORM_URL=https://docs.google.com/forms/d/e/.../viewform?embedded=true
Used only for geocoding (the Apps Script calls it). Map tiles come free from OpenFreeMap — no key needed.
- Sign up at https://www.maptiler.com/cloud/
- Dashboard → Account → API Keys → use the default or create one named
changeplease. - Leave Allowed Origins blank. The Apps Script calls this from Google's servers, where there's no Origin header. Origin restriction would block it.
- Copy the key — you'll paste it into Apps Script in section 6.
(The browser-facing PUBLIC_MAPTILER_API_KEY is reserved but currently unused since tiles are free from OpenFreeMap. Leave it blank or set it later if you swap tile providers.)
This step must be done in the UI because the API can't install the Cloudflare GitHub App on your behalf.
- Sign up at https://dash.cloudflare.com/sign-up if you haven't.
- Cloudflare dashboard → Workers & Pages → Create application → Pages tab (NOT the default Workers tab). If you only see a "Deploy command" field, you're in the Workers flow — cancel and find the Pages tab, or go directly to
https://dash.cloudflare.com/?to=/:account/pages/new. - Connect to Git → select
alwaysmap/changeplease. - Build settings can be left as defaults — the script in 5.2 sets them.
- Skip the env-vars dialog — the script handles them.
- Save and Deploy. First build will FAIL (no env vars yet); that's expected, we fix it now.
Add three Cloudflare values to .env:
CLOUDFLARE_API_TOKEN=… # dash.cloudflare.com/profile/api-tokens
# → Create Token → Custom → Permissions:
# Account → Cloudflare Pages: Edit
CLOUDFLARE_ACCOUNT_ID=… # Right sidebar of any Cloudflare dashboard page
CLOUDFLARE_PAGES_PROJECT=changeplease # Whatever you named the project
Run:
set -a; source .env; set +a
npm run setup:cloudflareThis:
- Pushes
SHEET_ID,PUBLIC_FORM_URL,PUBLIC_SITE_URL,NODE_ENV=productionto Production + Preview scopes - Sets
npm run build/distas the build config - Creates the
sheet-on-approvedeploy hook (idempotent; won't duplicate) - Prints the deploy hook URL — you'll paste it into Apps Script in section 6
Re-run the script any time .env values change. Full sync, idempotent.
⚠️ The deploy hook URL is not synced automatically to Apps Script. If the hook is ever deleted and recreated (e.g. cleanup, or a different automation) the URL changes. Apps Script stores its copy in a Script Property — and will silently POST to the old, now-404'd URL until you update it. Wheneversetup:cloudflareprints a hook URL that's different from what's in Apps Script, update the Script Property (section 6.2). This is the single most common "edits stopped deploying" trap.
Cloudflare Pages project → Settings → Custom domains → Add.
This is what automates the whole thing: geocoding on approval, deploy-trigger on change, Last-Modified timestamps.
In your sheet: Extensions → Apps Script. Delete the default Code.gs contents. Paste the full contents of scripts/apps-script.gs from this repo.
In the Apps Script editor sidebar: Project Settings (gear icon) → Script Properties → Add script property, twice:
| Property | Value |
|---|---|
MAPTILER_API_KEY |
Your MapTiler key from section 4 |
CLOUDFLARE_DEPLOY_HOOK_URL |
The URL printed by npm run setup:cloudflare in section 5.2 |
In the Apps Script editor sidebar: Triggers (clock icon) → Add Trigger. Create two:
Trigger 1 — on edit:
- Function:
handleEdit - Event source:
From spreadsheet - Event type:
On edit
Trigger 2 — on form submit:
- Function:
handleFormSubmit - Event source:
From spreadsheet - Event type:
On form submit
On each trigger you create, set "Failure notification settings" to "Notify me immediately". This makes Google email the script owner (you) whenever a trigger throws. Free early-warning system — no polling, no code, just a checkbox.
Google will ask you to authorize the script's permissions on first save. It needs: read/write the sheet, call external URLs (MapTiler + Cloudflare), read script properties. Authorize.
The script includes a dailyHealthCheck function that verifies end-to-end wiring (sheet accessible, all required headers present, MapTiler key working, deploy hook URL well-formed) and emails you if anything's off. Good insurance — even if you stop looking at the site for a while, you'll know when it breaks.
To enable:
- Apps Script → ⏱ Triggers → + Add Trigger
- Function:
dailyHealthCheck - Event source: Time-driven
- Type: Day timer, pick a time (e.g. 9am-10am)
- Failure notifications: Notify me immediately
- Save
On first run, Google prompts for additional Gmail-send permission (needed for the failure alert). Authorize.
Whenever you re-run npm run setup:cloudflare and get a new deploy hook URL, paste it into the Script Property CLOUDFLARE_DEPLOY_HOOK_URL. Then verify it works without waiting for a real sheet edit:
- Apps Script editor → open the function dropdown at the top (next to Run / Debug)
- Select
verifyDeployHook - Click Run
- Executions log shows either
PASS: deploy hook responded 200. URL is valid.or a specificFAIL:message with the problem
This catches the "I pasted the wrong URL" bug the instant it happens, not three days later when an admin edit silently fails to deploy.
- In the sheet, add a test row on the Locations tab: fill Name, Address, City, State, Mission, Type (pick one from the dropdown — e.g.
coffee-shop). Leave Lat/Lng blank. - Change Status to
Approved. - Watch the row: within a few seconds, Lat/Lng and Geocoded Address should populate automatically. Last Modified should update.
- Watch Cloudflare Pages → Deployments: a build should kick off within a few seconds.
- ~30 seconds later, the new location is live at your site URL under
/location/<slug>, and its pin shows the color/icon from the corresponding row on theTypestab.
If nothing happens, check the Apps Script editor → Executions tab for logs from the trigger runs. Most common issue: script properties not set, or origin restriction still active on the MapTiler key.
Compare the Address column with the Geocoded Address column. If MapTiler resolved to something surprising (e.g., just a city name because the street was ambiguous), paste correct coordinates from a Google Maps right-click directly into Lat/Lng. The Apps Script doesn't overwrite non-blank lat/lng.
From here on: admin edits the sheet, the site updates itself. See MAINTENANCE.md for the day-to-day operator guide.