Build a public form with conditional logic
Show Northwind form fields based on earlier answers — branchy intake without Google Forms.
Published Sep 13, 2025
Northwind takes briefs through a single intake form, but a new-client brief and a follow-up on an existing project ask for completely different things. Google Forms can branch between sections, but it cannot show or hide a field on the basis of a dropdown without splitting the form into clumsy multi-page sections. The result is a long form where most people skip half the fields, then someone has to chase the gaps.
This automation is a small Apps Script web app instead. It serves one HTML page where the visible fields change the moment the visitor picks a request type, and it writes each submission straight to a Sheet. There is no Forms quota, no account sign-in, and you control the markup, so the branching is exactly as clever as you need it to be.
What you’ll need
- A Google Sheet to collect submissions, with a header row:
Timestamp,Type,Company,Project. Copy its ID from the URL. - An Apps Script project containing two files: a
Form.htmlfile for the page and a.gsfile for the server code. - The project deployed as a web app (covered under Deploy it), set to execute as you and accessible to anyone.
The HTML
This is the Form.html file. The update() function toggles which block is
visible; submit() hands the values to the server with google.script.run.
<!-- Form.html — the single page the visitor sees. -->
<form id="f">
<!-- Changing this dropdown calls update() to reveal the right block. -->
<label>Type:
<select id="type" onchange="update()">
<option>New brief</option><option>Existing project</option>
</select>
</label>
<!-- Shown only for a "New brief" request. -->
<div id="new" style="display:block">
<input id="company" placeholder="Company">
</div>
<!-- Shown only for an "Existing project" request. -->
<div id="existing" style="display:none">
<input id="project" placeholder="Project name">
</div>
<button onclick="submit(event)">Send</button>
</form>
<script>
// Show the block that matches the selected type, hide the other.
const update = () => {
const t = document.getElementById('type').value;
document.getElementById('new').style.display = t === 'New brief' ? '' : 'none';
document.getElementById('existing').style.display = t === 'Existing project' ? '' : 'none';
};
// Send the answers to the server, then confirm to the visitor.
const submit = (e) => {
e.preventDefault();
google.script.run
.withSuccessHandler(() => { document.getElementById('f').innerHTML = 'Thanks — we have your brief.'; })
.handleForm({
type: document.getElementById('type').value,
company: document.getElementById('company').value,
project: document.getElementById('project').value,
});
};
</script>
The script
This is the server-side .gs file. doGet serves the page; handleForm
receives the submission and appends a row.
// The Sheet that collects submissions. Copy the ID from its URL.
const SUBMISSIONS_SHEET_ID = '1abcSubmissionsId';
/**
* Serves the conditional intake form to anyone who opens the web app URL.
*/
function doGet() {
return HtmlService.createHtmlOutputFromFile('Form')
.setTitle('Northwind brief');
}
/**
* Receives a submission from the page and appends one row to the Sheet.
* Only the field relevant to the chosen type is filled — the other stays blank.
*
* @param {{type: string, company: string, project: string}} data Form values.
*/
function handleForm(data) {
// Guard: a request type is the one field we always need.
if (!data || !data.type) {
throw new Error('Missing request type.');
}
const sheet = SpreadsheetApp.openById(SUBMISSIONS_SHEET_ID).getSheets()[0];
// Keep only the field that matches the chosen branch.
const company = data.type === 'New brief' ? data.company : '';
const project = data.type === 'Existing project' ? data.project : '';
sheet.appendRow([new Date(), data.type, company, project]);
}
How it works
doGetruns when someone opens the web app URL and returns the renderedForm.htmlpage.- In the browser, every change to the
Typedropdown firesupdate(), which setsdisplayon thenewandexistingblocks so only the matching one is visible. - When the visitor clicks Send,
submit()cancels the default form post and callsgoogle.script.run.handleForm(...)with all three values. handleFormruns back on the server. It bails out early if there is no request type, then keeps only the field that belongs to the chosen branch — the irrelevant input is left blank rather than stored as noise.- It appends a timestamped row to the submissions Sheet.
- The success handler swaps the form for a thank-you message, so the visitor gets confirmation without a page reload.
Example run
A visitor picks New brief, so the company field is showing and they type
Tasman Joinery. The project field stays hidden. After they submit, the Sheet
gains:
| Timestamp | Type | Company | Project |
|---|---|---|---|
| 2026-05-25 10:14 | New brief | Tasman Joinery |
A second visitor picks Existing project and enters Harbour Rebrand. This
time company is blank:
| Timestamp | Type | Company | Project |
|---|---|---|---|
| 2026-05-25 11:02 | Existing project | Harbour Rebrand |
Each row carries only the answers that the chosen branch actually asked for.
Deploy it
The form is only live once the project is deployed as a web app:
- In the Apps Script editor, click Deploy then New deployment.
- Choose Web app as the type.
- Set Execute as to yourself and Who has access to Anyone.
- Click Deploy, approve the authorisation prompt, and copy the web app URL.
- Share that URL — anyone who opens it sees the form, no Google account needed.
After any change to the code or HTML, deploy a new version of the same deployment, or the live URL will keep serving the old build.
Watch out for
- Validate on the server, not just in the browser. The HTML hides fields,
but anyone can call
handleFormwith any payload.handleFormalready discards fields that do not match the type — add more checks there if a value must be present. - Re-deploy after edits. Saving the script is not enough. Until you publish a new version of the deployment, visitors see the previous build.
- One file name, one HTML file.
createHtmlOutputFromFile('Form')must match the HTML file name exactly, without the.htmlextension. - No spam protection out of the box. A public URL will eventually attract junk submissions. Add a honeypot field or a simple shared code if that becomes a problem.
google.script.runis asynchronous. UsewithSuccessHandlerandwithFailureHandlerfor confirmation and errors — the call returns nothing directly.
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