A scored quiz needs a reliable answer state, and a Webflow Cloud app gives you that without wiring up dozens of show and hide rules.
Building a scored quiz is a recurring question in the Webflow community, and most walkthroughs rely on stacked show-and-hide Interactions to reveal answers and keep a running score. That setup is fine for a few questions, but once you need real scoring and a reliable answer state, that logic is easier to manage in application code.
The better pattern lives one layer up: a React Client Component in a Webflow Cloud Next.js app, with quiz questions managed in Webflow CMS so editors can add and update content without touching code.
In this guide, we explore that setup end to end. Questions live in a Webflow CMS collection. The quiz state is managed by a useReducer hook. The score and per-question explanations are displayed on a results screen. Optionally, completions log to a Webflow Cloud Key Value Store for analytics.
What do you need to build an interactive quiz with scoring in Webflow Cloud?
You need a Webflow Cloud Next.js app and a Webflow site with CMS access. No third-party services required.
Here are the prerequisites:
- A Webflow Cloud project with a Next.js app deployed or running locally
- A Webflow site on a plan that includes CMS Collections (the Premium Site plan or higher)
- A Webflow API token with
cms:readscope (generated in Site settings > Apps & integrations > API access) - Node.js 22 or higher for local development
The quiz question content lives in Webflow CMS, which means your editors can update questions, add new ones, and edit answer explanations from the Webflow dashboard without touching the app or triggering a redeployment.
In my experience, this is the single biggest reason to use Webflow CMS as the source rather than hard-coding it in JSON.
6 steps to build an interactive quiz on Webflow Cloud
The architecture splits cleanly:
- Webflow CMS stores the questions
- The Next.js Server Component fetches them at build time with ISR
- The React Client Component manages all quiz state
No client-to-server round trips happen during the quiz itself; the entire state machine runs in the browser once questions are loaded.
Let’s proceed with the steps.
1. Create the Webflow CMS collection for quiz questions
In Webflow, go to CMS > Collections and create a new collection. I call it "Quiz Questions."
Then, add the following fields:
Once the collection is created, add your questions. The Order field lets you control sequence without relying on the CMS item creation order, which can shift when items are duplicated or imported.
2. Fetch questions from the Webflow Data API
In the Next.js app, fetch questions from Webflow CMS at build time using ISR.
Create a lib/questions.ts utility:
// lib/questions.ts
export type Question = {
id: string
question: string
options: { key: string; text: string }[]
correctAnswer: string
explanation: string
order: number
}
export async function getQuizQuestions(): Promise<Question[]> {
const res = await fetch(
`https://api.webflow.com/v2/collections/${process.env.WEBFLOW_COLLECTION_ID}/items?limit=100`,
{
headers: {
Authorization: `Bearer ${process.env.WEBFLOW_API_TOKEN}`,
accept: 'application/json',
},
// Revalidate at most every 5 minutes. ISR is experimental on
// Webflow Cloud, so treat this interval as best effort.
next: { revalidate: 300 },
}
)
if (!res.ok) throw new Error(`Webflow CMS fetch failed: ${res.status}`)
const { items } = await res.json() as {
items: {
id: string
fieldData: {
name: string
'option-a': string
'option-b': string
'option-c': string
'option-d': string
'correct-answer': string
explanation: string
order: number
}
}[]
}
return items
.map((item) => ({
id: item.id,
question: item.fieldData.name,
options: [
{ key: 'a', text: item.fieldData['option-a'] },
{ key: 'b', text: item.fieldData['option-b'] },
{ key: 'c', text: item.fieldData['option-c'] },
{ key: 'd', text: item.fieldData['option-d'] },
],
correctAnswer: item.fieldData['correct-answer'].toLowerCase(),
explanation: item.fieldData['explanation'],
order: item.fieldData['order'] ?? 0,
}))
.sort((a, b) => a.order - b.order)
}
Add the environment variables to .env.local and to Webflow Cloud's environment dashboard:
WEBFLOW_API_TOKEN=your_token_here
WEBFLOW_COLLECTION_ID=your_quiz_questions_collection_id
With the utility and environment variables in place, getQuizQuestions() returns a sorted array of typed Question objects that any Server Component can call, and the API token stays on the server instead of the browser bundle.
One caveat worth knowing up front is that ISR on Webflow Cloud is currently experimental, so treat the revalidate interval as best effort and redeploy when a change has to appear immediately. With fetching handled, the next step builds the state machine that drives the quiz on the client.
3. Build the quiz state machine
I reach for useReducer the moment a component has more than three interdependent pieces of state.
A quiz has at least five:
- The current question index
- The selected answer for the current question
- Whether the answer has been revealed
- The answer history
- The completion status
Using useState for each of those creates the kind of scattered update logic that breaks in subtle ways when users click fast.
The state machine has four states:
idle(before starting)active(question visible, no answer selected)answered(answer selected, explanation visible)complete(all questions done, results visible)
Create app/quiz/quizReducer.ts:
// app/quiz/quizReducer.ts
export type AnswerRecord = {
questionId: string
selected: string
correct: boolean
}
export type QuizState = {
status: 'idle' | 'active' | 'answered' | 'complete'
currentIndex: number
selectedAnswer: string | null
answers: AnswerRecord[]
}
export type QuizAction =
| { type: 'START' }
| { type: 'SELECT'; answer: string; questionId: string; isCorrect: boolean }
| { type: 'NEXT'; total: number }
| { type: 'RESTART' }
export const initialState: QuizState = {
status: 'idle',
currentIndex: 0,
selectedAnswer: null,
answers: [],
}
export function quizReducer(state: QuizState, action: QuizAction): QuizState {
switch (action.type) {
case 'START':
return { ...initialState, status: 'active' }
case 'SELECT':
if (state.status !== 'active') return state
return {
...state,
status: 'answered',
selectedAnswer: action.answer,
answers: [
...state.answers,
{
questionId: action.questionId,
selected: action.answer,
correct: action.isCorrect,
},
],
}
case 'NEXT': {
if (state.status !== 'answered') return state
const isLast = state.currentIndex >= action.total - 1
return {
...state,
status: isLast ? 'complete' : 'active',
currentIndex: state.currentIndex + 1,
selectedAnswer: null,
}
}
case 'RESTART':
return initialState
default:
return state
}
}
The SELECT action is idempotent within a turn: once status moves to 'answered', further SELECT actions are ignored. That prevents double-scoring if the user clicks rapidly.
4. Render the quiz UI
Create the Client Component that drives the quiz. It receives questions as props (server-fetched, no client API calls during the quiz itself):
// app/quiz/QuizClient.tsx
'use client'
import { useReducer } from 'react'
import { quizReducer, initialState, type AnswerRecord } from './quizReducer'
import type { Question } from '@/lib/questions'
function QuestionScreen({
question,
questionNumber,
total,
selectedAnswer,
onSelect,
onNext,
isAnswered,
}: {
question: Question
questionNumber: number
total: number
selectedAnswer: string | null
onSelect: (key: string) => void
onNext: () => void
isAnswered: boolean
}) {
return (
<div className="quiz-question">
<p className="quiz-progress">Question {questionNumber} of {total}</p>
<h2>{question.question}</h2>
<div className="quiz-options">
{question.options.map((opt) => {
let state = 'default'
if (isAnswered) {
if (opt.key === question.correctAnswer) state = 'correct'
else if (opt.key === selectedAnswer) state = 'wrong'
}
return (
<button
key={opt.key}
className={`quiz-option quiz-option--${state}`}
onClick={() => !isAnswered && onSelect(opt.key)}
disabled={isAnswered}
>
<span className="quiz-option-key">{opt.key.toUpperCase()}.</span>
{opt.text}
</button>
)
})}
</div>
{isAnswered && (
<div className="quiz-explanation">
<p>
<strong>
{selectedAnswer === question.correctAnswer ? 'Correct!' : 'Not quite.'}
</strong>{' '}
{question.explanation}
</p>
<button className="quiz-btn" onClick={onNext}>
Next question
</button>
</div>
)}
</div>
)
}
function ResultsScreen({
answers,
questions,
onRestart,
}: {
answers: AnswerRecord[]
questions: Question[]
onRestart: () => void
}) {
const score = answers.filter((a) => a.correct).length
const pct = Math.round((score / questions.length) * 100)
return (
<div className="quiz-results">
<h2>You scored {score}/{questions.length} ({pct}%)</h2>
<div className="quiz-breakdown">
{questions.map((q, i) => {
const record = answers.find((a) => a.questionId === q.id)
return (
<div key={q.id} className={`quiz-result-item ${record?.correct ? 'correct' : 'wrong'}`}>
<p><strong>Q{i + 1}:</strong> {q.question}</p>
<p>Your answer: {q.options.find((o) => o.key === record?.selected)?.text ?? 'None'}</p>
{!record?.correct && (
<p>Correct answer: {q.options.find((o) => o.key === q.correctAnswer)?.text}</p>
)}
<p className="quiz-explanation-text">{q.explanation}</p>
</div>
)
})}
</div>
<button className="quiz-btn" onClick={onRestart}>Try again</button>
</div>
)
}
export default function QuizClient({ questions }: { questions: Question[] }) {
const [state, dispatch] = useReducer(quizReducer, initialState)
const currentQuestion = questions[state.currentIndex]
if (state.status === 'idle') {
return (
<div className="quiz-start">
<h2>{questions.length}-question quiz</h2>
<p>Select the best answer for each question. Your score and explanations appear at the end.</p>
<button className="quiz-btn" onClick={() => dispatch({ type: 'START' })}>
Start quiz
</button>
</div>
)
}
if (state.status === 'complete') {
return (
<ResultsScreen
answers={state.answers}
questions={questions}
onRestart={() => dispatch({ type: 'RESTART' })}
/>
)
}
return (
<QuestionScreen
question={currentQuestion}
questionNumber={state.currentIndex + 1}
total={questions.length}
selectedAnswer={state.selectedAnswer}
isAnswered={state.status === 'answered'}
onSelect={(key) =>
dispatch({
type: 'SELECT',
answer: key,
questionId: currentQuestion.id,
isCorrect: key === currentQuestion.correctAnswer,
})
}
onNext={() => dispatch({ type: 'NEXT', total: questions.length })}
/>
)
}
That single Client Component now covers all three screens, the start prompt, the active question with answer feedback, and the results breakdown, each driven by the reducer's status field. Because the questions arrive as props, nothing here calls the network mid-quiz. The last piece is the Server Component that fetches the questions and hands them in.
5. Wire up the Server Component page
Fetch questions on the server and pass them as props. The quiz state stays entirely client-side; the server only provides the question data.
Here’s the snippet to wire up:
// app/quiz/page.tsx
import { getQuizQuestions } from '@/lib/questions'
import QuizClient from './QuizClient'
export default async function QuizPage() {
const questions = await getQuizQuestions()
if (questions.length === 0) {
return <main><p>No quiz questions found. Add some in Webflow CMS.</p></main>
}
return (
<main className="quiz-container">
<QuizClient questions={questions} />
</main>
)
}
Because the questions are fetched with a revalidate window, edits to Webflow CMS should appear in the live quiz within a few minutes. ISR is currently experimental on Webflow Cloud, so when an edit has to go live right away, redeploy the app.
I've leaned on this CMS as source pattern for lead-gen quizzes where the marketing team updates questions based on campaign performance without looping in a developer for each change.
6. Log quiz completions to Webflow Cloud KV (optional)
If you want to track how many people complete the quiz and their scores, Webflow Cloud's built-in Key Value Store is the lightest storage option available. It requires no Drizzle setup or schema migration, just a binding declaration and a put call.
Add the KV binding to wrangler.json:
{
"kv_namespaces": [
{
"binding": "QUIZ_KV",
"id": "your-kv-namespace-id"
}
]
}
Then create a Route Handler that the client calls when the quiz completes:
// app/api/quiz/complete/route.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { NextResponse, type NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const { score, total, sessionId } = await request.json() as {
score: number
total: number
sessionId: string
}
const { env } = getCloudflareContext()
const kv = (env as { QUIZ_KV: KVNamespace }).QUIZ_KV
const entry = JSON.stringify({
score,
total,
percentage: Math.round((score / total) * 100),
completedAt: new Date().toISOString(),
})
// Store result keyed by session ID. Expires after 90 days.
await kv.put(`result:${sessionId}`, entry, { expirationTtl: 60 * 60 * 24 * 90 })
return NextResponse.json({ ok: true })
}
In QuizClient.tsx, fire this call when the reducer reaches 'complete' status using a useEffect:
useEffect(() => {
if (state.status !== 'complete') return
const score = state.answers.filter((a) => a.correct).length
const sessionId = crypto.randomUUID()
fetch('/api/quiz/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ score, total: questions.length, sessionId }),
})
}, [state.status])
This runs once when the quiz transitions to complete. The stored results are viewable in the Webflow Cloud dashboard in your environment's storage section.
What causes building an interactive quiz on Webflow Cloud to fail?
These three show up consistently when moving from local dev to production.
Questions are rendered in the wrong order
The Order field exists for exactly this reason, but it only works if the .sort() in getQuizQuestions() runs correctly. If questions still appear out of order, check that the Order field is populated in Webflow CMS for every item. CMS items without an Order value default to 0 in the sort, which clusters them at the front.
Answers from the previous session show on refresh
The quiz state lives in useReducer, which resets to initialState on every page load. If you're seeing stale answers, something is restoring state from localStorage or a custom hook. I've seen this happen when a quiz component is accidentally wrapped in a state-persistence library. Check for any persistent storage wrappers in the component tree.
ISR serves stale questions after a Webflow CMS update
The revalidate: 300 setting means the cache refreshes every five minutes. If you need faster propagation (for example, you just fixed a typo in a correct answer), redeploy the Cloud app or reduce revalidate to 60 seconds.
On-demand revalidation through Next.js revalidatePath wired to a Webflow webhook is another option, though both ISR and on-demand revalidation are still experimental on Webflow Cloud, so a redeploy is the most reliable way to force fresh content.
Build more with this pattern
The quiz state machine in this guide scales cleanly to more complex formats: timed questions (add a timeRemaining field to the state and a useEffect countdown), branching logic (dispatch NEXT with a destination index based on the answer), and personality quizzes (accumulate trait scores in the answer records rather than a binary correct/wrong).
For deeper integration between your Webflow Cloud app and Webflow CMS content, Webflow's developer documentation covers the Data API, storage bindings, and environment configuration.
Frequently asked questions
Can I use Webflow Interactions for the quiz instead of a Cloud app?
You can, but I'd advise against it for anything that requires scoring or answer validation. Interaction-based quizzes expose correct answers in the DOM (any visitor can open DevTools and see them), can't compute scores server-side, and break down when the question count grows. The Webflow Cloud approach keeps answer validation in JavaScript state where it belongs.
How do I prevent visitors from seeing correct answers in the source?
The correctAnswer field is included in the questions payload sent to the browser, so it's visible in React DevTools and network responses. For casual quizzes (knowledge checks, lead magnets), this is acceptable. For assessments where cheating matters, move answer validation to a Route Handler: send only the question text and options to the client, submit answers to the server, and have the server validate against a field that never leaves the backend.
Can I use more than four answer options?
Yes. The CMS collection structure in this guide uses four fixed option fields for simplicity. For variable-length options, use a multi-reference field pointing to an "Answer Options" collection, or store options as a JSON string in a plain text field and parse it in the mapping function.
What plan does Webflow Cloud require?
Webflow Cloud app hosting is available on Webflow's paid Site plans. The CMS Collections used in this guide require a plan that includes the CMS, which is the Premium plan or higher, since the Basic plan does not include CMS Collections. Check Webflow's pricing for current tier details.




