Getting Started with Quests in 10 Minutes
Drop a multi-step intake form onto your site, define the questions in JSON, and start collecting structured responses. A dental practice is the running example.
You run a small dental practice with a static marketing site, and you want new patients to book an intake without phoning. A booking platform is far more than you need. What you need is four questions and a submit button.
@qaiddev/quests-embed is the questionnaire sibling of @qaiddev/thumbs-embed. Instead of catching a thumbs-up, it walks somebody through a multi-step form defined in JSON. We build the dental intake form here, from nothing.
The shape of one
A plain JSON object: an id, a title, an optional description, and an array of questions. Button labels and the thank-you screen are overridable on the same object.
{
"id": "new-patient-intake",
"title": "New Patient Intake",
"description": "A few quick questions so we can prepare for your visit.",
"submitLabel": "Request appointment",
"nextLabel": "Continue",
"backLabel": "Back",
"thankYouTitle": "Thanks — we will be in touch",
"thankYouMessage": "Our front desk will call you within one business day to confirm your appointment time.",
"questions": [
{
"id": "name",
"type": "text",
"label": "Your full name",
"required": true,
"minLength": 2,
"maxLength": 80
},
{
"id": "phone",
"type": "text",
"label": "Best phone number to reach you",
"inputType": "tel",
"required": true
},
{
"id": "reason",
"type": "multiple-choice",
"label": "What brings you in?",
"required": true,
"options": [
{ "value": "cleaning", "label": "Routine cleaning" },
{ "value": "consultation", "label": "New patient consultation" },
{ "value": "pain", "label": "I am having pain" },
{ "value": "cosmetic", "label": "Cosmetic question" },
{ "value": "other", "label": "Something else" }
]
}
]
}
That is the whole shape. Everything below adds fields to it or wires it into a page.
Installing with a script tag
One tag at the end of <body>, carrying your endpoint, API key, and a URL to that JSON:
<script
src="https://unpkg.com/@qaiddev/quests-embed"
data-endpoint="https://qaid.dev/api/quest-responses"
data-api-key="your-api-key-here"
data-config-url="/forms/new-patient-intake.json"
data-container="#appointment-form"
></script>
Or keep the config as a JSON block beside the loader:
<script type="application/json" data-quests-config>
{
"endpoint": "https://qaid.dev/api/quest-responses",
"apiKey": "your-api-key-here",
"configUrl": "/forms/new-patient-intake.json",
"container": "#appointment-form"
}
</script>
<script src="https://unpkg.com/@qaiddev/quests-embed"></script>
Both start themselves as soon as the script loads.
Installing with npm
In React, Vue, Svelte or anything else that hydrates client-side, construct it yourself:
npm install @qaiddev/quests-embed
import { QaidQuests } from "@qaiddev/quests-embed";
const embed = new QaidQuests({
endpoint: "https://qaid.dev/api/quest-responses",
apiKey: "your-api-key-here",
configUrl: "/forms/new-patient-intake.json",
container: "#appointment-form",
});
You get a real instance back, so embed.destroy() cleans up on a route change and embed.getAnswers() reads the current state.
Inline JSON or a configUrl
Pass the object as questionnaire and it ships in your page bundle, costing no extra request and needing a deploy to change. Pass a URL as configUrl and the embed fetches it at runtime, so you can reword a question without redeploying.
Inline:
import { QaidQuests } from "@qaiddev/quests-embed";
import questionnaire from "./new-patient-intake.json";
new QaidQuests({
endpoint: "https://qaid.dev/api/quest-responses",
apiKey: "your-api-key-here",
questionnaire,
container: "#appointment-form",
});
Give it both and the inline questionnaire wins.
Modal or in the page
With no container, the form opens as a centred modal over a backdrop, which is right when a button triggers it.
new QaidQuests({
endpoint: "https://qaid.dev/api/quest-responses",
apiKey: "your-api-key-here",
configUrl: "/forms/new-patient-intake.json",
});
With a container selector it renders inside that element, which is right when the form is the page.
<section id="appointment-form" style="max-width: 520px; margin: 0 auto;"></section>
new QaidQuests({
endpoint: "https://qaid.dev/api/quest-responses",
apiKey: "your-api-key-here",
configUrl: "/forms/new-patient-intake.json",
container: "#appointment-form",
});
modalWidth, backdropOpacity and zIndex tune the modal.
The question types
The intake form uses four of the five. Each one is an object in the questions array.
text, for name, phone and email
Any free text, single or multi-line. inputType switches the native input mode, which is what gets a phone keypad on a phone.
{
"id": "name",
"type": "text",
"label": "Your full name",
"required": true,
"minLength": 2,
"maxLength": 80
},
{
"id": "phone",
"type": "text",
"label": "Best phone number to reach you",
"inputType": "tel",
"required": true
},
{
"id": "email",
"type": "text",
"label": "Email for appointment confirmation",
"inputType": "email",
"placeholder": "you@example.com"
}
multiline, for notes to the dentist
multiline: true turns the input into a textarea.
{
"id": "notes",
"type": "text",
"label": "Anything we should know before your visit?",
"description": "Allergies, anxieties, recent dental work — all helpful.",
"multiline": true,
"maxLength": 500
}
date, for the preferred day
A native date picker, bounded by min and max. Setting min to tomorrow is how you refuse same-day requests without validating anything.
{
"id": "preferredDay",
"type": "date",
"label": "Preferred appointment day",
"required": true,
"min": "2026-04-27"
}
multiple-choice, for the reason
Single-select is the default and the answer stores as the chosen value string.
{
"id": "reason",
"type": "multiple-choice",
"label": "What brings you in?",
"required": true,
"options": [
{ "value": "cleaning", "label": "Routine cleaning" },
{ "value": "consultation", "label": "New patient consultation" },
{ "value": "pain", "label": "I am having pain" },
{ "value": "cosmetic", "label": "Cosmetic question" },
{ "value": "other", "label": "Something else" }
]
}
multiple-choice again, with multiple: true
Now several selections are allowed and the answer becomes an array.
{
"id": "insurance",
"type": "multiple-choice",
"label": "Which insurance providers do you carry?",
"description": "Select all that apply.",
"multiple": true,
"options": [
{ "value": "delta", "label": "Delta Dental" },
{ "value": "cigna", "label": "Cigna" },
{ "value": "metlife", "label": "MetLife" },
{ "value": "aetna", "label": "Aetna" },
{ "value": "none", "label": "None / paying out of pocket" }
]
}
There is a fifth type, currency, with a locale-aware formatter. A dental intake has no use for it; a quote request would.
Required fields and validation
required: true on any question blocks the next step until it is answered. Each type adds its own limits:
Text takes minLength, which is only enforced when the question is required, and maxLength. Currency takes numeric min and max. Date takes min and max as ISO date strings.
Require the basics and leave the nuance optional:
{
"id": "preferredDay",
"type": "date",
"label": "Preferred appointment day",
"required": true,
"min": "2026-04-27",
"max": "2026-07-31"
}
A question hidden by visibleIf never blocks submission, even marked required. That is what makes branching forms possible.
Wording the submit button and the last screen
Both strings live on the Questionnaire object rather than on any question:
{
"id": "new-patient-intake",
"title": "New Patient Intake",
"submitLabel": "Request appointment",
"nextLabel": "Continue",
"backLabel": "Back",
"thankYouTitle": "Thanks — we will be in touch",
"thankYouMessage": "Our front desk will call you within one business day to confirm your appointment time.",
"questions": [ /* ... */ ]
}
submitLabel only appears on the last step. Everything before it uses nextLabel.
Reading answers in the browser
Persistence is already handled. Mounting the form POSTs to your endpoint and creates a response record. Each answer PATCHes to endpoint/{id}, debounced on text fields by saveDebounceMs. Submitting POSTs the full answers map to endpoint/{id}/submit.
So the server has everything before you ask. Inside a larger flow, though, a booking wizard or a checkout, you can take a snapshot whenever:
const embed = new QaidQuests({
endpoint: "https://qaid.dev/api/quest-responses",
apiKey: "your-api-key-here",
configUrl: "/forms/new-patient-intake.json",
container: "#appointment-form",
});
// Later, e.g. in a "Save draft" button handler
const answers = embed.getAnswers();
console.log(answers.name, answers.reason, answers.insurance);
It comes back read-only and keyed by question id, with values that are strings, numbers, string arrays or null.
The embed also mints a visitorId and keeps it client-side, which is how one person’s session survives a page reload. There are no userId or userEmail options. If you need to know who somebody is, ask them in a question.
Building a quest in the dashboard
Everything above can be hand-written. Mostly it isn’t. Projects → your project → Quests lists every quest you have.
Click one to open the editor. On the left, a visual builder for adding questions, reordering them, and setting types and options, with a raw JSON tab when you want it. On the right, a preview that re-renders as you type. Publishing a draft creates a version, and the embed only ever serves the published one, so an unfinished edit cannot reach a patient. Roll back whenever.
Reading responses and analytics
Every submission lands in the Responses tab, answer by answer.
Analytics aggregates the same data per question: completion rate, choice breakdowns, range distributions.
Styling with quest themes
Quests have their own theme maker under Quest Themes: colour, type, radius, padding and spacing tokens, plus custom CSS, over a live sample. Publish once and point any quest at it.
Where to go next
Branching with visibleIf shows and hides questions from earlier answers, which is how the dental form asks about pain only when somebody says they are in pain. Theming and inline embeds covers colour tokens, custom CSS and layout.