appscript.dev
Automation Intermediate Sheets

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.html file for the page and a .gs file 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

  1. doGet runs when someone opens the web app URL and returns the rendered Form.html page.
  2. In the browser, every change to the Type dropdown fires update(), which sets display on the new and existing blocks so only the matching one is visible.
  3. When the visitor clicks Send, submit() cancels the default form post and calls google.script.run.handleForm(...) with all three values.
  4. handleForm runs 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.
  5. It appends a timestamped row to the submissions Sheet.
  6. 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:

TimestampTypeCompanyProject
2026-05-25 10:14New briefTasman Joinery

A second visitor picks Existing project and enters Harbour Rebrand. This time company is blank:

TimestampTypeCompanyProject
2026-05-25 11:02Existing projectHarbour 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:

  1. In the Apps Script editor, click Deploy then New deployment.
  2. Choose Web app as the type.
  3. Set Execute as to yourself and Who has access to Anyone.
  4. Click Deploy, approve the authorisation prompt, and copy the web app URL.
  5. 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 handleForm with any payload. handleForm already 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 .html extension.
  • 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.run is asynchronous. Use withSuccessHandler and withFailureHandler for confirmation and errors — the call returns nothing directly.

Related