Build an interactive quiz or assessment app
Run Northwind tests with scoring and feedback — questions in a Sheet, results in another.
Published Nov 4, 2025
Northwind runs short knowledge checks — onboarding quizzes for new hires, a craft test for freelance applicants, a quick refresher for the team after a process change. Google Forms can do this, but as soon as you want custom scoring, branching, or a results sheet that does not look like a Forms export, you outgrow it. A small Apps Script web app handles the lot: questions in one Sheet, results in another, a single HTML page that scores in real time.
This script keeps the moving parts to a minimum. The questions live in a Sheet
so non-developers can edit them; the front end is one HTML template with a
sprinkle of vanilla JavaScript; google.script.run carries the answers back to
the server, which scores them and appends a row to a results Sheet. No build
step, no framework, no third-party hosting.
What you’ll need
- A “Questions” Sheet with columns
question,optionA,optionB,optionC,optionD,correct(the letter A–D). One row per question, header in row 1. - A “Results” Sheet with at least a single tab — the script appends rows there.
- An Apps Script project bound to a standalone file, with both Sheet IDs to hand.
The HTML (Quiz.html)
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
/* Plain, readable styles — no framework, no build step. */
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2em auto; padding: 0 1em; }
.question { margin: 1.5em 0; }
.question p { font-weight: 600; }
label { display: block; padding: 0.3em 0; cursor: pointer; }
button { padding: 0.6em 1.2em; font-size: 1em; }
#result { margin-top: 1.5em; padding: 1em; background: #f4f4f4; border-radius: 4px; }
</style>
</head>
<body>
<h1>Northwind knowledge check</h1>
<form id="quiz">
<!-- The template loop runs server-side; each question gets four radio
options, named so we can collect answers by question index. -->
<? for (var i = 0; i < questions.length; i++) { var q = questions[i]; ?>
<div class="question">
<p><?= (i + 1) + '. ' + q.question ?></p>
<label><input type="radio" name="q<?= i ?>" value="A"> <?= q.optionA ?></label>
<label><input type="radio" name="q<?= i ?>" value="B"> <?= q.optionB ?></label>
<label><input type="radio" name="q<?= i ?>" value="C"> <?= q.optionC ?></label>
<label><input type="radio" name="q<?= i ?>" value="D"> <?= q.optionD ?></label>
</div>
<? } ?>
<button type="button" onclick="submitQuiz()">Submit</button>
</form>
<div id="result" hidden></div>
<script>
// Number of questions, baked in by the template so the client knows
// how many radio groups to read without another server round-trip.
var TOTAL = <?= questions.length ?>;
function submitQuiz() {
// Collect one answer per question into a plain array. Unanswered
// questions stay null and count as wrong on the server.
var answers = [];
for (var i = 0; i < TOTAL; i++) {
var picked = document.querySelector('input[name="q' + i + '"]:checked');
answers.push(picked ? picked.value : null);
}
// google.script.run is the bridge from page to server. The success
// handler receives whatever gradeQuiz returns.
google.script.run
.withSuccessHandler(showResult)
.gradeQuiz(answers);
}
function showResult(res) {
var el = document.getElementById('result');
el.hidden = false;
el.innerHTML = '<strong>You scored ' + res.score + ' / ' + res.total + '.</strong>';
}
</script>
</body>
</html>
The web-app handler
// The Sheet that holds the questions. One row per question, header
// columns: question, optionA, optionB, optionC, optionD, correct.
const QUESTIONS_SHEET_ID = '1abcQuestionsId';
// The Sheet that collects results — one row appended per submission.
const RESULTS_SHEET_ID = '1abcQuizResultsId';
/**
* Entry point. Loads the questions, hands them to the template, and
* returns the rendered HTML.
*
* @param {GoogleAppsScript.Events.DoGet} _e Unused.
* @return {GoogleAppsScript.HTML.HtmlOutput} The quiz page.
*/
function doGet(_e) {
const template = HtmlService.createTemplateFromFile('Quiz');
template.questions = readSheet(QUESTIONS_SHEET_ID);
return template.evaluate()
.setTitle('Northwind knowledge check')
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
/**
* Scores a submitted set of answers, appends the result, and returns
* the score to the page. Called from google.script.run.
*
* @param {Array<string|null>} answers One letter per question, in order.
* @return {{score: number, total: number}}
*/
function gradeQuiz(answers) {
const questions = readSheet(QUESTIONS_SHEET_ID);
if (!Array.isArray(answers) || answers.length !== questions.length) {
throw new Error('Answer count does not match question count.');
}
// One pass — increment the score whenever the picked letter matches
// the correct column. Missing answers stay null and never match.
let score = 0;
questions.forEach((q, i) => {
if (answers[i] && String(answers[i]).toUpperCase() === String(q.correct).toUpperCase()) {
score++;
}
});
// Record the attempt for review. Score and total go in their own
// columns so a pivot or chart over the Results sheet is trivial.
SpreadsheetApp.openById(RESULTS_SHEET_ID).getSheets()[0]
.appendRow([new Date(), score, questions.length, JSON.stringify(answers)]);
return { score, total: questions.length };
}
/**
* Reads a header-row sheet into an array of plain objects keyed by
* header name — much easier to use than raw 2D arrays.
*
* @param {string} id The Sheet ID to read.
* @return {Array<Object>}
*/
function readSheet(id) {
const [headers, ...rows] = SpreadsheetApp.openById(id)
.getSheets()[0]
.getDataRange()
.getValues();
return rows.map((row) =>
Object.fromEntries(headers.map((key, i) => [key, row[i]]))
);
}
How it works
doGetloads the questions from the Sheet, attaches them to the HTML template, and returns the evaluated page. The template loop renders one radio group per question.- The page collects answers into a plain array, where index
iholds the letter chosen for questioni. Unanswered questions staynull. google.script.run.gradeQuiz(answers)ships the array back to the server. The success handler runs only after the server replies.gradeQuizreads the questions again so the correct answers never travel to the browser — a quiz that ships answers to the client is no quiz at all. It walks the array once, scoring case-insensitively.- The result row records timestamp, score, total, and the raw answers as JSON so you can review individual attempts later without polluting the columns.
Example run
The Questions Sheet:
| question | optionA | optionB | optionC | optionD | correct |
|---|---|---|---|---|---|
What does SS stand for? | Spreadsheet service | Single sheet | Server-side | Style sheet | A |
| Default trigger limit? | 5 | 10 | 20 | 50 | C |
A user submits A, C. The page shows “You scored 2 / 2.” and the Results Sheet gets a new row:
| Timestamp | Score | Total | Answers |
|---|---|---|---|
| 2026-05-27 10:14:02 | 2 | 2 | ["A","C"] |
Deploy it
- Paste the JavaScript into
Code.gsand the HTML into a file namedQuiz.html(the.htmlis added automatically). - Set
QUESTIONS_SHEET_IDandRESULTS_SHEET_IDat the top of the script. - Deploy → New deployment → Web app. Execute as Me; pick the access level that matches your audience.
- Approve the authorisation prompt and share the
/execURL with your testers.
Watch out for
- The correct answers are loaded server-side inside
gradeQuiz, never sent to the page. Resist the temptation to ship them with the questions for “easier client-side scoring” — once they leave the server, the quiz is over. - Apps Script caches scriptlets aggressively. After editing
Quiz.html, use the/devURL during development; switch to/execonly when you re-deploy. Object.fromEntriesand template literals require V8. New scripts default to V8, but if you copy this into an older project, enable V8 inappsscript.json.- For a longer quiz, store the questions across multiple sheets keyed by
difficulty, and pass a query parameter (
?level=intro) todoGetto pick one. The page stays the same; only the data behind it changes. - Quizzes with timed sections need a session token, since
doGetis stateless. The same Script Properties pattern from Build an expiring secure-download generator works here too — mint a token at start, validate at submit.
Related
Build a branded approval interface
Approve Northwind requests through a custom UI — clients click, decision is logged.
Updated Nov 8, 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
Build a guided onboarding tour for Sheets
Walk Northwind's first-time users through dialogs — each step explains one feature.
Updated Oct 19, 2025