How to Build a Shopify Quiz App with Metaobjects, Liquid, JavaScript, and a Vercel API
Quizzes are a fantastic tool for engaging customers, collecting insights, and offering personalized shopping experiences. In this guide, we will explore how to build a Shopify quiz app from scratch.
1. Understanding Metaobjects in Shopify
Shopify metaobjects are a structured way to store and retrieve data. Unlike metafields, metaobjects allow for more organized data storage and retrieval. In this project, we will use them to save quiz responses.
Why Use Metaobjects for Quizzes?
Metaobjects provide several advantages:
- Structured Data: Metaobjects allow you to save quiz responses in a key-value format, making them easy to retrieve.
- Shopify Integration: They can be accessed via Shopify’s Admin API, allowing for seamless interaction.
- Scalability: Storing responses in metaobjects allows businesses to scale quizzes efficiently.
Creating a Metaobject for Storing Quiz Responses
To store quiz responses, we need to create a metaobject type. This can be done via a Shopify GraphQL mutation:
mutation CreateMetaobject {
metaobjectCreate(
metaobject: {
type: "quiz_response",
fields: [{ key: "data", value: "User quiz response data" }]
}
) {
metaobject {
id
type
fields {
key
value
}
}
userErrors {
field
message
}
}
}
This mutation tells Shopify to create a new structured data entry for quiz responses.
2. Building the Shopify Quiz Using Liquid
Shopify’s Liquid templating language allows us to create an interactive quiz within a Shopify store. This will form the basis of our frontend.
Defining the Quiz Layout
The quiz consists of multiple slides, each representing a question. We structure the quiz using a Shopify section that dynamically generates questions.
<div class="quiz-container">
<form id="quiz-form">
{% for block in section.blocks %}
<div class="quiz-slide" data-slide="{{ forloop.index }}">
<p>{{ block.settings.question_text }}</p>
{% for option in block.settings.options | split: ',' %}
<label>
<input type="radio" name="q_{{ forloop.index }}" value="{{ option }}" required> {{ option }}
</label>
{% endfor %}
<button class="next-btn" type="button" onclick="nextSlide()">Next</button>
</div>
{% endfor %}
<button type="submit">Submit</button>
</form>
</div>
Each block in the section represents a quiz question, allowing for dynamic quiz generation.
3. Adding JavaScript for Quiz Navigation
To enable smooth transitions between questions and handle form submission, we need JavaScript.
Handling Slide Navigation
Each slide should be displayed one at a time, progressing when the user clicks “Next.”
document.addEventListener("DOMContentLoaded", () => {
let currentSlide = 0;
const slides = document.querySelectorAll(".quiz-slide");
function showSlide(index) {
slides.forEach((slide, i) => slide.classList.toggle("active", i === index));
}
document.getElementById("quiz-form").addEventListener("submit", async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const responses = {};
formData.forEach((value, key) => { responses[key] = value; });
await fetch("/api/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ responses })
});
alert("Quiz Submitted!");
});
showSlide(currentSlide);
});
This script ensures that only one question is visible at a time and that responses are collected correctly.
4. Creating a Vercel API Route for Processing Quiz Submissions
Once the user completes the quiz, their responses need to be stored. We accomplish this using a Vercel API route that interacts with Shopify.
This API route:
- Accepts POST requests with quiz responses.
- Transforms responses into a Shopify metaobject.
- Sends the data to Shopify using the Admin API.
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const { responses } = await req.json();
const shopifyStore = process.env.SHOPIFY_STORE!;
const accessToken = process.env.SHOPIFY_ADMIN_API_ACCESS_TOKEN!;
const mutation = `mutation CreateMetaobject { metaobjectCreate(metaobject: { type: "quiz_response", fields: [{ key: "data", value: "${JSON.stringify(responses).replace(/"/g, '\"')}" }] }) { metaobject { id } } }`;
await fetch(`https://${shopifyStore}.myshopify.com/admin/api/2025-01/graphql.json`, { method: "POST", headers: { "X-Shopify-Access-Token": accessToken, "Content-Type": "application/json" }, body: JSON.stringify({ query: mutation }) });
return NextResponse.json({ success: true });
}
The final code
<style>
.quiz-container {
margin: auto;
text-align: center;
display: grid;
}
.quiz-slide {
display: none;
}
.quiz-slide.active {
display: grid;
}
.answers.wrap {
display: flex;
flex-wrap: wrap;
column-gap: 3rem;
row-gap: 1rem;
justify-content: center;
margin-top: 1rem;
}
.next-btn {
opacity: 0.5;
pointer-events: none;
}
/* Enables "Next" button once an answer is selected or typed */
.quiz-slide:has(:checked),
.quiz-slide:has(input:valid),
.quiz-slide:has(textarea:valid) {
.next-btn {
opacity: 1;
pointer-events: auto;
}
}
</style>
<div class="quiz-container"><form novalidate="" id="quiz-form"><!-- Title Card --> {% if section.settings.quiz_image != blank %} <picture> {% render 'media', media: section.settings.quiz_image %} </picture> {% endif %}
<div class="prose text-center sm:text-start">
<h2 class="h2">{{ section.settings.quiz_title }}</h2>
<p>{{ section.settings.quiz_description }}</p>
{% render 'button', id: 'start-quiz', content: 'Start Quiz' %}</div>
<!-- Quiz Slides --> {% for block in section.blocks %} {% assign i = forloop.index %} <!-- Create a stable name prefix for this question index --> {% assign qTextName = 'q_' | append: i | append: '_txt' %} {% assign qAnsName = 'q_' | append: i | append: '_ans' %} {% if block.settings.question_image != blank %} <picture> {% render 'media', media: block.settings.question_image %} </picture> {% endif %}
<div class="v-stack gap-5 text-center prose quizcopy"><!-- Hidden input to store question text --> <input value="{{ block.settings.question_text }}" name="{{ qTextName }}" type="hidden"> {% if block.settings.question_type == 'radio' %}
<p class="smallcaps">Please select one</p>
{% elsif block.settings.question_type == 'checkbox' %}
<p class="smallcaps">Select all that apply</p>
{% endif %}
<p class="h4">{{ block.settings.question_text }}</p>
<!-- Answers -->
<div class="answers {% if block.settings.wrap == true %}wrap{% endif %}">{% assign opts = block.settings.options | split: ',' %} {% case block.settings.question_type %} {% when 'checkbox' %} {% for opt in opts %} {% render 'checkbox', name: qAnsName | append: '[]', value: opt | strip, label: opt | strip, required: block.settings.required, id_prefix: 'quiz' %} {% endfor %} {% when 'radio' %} {% for opt in opts %} {% render 'checkbox', name: qAnsName, value: opt | strip, label: opt | strip, id_prefix: 'quiz', required: block.settings.required, type: 'radio' %} {% endfor %} {% when 'multiline_text' %} {% render 'input', name: qAnsName, multiline: 4, label: block.settings.options, required: block.settings.required %} {% when 'singleline_text' %} {% render 'input', name: qAnsName, label: block.settings.options, required: block.settings.required %} {% when 'email' %} {% render 'input', name: qAnsName, type: 'email', label: 'Email', required: block.settings.required %} {% endcase %}</div>
<!-- Nav Buttons -->
<div class="quiz-navigation">{% if i < section.blocks.size %} {% render 'button', id: 'next', content: 'Next', style: 'outline', custom_class: 'next-btn' %} {% else %} {% render 'button', id: 'submit', content: 'Submit', custom_class: 'next-btn', type: 'submit' %} {% endif %}</div>
{% if i > 1 %}
<div style="width: fit-content; margin: auto;">{% render 'button', id: 'prev', content: 'Previous', style: 'link' %}</div>
{% endif %}</div>
{% endfor %}</form></div>
<pre>
<script>
document.addEventListener("DOMContentLoaded", () => {
let currentSlide = 0;
const slides = document.querySelectorAll(".quiz-slide");
const startBtn = document.getElementById("start-quiz");
const titleCard = document.getElementById("title-card");
const quizForm = document.getElementById("quiz-form");
const previewContainer = document.getElementById("response-preview");
function showSlide(index) {
slides.forEach((slide, i) => slide.classList.toggle("active", i === index));
}
startBtn?.addEventListener("click", () => {
titleCard.style.display = "none";
currentSlide = 1;
showSlide(currentSlide);
});
document.addEventListener("click", (e) => {
if (e.target.id === "next" && currentSlide < slides.length - 1) {
currentSlide++;
showSlide(currentSlide);
} else if (e.target.id === "prev" && currentSlide > 0) {
currentSlide--;
if (currentSlide === 0) titleCard.style.display = "grid";
showSlide(currentSlide);
}
updatePreview();
});
// Build preview from the hidden question text + answers
function buildPreview(formData) {
let results = {};
// formData keys: q_1_txt => question text, q_1_ans => single/radio, q_1_ans[] => checkboxes
for (let [key, value] of formData.entries()) {
// If key matches q_#_txt, store question text
let txtMatch = key.match(/^q_(\d+)_txt$/);
if (txtMatch) {
results[key] = { question: value, answers: [] };
}
// If key matches q_#_ans or q_#_ans[]
let ansMatch = key.match(/^q_(\d+)_ans(\[\])?$/);
if (ansMatch) {
// The question text is in q_#_txt
let index = ansMatch[1];
let qTextKey = `q_${index}_txt`;
// If we haven't seen that question text yet, store it quickly
if (!results[qTextKey]) results[qTextKey] = { question: "", answers: [] };
// Push this answer
results[qTextKey].answers.push(value.trim());
}
}
// Build final string
let lines = [];
for (let [k, obj] of Object.entries(results)) {
let q = obj.question || "";
let ans = obj.answers.length ? obj.answers.join(", ") : "";
if (q && ans) lines.push(`${q}:\n${ans}`);
}
return lines.length ? lines.join("\n\n") : "No responses yet.";
}
// Submit
async function submitQuiz() {
try {
document.dispatchEvent(new Event("theme:loading:start"));
let fd = new FormData(quizForm);
let preview = buildPreview(fd);
console.log("Submitting:\n", preview);
let resp = await fetch("/api/submitForm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ responses: preview }),
});
if (!resp.ok) throw new Error("First attempt failed");
document.dispatchEvent(new Event("theme:loading:end"));
window.location.href = "{{ section.settings.results_page }}";
} catch (err) {
console.error("Submission failed, retrying...", err);
try {
let fd = new FormData(quizForm);
let preview = buildPreview(fd);
let resp2 = await fetch("https://cwskin-quiz-api.vercel.app/api/submitForm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ responses: preview }),
});
if (!resp2.ok) throw new Error("Retry attempt failed");
document.dispatchEvent(new Event("theme:loading:end"));
window.location.href = "{{ section.settings.results_page }}";
} catch (error2) {
console.error("Final failure:", error2);
document.dispatchEvent(new Event("theme:loading:end"));
alert("Submission failed. Check your connection and try again.");
}
}
}
quizForm.addEventListener("submit", (e) => {
e.preventDefault();
submitQuiz();
});
});
</script>
{% schema %}
{
"name": "Quiz Slideshow",
"settings": [
{ "type": "text", "id": "quiz_title", "label": "Quiz Title", "default": "Take the Quiz!" },
{ "type": "textarea", "id": "quiz_description", "label": "Quiz Description", "default": "Answer the questions and get your personalized results." },
{ "type": "image_picker", "id": "quiz_image", "label": "Quiz Image" },
{ "type": "url", "id": "results_page", "label": "Results Page Redirect URL" }
],
"blocks": [
{
"type": "question",
"name": "Question",
"settings": [
{ "type": "text", "id": "question_text", "label": "Question Text" },
{ "type": "image_picker", "id": "question_image", "label": "Question Image (optional)" },
{
"type": "select",
"id": "question_type",
"label": "Question Type",
"options": [
{ "value": "checkbox", "label": "Multiple Choice (Checkbox)" },
{ "value": "radio", "label": "Select One (Radio)" },
{ "value": "singleline_text", "label": "Single Line Text" },
{ "value": "multiline_text", "label": "Long Form Text" },
{ "value": "email", "label": "Email" }
],
"default": "radio"
},
{ "type": "textarea", "id": "options", "label": "Options (comma-separated, only for checkbox & radio)" },
{ "type": "checkbox", "id": "wrap", "label": "Show answers horizontally" },
{ "type": "checkbox", "id": "required", "label": "Require an answer", "default": true }
]
}
],
"presets": [
{
"name": "Quiz Slideshow",
"settings": {},
"blocks": [
{
"type": "question",
"settings": {
"question_text": "Sample Question",
"question_type": "radio",
"options": "Option 1,Option 2,Option 3"
}
}
]
}
]
}
{% endschema %}