This post walks through what the provided code does and how to set up the Google Sheet it expects. When you’re done, you’ll have a lightweight web app that:
- Reads questions from a Google Sheet
- Renders them as 1–5 (Worst→Best) radio-button questions
- Prefills the user’s email
- Saves each submission to a results sheet (with a timestamp)
- Confirms the saved row number to the user
What the Code Does (at a glance)
1) doGet(e) — serve the web app
- Loads an HTML template file named
quiz. - Injects two data points into the template:
data: the array of question objects returned byquizData()email: the active user’s email fromSession.getActiveUser().getEmail()
- If anything fails, it serves a simple “Not found” page.
💡 Note about
getActiveUser()
This returns a non-empty email when your script runs under a Google Workspace domain and the web app is configured appropriately (e.g., “Execute as: User accessing the web app” + access limited to your domain). For public/consumer access, it may be blank; the email input field in the form still lets users enter it manually.
2) quizData() — read and shape your questions
- Opens the spreadsheet by ID (replace the placeholder with your own).
- Pulls all rows from the
questionssheet. - Treats the first row as headings (e.g.,
id,question). - Converts each subsequent row into an object where keys are the lowercase headings.
- Example:
{ id: 'q1', question: 'The course content was clear.' }
- Example:
- Returns an array of question objects to the template.
3) addSheet(vals) — save a submission
- Opens the same spreadsheet’s
resultssheet. - Reads the first row (headings) to decide the order of values to append.
- For each heading:
- If
valscontains that key, use it; otherwise use an empty string. - If the heading is
date, it inserts the current timestamp.
- If
- Appends a new row and returns metadata (e.g., the new row number) to the front end.
4) The HTML template (quiz) + client script (quizjs)
- Receives
dataandemailfrom the server and logs them. - Shuffles the questions (
data.data.sort(() => .5 - Math.random())). - Dynamically builds the form:
- Each question renders as a “Worst → Best” scale with radio buttons 1–5.
- One input per question is marked
required, which effectively requires a choice per group.
- On submit:
- Gathers
name,email, and all selected scores (by questionid). - Disables the button and shows a “Saving…” state.
- Calls
google.script.run.addSheet(res)and, on success, replaces the page with a thank-you + the saved row number.
- Gathers
Spreadsheet Setup (the crucial part)
The script expects two sheets in the same spreadsheet ID:
questions— the source of truth for what to askresults— where submissions are stored
1) questions sheet
Required headings: at minimum id and question.
You can add other columns if you like (e.g., category), but the UI only uses id and question.
| id | question |
|---|---|
| q1 | The course content was clear. |
| q2 | The instructor was engaging. |
| q3 | The assignments supported my learning. |
| q4 | The pace of the course felt appropriate. |
| q5 | I would recommend this course to others. |
The
idvalues become the column names you’ll also include in theresultssheet (see below). They’re how the app maps each answer to the right column.
2) results sheet
Headings row defines the save order.
Include at least date, name, email, and a column for every question id in questions.
| date | name | q1 | q2 | q3 | q4 | q5 | |
|---|---|---|---|---|---|---|---|
| (auto-filled date) | (text) | (email) | 1–5 | 1–5 | 1–5 | 1–5 | 1–5 |
dateis automatically populated by the script (new Date()).nameandemailcome from the form.q1…qNcolumns receive the numeric scores submitted by the user.
If you add more questions (e.g., q6), add matching columns in results.
How to Use / Deploy
- Create your Google Sheet with the two tabs above. Copy the Sheet ID from its URL.
- In Apps Script:
- Create a new Apps Script project, or attach one to the sheet.
- Replace the spreadsheet ID in
quizData()andaddSheet()with your own. - Add a file named
Code.gs(your server functions). - Add an HTML file named
quizfor the main template (CSS + body + the<?!= HtmlService... ?>include). - Add an HTML file named
quizjsthat contains the client-side<script>which builds the form and handles submit.
- Deploy as Web App: Deploy → New deployment → Web app
- Execute as: User accessing the web app (to prefill email on Workspace) or Me (if you prefer).
- Who has access: Set appropriately (e.g., your domain).
- Open the URL to try it out.
If
Why This Pattern Works Well
- Sheet-driven: Non-developers can add/edit questions in Sheets.
- Schema-aware saving: Using the
resultsheadings as the “save map” makes appending robust and flexible. - Low friction UX: Radio groups validate easily; submit is instant; users get a confirmation.
- Easy to extend: Add categories, weights, or analytics later (averages, charts, dashboards).
Small Improvements (optional)
- Validation on the server: Double-check
name,email, and that all question IDs are present before saving. - Unique attempt control: Store a hash of
email + dateto prevent rapid duplicate submissions. - Analytics tab: Use
QUERY()/AVERAGE()/COUNTIF()or Apps Script to compute per-question averages and trends. - Error UX: In
doGet, consider logging errors withconsole.error(e)and showing a friendlier page.
Quick Reference: What Each Piece Does
doGet: Prepares template + injectsquizData()+ attempts to prefillemail.quizData: Turns thequestionssheet into an array of objects for the UI.addSheet: Writes a row intoresults, settingdateautomatically.quiz(HTML): The visible page; includes styles, user info inputs, and loadsquizjs.quizjs(client JS): Builds the Likert questions, handles submit, and shows a success message.

Likert Quiz Web App (Apps Script)
This bundle contains everything you need to deploy a simple Likert-scale quiz backed by Google Sheets.
Files
- Code.gs — server-side Apps Script (doGet, quizData, addSheet, include)
- quiz.html — main HTML template (loads
quizjsand renders the page) - quizjs.html — client-side JS that builds the UI and submits results
Spreadsheet Setup
Create a Google Sheet with two tabs:
questions
| id | question |
|---|---|
| q1 | The course content was clear. |
| q2 | The instructor was engaging. |
| q3 | The assignments supported my learning. |
| q4 | The pace of the course felt appropriate. |
| q5 | I would recommend this course to others. |
results
| date | name | email | q1 | q2 | q3 | q4 | q5 |
The first row on each sheet is headings.
Configure
Edit Code.gs and set SPREADSHEET_ID to your own Google Sheet ID.
Deploy
- In Apps Script, create a project and add the three files with the same names.
- Deploy → New deployment → Web app.
- Execute as: User accessing the web app (or Me).
- Who has access: your domain or anyone with link (as needed).
- Open the web app URL to test. Email prefill requires Workspace and appropriate sharing/execution settings.
// ===== Likert Quiz Web App (Apps Script) =====
// Replace this with your own spreadsheet ID:
const SPREADSHEET_ID = '1IFBVT-uvXcHJTZIr4ENMlGZFvuHM6hBkisEyrbHAXvg';
// Serves the web app
function doGet(e) {
const email = Session.getActiveUser().getEmail() || '';
const tpl = HtmlService.createTemplateFromFile('quiz');
tpl.data = {
data: quizData(),
email: email
};
return tpl
.evaluate()
.setTitle('Likert Quiz')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); // optional if you want to embed
}
// Reads the questions sheet and returns an array of objects
function quizData() {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sh = ss.getSheetByName('questions');
if (!sh) throw new Error("Missing 'questions' sheet.");
const values = sh.getDataRange().getValues();
if (!values.length) return [];
const headings = values[0].map(h => String(h).trim());
const rows = values.slice(1);
return rows.map(r => {
const o = {};
headings.forEach((h, i) => o[h.toLowerCase()] = r[i]);
return o;
});
}
// Appends a submission to the results sheet
function addSheet(vals) {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sh = ss.getSheetByName('results');
if (!sh) throw new Error("Missing 'results' sheet.");
const headings = sh.getDataRange().getValues()[0].map(h => String(h).trim());
const row = [];
headings.forEach(h => {
let v = (h.toLowerCase() in vals) ? vals[h.toLowerCase()] : (h in vals ? vals[h] : '');
if (h.toLowerCase() === 'date') v = new Date();
row.push(v);
});
sh.appendRow(row);
const last = sh.getLastRow();
return { row: last, headings, obj: vals };
}
// Helper to include partial HTML files in templates
function include(filename) {
return HtmlService.createHtmlOutputFromFile(filename).getContent();
}
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
--border:#1a1a1a;
--muted:#ddd;
--ink:#0a0a0a;
}
body { font-family: Arial, sans-serif; margin: 0; background:#fafafa; color:var(--ink); }
.wrap { max-width: 840px; margin: 24px auto; padding: 16px; }
h1 { margin: 0 0 12px; font-size: 1.6rem; }
.userInfo {
display:flex; gap:12px; align-items:center; padding:10px;
background:#000; color:#fff; border-radius:10px; margin-bottom:12px;
}
.userInfo label { font-size:.9rem; }
.userInfo input { flex:1; padding:8px 10px; border-radius:8px; border:1px solid #333; }
form.questions { display:block; }
.box { padding: 12px; border: 1px solid var(--border); margin: 8px 0; border-radius:10px; background:#fff; }
.ques { font-size: 1.1rem; margin-bottom: 8px; }
.scale { display:flex; align-items:center; gap:8px; flex-wrap: wrap; }
.guideWord { font-size: 0.9rem; color:#333; padding: 6px 0; }
.scale label { font-size: .85rem; color: var(--muted); padding:0 4px; }
button[type="submit"] {
display:block; width:100%; padding:12px 14px; border-radius:12px;
border:1px solid var(--border); background:#f2f2f2; font-weight:600; margin-top:12px;
cursor:pointer;
}
.muted { color:#666; font-size:.95rem; margin:6px 0 14px; }
.footer { margin-top:18px; font-size:.8rem; color:#555; }
code.inline { background:#f4f4f4; padding:2px 6px; border-radius:6px; }
</style>
</head>
<body>
<div class="wrap">
<h1>Course Feedback (1–5 Likert)</h1>
<p class="muted">Choose one option per question (1 = Worst, 5 = Best).</p>
<form class="questions" autocomplete="on">
<div class="userInfo">
<label for="email">Your Email</label>
<input type="email" id="email" name="email" placeholder="you@example.com" required>
<label for="name">Name</label>
<input type="text" id="name" name="name" placeholder="Your name" required>
</div>
</form>
<div class="footer">
Powered by <code class="inline">HtmlService</code> + Google Sheets
</div>
</div>
<script>
// Server-injected data object: {{ data: [...], email: "..." }}
const data = <?!= JSON.stringify(data) ?>;
</script>
<?!= include('quizjs'); ?>
</body>
</html>
<script>
(function () {
console.log('Quiz ready');
console.log('data:', data);
// Shuffle questions for simple randomization
const questionsData = Array.isArray(data.data) ? [...data.data] : [];
questionsData.sort(() => Math.random() - 0.5);
const form = document.querySelector('.questions');
const emailInput = document.querySelector('#email');
const nameInput = document.querySelector('#name');
const list = document.createElement('div');
list.setAttribute('aria-live', 'polite');
emailInput.value = data.email || '';
form.append(list);
// Render each question block
questionsData.forEach(q => renderQuestion(q, list));
// Submit button
const btn = document.createElement('button');
btn.type = 'submit';
btn.textContent = 'Submit Quiz';
form.append(btn);
form.addEventListener('submit', onSubmit);
function renderQuestion(q, mount) {
const wrapper = document.createElement('div');
wrapper.className = 'box';
const prompt = document.createElement('div');
prompt.className = 'ques';
prompt.textContent = q.question;
wrapper.appendChild(prompt);
const scale = document.createElement('div');
scale.className = 'scale';
const worst = document.createElement('span');
worst.className = 'guideWord';
worst.textContent = 'Worst';
scale.appendChild(worst);
for (let i = 1; i <= 5; i++) {
const input = document.createElement('input');
input.type = 'radio';
input.name = q.id;
input.value = String(i);
input.id = q.id + '_' + i;
if (i === 1) input.required = true; // require one selection per group
const label = document.createElement('label');
label.setAttribute('for', input.id);
label.textContent = String(i);
scale.appendChild(input);
scale.appendChild(label);
}
const best = document.createElement('span');
best.className = 'guideWord';
best.textContent = 'Best';
scale.appendChild(best);
wrapper.appendChild(scale);
mount.appendChild(wrapper);
}
function onSubmit(e) {
e.preventDefault();
const res = {
name: nameInput.value.trim(),
email: emailInput.value.trim()
};
const checked = form.querySelectorAll('input[type="radio"]:checked');
checked.forEach(input => {
// store by question id; numeric values
const key = input.name;
res[key] = Number(input.value);
});
btn.disabled = true;
btn.textContent = 'Saving…';
google.script.run
.withSuccessHandler((r) => {
form.innerHTML = `Thank you for submitting your results.<br/>Your results are in row <b>${r.row}</b>.`;
})
.withFailureHandler((err) => {
btn.disabled = false;
btn.textContent = 'Submit Quiz';
alert('Save failed: ' + err.message);
})
.addSheet(res);
}
})();
</script>