A personality quiz looks like a front-end widget, but the part that matters (the scoring) belongs on the server, and Webflow Cloud is where you should keep it.
Most personality quizzes you find on a marketing site are built entirely in the browser. The questions ship to the visitor in plain JavaScript, and so does the scoring key that maps answers to a result.
That works until someone opens DevTools and finds your scoring key sitting in plain sight, at which point the "assessment" is a lookup table they can reverse in two minutes. Webflow Cloud runs a Next.js app as a Cloudflare Worker on the same domain as your Webflow site, so the page that shows the questions and the endpoint that scores them share an origin with no CORS setup.
You get a SQLite database for storing completed results and a Key Value store for in-progress session state, plus environment variables for any API keys, all wired into the same app.
This guide builds a Big Five (OCEAN) assessment using the public-domain International Personality Item Pool, scores it server-side and persists each submission, then optionally converts the raw scores into a readable profile using an AI model.
The Big Five is the right framework to build on because the items are free to use and modify, unlike trademarked instruments, so you can ship a real product without licensing risk.
3 ways to build a personality assessment on a Webflow site
There are three common ways to add a personality assessment to a Webflow site, and they differ mostly in where the scoring runs and who can see it:
- A third-party embed is the fastest to ship, but it hands the experience and the data to another company.
- A client-side script keeps you in control of the design but exposes the scoring key to anyone who looks.
- A Webflow Cloud app keeps the scoring private and the data yours, which is why I reach for it on anything beyond a throwaway quiz.
The table below compares the three on the dimensions that decide the choice:
The rest of this guide implements the third row.
Before any of it runs, here is what you need in place.
What do you need to build a personality assessment tool in Webflow Cloud?
You need a Webflow Cloud project running a Next.js app, a scored question set, two storage bindings (one SQLite database and one Key Value store), and, if you want AI-written results, an API key for a language model. Everything here runs on the Webflow Cloud Workers runtime, with no separate backend to host.
Confirm you have these ready before you proceed:
- A Webflow Cloud project running a Next.js app, deployed or in local dev
- A Big Five question set with a scoring key (this guide uses public-domain IPIP items)
- A SQLite (D1) binding for completed results and a Key Value binding for session state
- An LLM API key (optional, for the AI narrative step), set as a Webflow Cloud environment variable
- Node.js 22.13.0 or higher locally, the minimum for Webflow CLI 2.0, plus the Webflow CLI itself
Each of these earns its place. The steps that follow explain what each one does and why I set it up the way I do.
7 steps to build a personality assessment tool in Webflow Cloud
At a high level, you define the questions and scoring key, declare your storage, create the database schema, serve questions and hold session state, score and persist a submission, optionally generate an AI narrative, and wire the whole thing to your Webflow page.
Each step below is self-contained, and the scoring and storage steps work whether or not you add the AI step at the end.
1. Define the question set and scoring key
Start with the data, because every later step reads from it. Create a module that holds your questions, each tagged with the trait it measures and its keyed direction. The example below uses two items per trait for readability; a production assessment typically uses the full ten-per-trait IPIP set, which you can copy from the IPIP Big-Five Factor Markers.
The keyed field is the important part: 1 for positively keyed items and -1 for negatively keyed ones. The scorer in Step 5 uses it to decide whether to flip a response.
Create lib/questions.ts:
// lib/questions.ts
export type Trait = 'O' | 'C' | 'E' | 'A' | 'N'
export type Question = {
id: number
text: string
trait: Trait
keyed: 1 | -1 // 1 = positively keyed, -1 = reverse-keyed
}
// Public-domain IPIP items. Two per trait shown; expand to 10 per trait for production.
export const QUESTIONS: Question[] = [
{ id: 1, text: 'I am the life of the party.', trait: 'E', keyed: 1 },
{ id: 2, text: "I don't talk a lot.", trait: 'E', keyed: -1 },
{ id: 3, text: "I sympathize with others' feelings.", trait: 'A', keyed: 1 },
{ id: 4, text: "I am not interested in other people's problems.", trait: 'A', keyed: -1 },
{ id: 5, text: 'I get chores done right away.', trait: 'C', keyed: 1 },
{ id: 6, text: 'I often forget to put things back in their proper place.', trait: 'C', keyed: -1 },
{ id: 7, text: 'I get stressed out easily.', trait: 'N', keyed: 1 },
{ id: 8, text: 'I am relaxed most of the time.', trait: 'N', keyed: -1 },
{ id: 9, text: 'I have a vivid imagination.', trait: 'O', keyed: 1 },
{ id: 10, text: 'I am not interested in abstract ideas.', trait: 'O', keyed: -1 },
]
export const TRAIT_NAMES: Record<Trait, string> = {
O: 'Openness',
C: 'Conscientiousness',
E: 'Extraversion',
A: 'Agreeableness',
N: 'Neuroticism',
}
Because this file lives in your Webflow Cloud app and never ships to the client, the scoring key stays private. The page in Step 7 fetches only the question text and IDs, not the trait or keyed direction.
Expected outcome: a typed question bank you can import anywhere in the app.
2. Declare your D1 and Key Value storage bindings
With the questions defined, give the app somewhere to read and write. Webflow Cloud connects storage through bindings declared in wrangler.json at the root of your project. Add one d1_databases entry for completed results and one kv_namespaces entry for session state.
The snippet below declares both. Leave the database_id placeholder as is; Webflow Cloud generates a real ID for each resource on its first deployment.
Add both bindings to wrangler.json:
{
"d1_databases": [
{
"binding": "DB",
"database_name": "assessment_db",
"database_id": "REPLACE_AFTER_DEPLOY",
"migrations_dir": "./migrations"
}
],
"kv_namespaces": [
{
"binding": "SESSION_KV",
"id": "REPLACE_AFTER_DEPLOY"
}
]
}
After saving this, generate the TypeScript types so your editor knows the bindings exist:
npx wrangler types
That command creates or updates worker-configuration.d.ts. In Next.js, you also update cloudflare-env.d.ts so the DB and SESSION_KV bindings type-check in your Route Handlers.
Expected outcome: two declared bindings, typed and ready, that Webflow Cloud provisions on your next deploy.
3. Create the database schema with a migration
Before the app can store a result, the results table has to exist. Webflow Cloud manages schema with migrations: versioned .sql files in the migrations_dir you set in Step 2, applied automatically on every deploy. Migrations are additive only, so you never edit an old file; you add a new one for each change.
Create your first migration with a table that holds one row per completed assessment. The five trait scores are stored as integers, and the raw answers and any AI narrative are stored as text.
Create migrations/001_init.sql:
-- migrations/001_init.sql
CREATE TABLE IF NOT EXISTS results (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
openness INTEGER NOT NULL,
conscientiousness INTEGER NOT NULL,
extraversion INTEGER NOT NULL,
agreeableness INTEGER NOT NULL,
neuroticism INTEGER NOT NULL,
answers TEXT NOT NULL,
narrative TEXT
);
You can test this against a local database before deploying, which I always do to catch a typo in the SQL before it reaches production:
wrangler d1 migrations apply DB --local
On deploy, Webflow Cloud applies any new migration files to the managed database.
Expected outcome: a results table that exists in both your local and deployed databases, ready to receive submissions.
4. Serve the questions and hold the session state in the Key Value store
Now expose the questions to the page and give a visitor somewhere to park answers as they go. Two small Route Handlers cover this. The first returns the questions with the trait and keyed fields stripped out, so the browser never sees the scoring key.
The second saves partial progress to the Key Value store under a per-session key. To let someone leave and return, persist that session ID in a cookie or localStorage and add a matching GET handler that reads the draft back; the write side is what the handler below covers.
Both handlers read their bindings through getCloudflareContext() from the OpenNext adapter, which is how a Next.js app reaches Webflow Cloud's runtime environment. Webflow Cloud runs these handlers on the Node.js runtime through that adapter, so no runtime declaration is needed.
Start with app/api/questions/route.ts:
// app/api/questions/route.ts
import { NextResponse } from 'next/server'
import { QUESTIONS } from '@/lib/questions'
export async function GET() {
// Send only what the browser needs. The trait and keyed direction stay server-side.
const safe = QUESTIONS.map(({ id, text }) => ({ id, text }))
return NextResponse.json({ questions: safe })
}
The session handler writes the in-progress answers to KV with a one-hour expiry, so abandoned sessions clean themselves up:
// app/api/session/route.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { NextResponse, type NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const { sessionId, answers } = await request.json() as {
sessionId: string
answers: Record<string, number>
}
const { env } = getCloudflareContext()
// expirationTtl is in seconds; 3600 = 1 hour.
await env.SESSION_KV.put(`session:${sessionId}`, JSON.stringify(answers), {
expirationTtl: 3600,
})
return NextResponse.json({ saved: true })
}
The expirationTtl is what keeps the Key Value store from filling with half-finished sessions. I save progress on each answer in Step 7, so the draft is already in KV before a visitor reaches the end. Expected outcome: a question feed with no scoring key in it, and in-progress answers stored under a session key.
5. Score the submission and store the result in D1
This is the step the whole tool exists for. The submit handler receives the full set of answers, applies the scoring key from Step 1 (flipping reverse-keyed items), sums each trait, normalizes the totals to a 0 to 100 scale, and writes one row to the database.
Because the key lives on the server, the scoring cannot be tampered with from the browser.
First, a small pure function does the math, so it is easy to reason about and test in isolation:
// lib/score.ts
import { QUESTIONS, type Trait } from './questions'
export type Scores = Record<Trait, number>
export function scoreAnswers(answers: Record<string, number>): Scores {
const sums: Record<Trait, number> = { O: 0, C: 0, E: 0, A: 0, N: 0 }
const counts: Record<Trait, number> = { O: 0, C: 0, E: 0, A: 0, N: 0 }
for (const q of QUESTIONS) {
const raw = answers[String(q.id)]
if (typeof raw !== 'number' || raw < 1 || raw > 5) continue
// Reverse-keyed items are flipped on a 1-5 scale: 5 becomes 1, 1 becomes 5.
const adjusted = q.keyed === 1 ? raw : 6 - raw
sums[q.trait] += adjusted
counts[q.trait] += 1
}
// Normalize each trait sum to a 0-100 percentage of its possible range.
const scores = {} as Scores
for (const trait of Object.keys(sums) as Trait[]) {
const n = counts[trait]
const min = n * 1
const max = n * 5
scores[trait] = n === 0 ? 0 : Math.round(((sums[trait] - min) / (max - min)) * 100)
}
return scores
}
The handler then validates the input, scores it, and inserts the result.
I use crypto.randomUUID() for the row ID because it is built into the Workers runtime and needs no extra dependency:
// app/api/submit/route.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { NextResponse, type NextRequest } from 'next/server'
import { QUESTIONS } from '@/lib/questions'
import { scoreAnswers } from '@/lib/score'
export async function POST(request: NextRequest) {
const { answers } = await request.json() as { answers: Record<string, number> }
// Reject incomplete submissions before scoring.
const answered = QUESTIONS.filter((q) => typeof answers[String(q.id)] === 'number')
if (answered.length < QUESTIONS.length) {
return NextResponse.json({ error: 'Please answer every question.' }, { status: 400 })
}
const scores = scoreAnswers(answers)
const id = crypto.randomUUID()
const { env } = getCloudflareContext()
await env.DB.prepare(
`INSERT INTO results
(id, created_at, openness, conscientiousness, extraversion, agreeableness, neuroticism, answers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
id,
Date.now(),
scores.O,
scores.C,
scores.E,
scores.A,
scores.N,
JSON.stringify(answers)
)
.run()
return NextResponse.json({ id, scores })
}
The validation check matters more than it looks. An unvalidated submit endpoint is the kind of public write route that gets abused, and the same caution applies to any open endpoint, which is why I pair tools like this with reCAPTCHA spam protection on Webflow forms when the assessment is public-facing.
Expected outcome: a posted set of answers returns five normalized scores and writes one durable row to D1.
6. Generate an AI personality narrative server-side
Numbers alone rarely feel like a "result," so this optional step turns the five scores into a short written profile. The model call lives in its own utility and runs server-side, so the API key never reaches the browser. I read the key from the runtime environment, which is where Webflow Cloud injects environment variables you set in the dashboard.
The utility sends the scores to a language model and asks for a grounded, non-clinical summary.
The system prompt is where I constrain tone and length so the output stays useful:
// lib/narrative.ts
import { getCloudflareContext } from '@opennextjs/cloudflare'
import type { Scores } from './score'
export async function generateNarrative(scores: Scores): Promise<string> {
const { env } = getCloudflareContext()
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': env.ANTHROPIC_API_KEY as string,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 600,
system:
'You summarize Big Five personality scores for a general audience. ' +
'Be warm, specific, and non-clinical. Never diagnose. Keep it under 200 words.',
messages: [
{
role: 'user',
content: `Scores out of 100 - Openness ${scores.O}, Conscientiousness ${scores.C}, ` +
`Extraversion ${scores.E}, Agreeableness ${scores.A}, Neuroticism ${scores.N}. ` +
`Write a short profile.`,
},
],
}),
})
if (!res.ok) throw new Error(`Model request failed: ${res.status}`)
const data = await res.json() as { content: { text: string }[] }
return data.content[0]?.text ?? ''
}
To use it, call generateNarrative(scores) inside the submit handler after the insert, wrapped in a try/catch so a model outage never breaks scoring, and update the row's narrative column with the result.
This is the same server-side pattern used behind an AI-powered chat interface on a Webflow site with Claude, applied to a one-shot generation rather than a conversation.
Expected outcome: each result carries a readable profile, and a failed model call degrades to scores-only rather than an error.
7. Render the assessment on your Webflow page
The final step connects the Webflow-designed page to the handlers you built. Add an embed element where the quiz should appear and load the questions, collect answers, save progress, and post the submission.
The one detail that trips people up is the path: fetch calls must include your app's mount path, so an app mounted at /quiz calls /quiz/api/questions, not /api/questions.
The condensed client script below fetches the questions, then persists each answer to the session endpoint before submitting for scoring:
<div id="assessment"></div>
<script>
const BASE = '/quiz' // your Webflow Cloud mount path
const sessionId = crypto.randomUUID()
const answers = {}
async function load() {
const res = await fetch(`${BASE}/api/questions`)
const { questions } = await res.json()
// ... render each question as a 1-5 (Strongly disagree to Strongly agree) scale
}
async function onAnswer(questionId, value) {
answers[questionId] = value
// Persist progress so the draft survives on the server side.
await fetch(`${BASE}/api/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, answers }),
})
}
async function submit() {
const res = await fetch(`${BASE}/api/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
})
const { scores } = await res.json()
// ... render the five trait bars and the narrative
}
load()
</script>
Because the app is served on the same domain as your Webflow site, none of these calls cross an origin, so there is no CORS configuration to manage.
Expected outcome: a live assessment on your Webflow page that scores on the server and shows a result.
What breaks a personality assessment tool in Webflow Cloud?
Most problems with a tool like this trace back to one of a few causes:
- A scoring key that is not being applied
- A binding that is not wired up after deployment
- A model call with no guardrail
- The Key Value store's eventual consistency
- An endpoint that accepts anything sent to it
Each one tends to fail quietly, so the symptom rarely names the cause.
Scores come back skewed or inverted
If relaxed, organized testers score high on Neuroticism or low on Conscientiousness, the reverse-keyed items are not being flipped. Confirm every question in lib/questions.ts has a correct keyed value and that scoreAnswers applies 6 - raw for items where keyed is -1.
A quick check is to score a sheet of all-5-answer items: positively and negatively keyed items should partly cancel, landing mid-scale rather than at the extremes. If every trait pins to 100, the flip is missing.
I keep a small fixture of known inputs and their expected scores, and run it before every deploy because a single miskeyed item is invisible in casual testing but skews an entire trait in production.
Results save in local dev but vanish after deploy
When writes work with wrangler dev but the deployed app stores nothing, the binding is usually the issue. Confirm the d1_databases binding name in wrangler.json exactly matches what your code reads (env.DB), and that you redeployed after declaring it, since Webflow Cloud provisions and connects the resource on deploy.
If the binding still does not appear, open the environment's Storage tab in the Webflow Cloud dashboard, confirm the database is listed for that environment, and then redeploy. Bindings are scoped per environment, so the database connected to a staging environment is not the one your production environment writes to.
When data seems to vanish, check that the environment you are inspecting is the same one you actually deployed to.
The AI narrative step times out or returns a 500
A model call is the slowest part of the request and the most likely to fail. If submissions started erroring after you added Step 6, the narrative call is almost certainly unguarded. Wrap generateNarrative in a try/catch and write the row before calling it, so scoring and storage succeed even when the model does not.
Also, confirm ANTHROPIC_API_KEY is set in the environment variables in the Webflow Cloud dashboard, not only in your local .env, and that you redeployed after adding it. Because the narrative is the slowest part of the request, I cap max_tokens and keep the prompt short, which lowers both latency and the chance of hitting the request limit when traffic spikes.
Session answers go missing or read back stale
The Key Value store is eventually consistent, so a write is visible immediately in the region that made it but may take up to 60 seconds or more to propagate globally.
If a visitor's saved progress occasionally reads back stale, do not treat KV as the source of truth for anything that must be exact. Keep it for convenience (resuming a session), and rely on the final submit call, which posts the complete answer set in one request, for the authoritative result that lands in D1.
For anything requiring strong consistency, use the database, not the store. If you let visitors resume on a different device shortly after starting, the cross-region read is exactly where the lag shows up, so design the resume flow to tolerate a missing draft rather than assume the saved answers are always there.
The submit endpoint accepts incomplete or junk data
If your results table fills with partial or impossible rows, the endpoint is trusting its input. The validation in Step 5 rejects submissions that do not answer every question, but you can tighten it further: confirm each value is an integer from 1 to 5, and reject unexpected keys.
For a public assessment, add a layer in front of the write, whether a token check or a challenge, so the endpoint is not an open door. Treating any public write route as untrusted by default is the habit that keeps the data clean.
While testing, I log the shape of rejected submissions so I can see what malformed payloads actually look like in the wild, then remove that logging before shipping so I am not writing visitor answers to the logs.
Build your own personality assessment on Webflow Cloud
If you are a marketer or agency building a lead-generation quiz, or a product team adding an onboarding assessment, the hard part was never the front end. It was owning the logic and the data without standing up a separate backend, and that is exactly the gap Webflow Cloud closes.
The scoring stays private and the results live in a database you can query, all on the same domain as the site you already maintain.
As your tool grows, the same foundation extends naturally:
- Gate saved results behind sign-in the way you would when you add Auth0 authentication to a Webflow site
- Report on submissions, the way a real-time dashboard with Supabase and Webflow does
- Watch the endpoints in production once you add Sentry error tracking to a Webflow Cloud app
Build the assessment once, and it becomes a durable part of your site rather than a widget you rent.
Frequently asked questions
Is the Big Five (IPIP) free to use commercially?
Yes. The International Personality Item Pool is in the public domain, so you can edit and ship its items in a commercial product with no license or fee. That is the main reason to build on it rather than a trademarked instrument, which would require permission and payment.
Do I need a database, or can I use only the Key Value store?
You can run the assessment with only the Key Value store, but I would not store the final results there. KV is eventually consistent and built for unstructured, short-lived data. Use SQLite for completed results you want to query or export, and KV for session progress.
Does the scoring run in the browser or on the server?
On the server. The question bank and the keyed directions live in your Webflow Cloud Route Handlers, which also run the scoring math, and the browser only ever sees question text. That is the whole point: a visitor cannot read or alter the scoring key from DevTools.
Can I build this without the AI narrative step?
Yes. Steps 1 through 5 and Step 7 produce a complete, accurate assessment with five normalized trait scores. The AI narrative in Step 6 is optional polish. Skip it, and you need no model API key at all.
How many questions should the assessment include?
The example uses two items per trait for readability, but a credible result needs more. The standard IPIP marker set uses ten items per trait, fifty in total, balancing reliability against survey dropout. Expand the question bank, and the scorer handles any count.
Will the assessment work on the same domain as my Webflow site?
Yes. Webflow Cloud serves your app from a mount path on your live Webflow domain, so the page and its API routes share an origin. There is no CORS to configure, as long as your fetch calls include the mount path, for example,/quiz/api/submit.




