How to build an interactive quiz in Webflow Cloud with React

Learn how to build an interactive quiz in Webflow Cloud using Webflow CMS for the questions and a React useReducer state machine to track answers.

How to build an interactive quiz in Webflow Cloud with React

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

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:read scope (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:

Data table
Field name Field type Purpose
Name Plain Text The question text (also the CMS item name)
Option A Plain Text First answer choice
Option B Plain Text Second answer choice
Option C Plain Text Third answer choice
Option D Plain Text Fourth answer choice
Correct Answer Plain Text The key to the correct option: "a", "b", "c", or "d."
Explanation Plain Text Shown to the user after they answer
Order Number Controls question sequence

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.


Last Updated
July 31, 2026
Category

Related articles

How to replace Webflow's default order email with a fully custom SendGrid template
How to replace Webflow's default order email with a fully custom SendGrid template

How to replace Webflow's default order email with a fully custom SendGrid template

How to replace Webflow's default order email with a fully custom SendGrid template

Development
By
Colin Lateano
,
,
Read article
The complete guide to syncing Webflow orders to Airtable with Zapier
The complete guide to syncing Webflow orders to Airtable with Zapier

The complete guide to syncing Webflow orders to Airtable with Zapier

The complete guide to syncing Webflow orders to Airtable with Zapier

Development
By
Colin Lateano
,
,
Read article
How to add Sentry error tracking to a Webflow Cloud app and stop flying blind in production
How to add Sentry error tracking to a Webflow Cloud app and stop flying blind in production

How to add Sentry error tracking to a Webflow Cloud app and stop flying blind in production

How to add Sentry error tracking to a Webflow Cloud app and stop flying blind in production

Development
By
Colin Lateano
,
,
Read article
How do you add a Claude-powered chat to a Webflow site without exposing your API key?
How do you add a Claude-powered chat to a Webflow site without exposing your API key?

How do you add a Claude-powered chat to a Webflow site without exposing your API key?

How do you add a Claude-powered chat to a Webflow site without exposing your API key?

Development
By
Colin Lateano
,
,
Read article

verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.