Demographics Page
Overview
After a participant consents, we collect basic demographic information before the task begins. Demographics serve two purposes in behavioral research. First, they let you describe your sample in the methods section: age range, gender breakdown, education distribution. Second, they let you run subgroup analyses after data collection. If response patterns differ by education level or gender, you can test that. If you didn't collect the variable, you can't go back and get it.
The answers get their own table in the database, linked to the participant, rather than extra columns on the participant record. This keeps each table focused on one kind of information, which pays off when the data comes back out for analysis.
What You're Building
This is a preview of the page we are building.
About You
Please answer a few questions before we begin. This information is used for research purposes only.
The preview shows the look; the real page adds three behaviors:
- Answers are saved to their own table. Submitting the form creates a
record in a new
Demographicstable, linked to the participant who filled it out, then moves them to the instructions. - Nothing can be left blank. Every field is required, and the browser refuses to submit until all three are answered.
- No one gets here uninvited. Opening the page without an entry ID bounces back to the start, same as the consent page.
Building It With Claude Code
This lesson adds a new table to the database, so the prompt includes a schema change and a migration, just like the entry page lesson did.
@prisma/schema.prisma
Build a demographics page at src/app/demographics/page.tsx. It should:
1. Check for the participant cookie. If missing, redirect to /.
2. Show a form with three required fields: age (number input, 18 to 99),
gender (dropdown), and highest education level completed (dropdown).
3. On submit, save the responses to a new Demographics table with one
record per participant, linked to the Participant model, along with a
timestamp. Then redirect to /instructions.
Add the Demographics model to the schema, run the migration (name it
add-demographics), and regenerate the Prisma client.
What to expect while it works. Like the entry page lesson, Claude Code
will ask permission to edit the schema and run the migration. Approve both.
The migration creates the Demographics table in Neon; after it finishes,
Prisma Studio will show the new table alongside Participant.
Testing What You Built
With npm run dev running, go through the flow: enter a new Prolific ID,
agree to consent, and you should land on the demographics page instead of
last lesson's 404.
Fill out all three fields and click Continue. You will hit a 404 at
/instructions, which is next lesson's page. Open Prisma Studio with
npm run db:studio and check the Demographics table: the row should be
there with your answers and the ID of the participant who submitted them.
Now try to cheat the form: leave a field blank and click Continue. The browser should refuse to submit and point at the empty field. That check runs before anything reaches the server.
Finally, open a private browser window and go straight to
http://localhost:3000/demographics. You should be bounced to the entry
page.
If You Get Stuck
Same pattern: what you did, what you expected, what happened, plus any error text. Common symptoms:
- The migration failed. Usually the database connection again. "The migration for the Demographics table failed, here is the error: [paste]. Help me fix it."
- The form submits but the Demographics table is empty. "I filled out the demographics form and clicked Continue, but there is no row in the Demographics table in Prisma Studio. The answers are not being saved. Find out why."
- The row exists but isn't connected to the right participant. Check
whether the
participantIdon the Demographics row matches the id of the participant you entered. "The Demographics row in Prisma Studio has no participant attached, or the wrong one. Fix the link between the form and the participant." - Age shows up wrong or causes an error. Form values arrive as text and the age field expects a whole number. "Submitting the demographics form gives an error about the age value. Make sure age is converted to a number before saving."
Make It Your Own
Your study may need different demographic variables. Because every form field needs a matching column in the database, changes here touch both the page and the schema. Describe the change and Claude Code will keep the two in sync:
@prisma/schema.prisma @src/app/demographics/page.tsx
Add a field for native language (text input). Update the schema and the
form to match, and run the migration.
The same pattern works in reverse: ask for a field to be removed and both the form and the table update together.
The Code, If You're Curious
You don't need to read anything in this section to continue the course. It's here for the curious and for anyone who wants to compare against the checkpoint branch.
The schema: the new Demographics table and its link to Participant
model Participant {
id String @id @default(cuid())
prolificId String @unique
createdAt DateTime @default(now())
consented Boolean @default(false)
demographics Demographics? // one participant has at most one demographics record
}
model Demographics {
id String @id @default(cuid())
participantId String @unique // each participant has exactly one record
participant Participant @relation(fields: [participantId], references: [id])
age Int // whole number
gender String // stored as text from the dropdown selection
education String // highest level completed
submittedAt DateTime @default(now()) // when the form was submitted
}The @unique on participantId means each participant can have at most one
demographics record, and the participant line is the link back to the
Participant table. This is the tables-referencing-each-other idea from the
architecture lesson, in the flesh.
The page: the form and the save
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import prisma from "@/lib/prisma";
async function submitDemographics(formData: FormData) {
"use server";
const cookieStore = await cookies();
const participantId = cookieStore.get("participantId")?.value;
if (!participantId) {
redirect("/");
}
await prisma.demographics.create({
data: {
participantId,
age: parseInt(formData.get("age") as string),
gender: formData.get("gender") as string,
education: formData.get("education") as string,
},
});
redirect("/instructions");
}
export default async function DemographicsPage() {
const cookieStore = await cookies();
const participantId = cookieStore.get("participantId")?.value;
if (!participantId) {
redirect("/");
}
return (
<div className="max-w-xl mx-auto px-6 py-12">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white mb-2">
About You
</h1>
<p className="text-zinc-500 dark:text-zinc-400 mb-8">
Please answer a few questions before we begin. This information is used
for research purposes only.
</p>
<form action={submitDemographics} className="space-y-6">
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
Age
</label>
<input
type="number"
name="age"
min="18"
max="99"
required
className="w-full border border-zinc-300 dark:border-zinc-700 rounded px-4 py-3 text-zinc-900 dark:text-white dark:bg-zinc-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
Gender
</label>
<select
name="gender"
required
className="w-full border border-zinc-300 dark:border-zinc-700 rounded px-4 py-3 text-zinc-900 dark:text-white dark:bg-zinc-900"
>
<option value="">Select one</option>
<option value="Male">Male</option>
<option value="Female">Female</option>
<option value="Non-binary">Non-binary</option>
<option value="Prefer not to say">Prefer not to say</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-1">
Highest level of education completed
</label>
<select
name="education"
required
className="w-full border border-zinc-300 dark:border-zinc-700 rounded px-4 py-3 text-zinc-900 dark:text-white dark:bg-zinc-900"
>
<option value="">Select one</option>
<option value="High school or equivalent">
High school or equivalent
</option>
<option value="Some college">Some college</option>
<option value="Bachelor's degree">Bachelor's degree</option>
<option value="Graduate degree">Graduate degree</option>
<option value="Prefer not to say">Prefer not to say</option>
</select>
</div>
<button
type="submit"
className="w-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 font-semibold py-3 px-6 rounded"
>
Continue
</button>
</form>
</div>
);
}The short version: the gate at the top bounces anyone without a cookie. The save function reads the three answers, converts age from text to a whole number, writes one linked Demographics row, and moves the participant on to the instructions.
🌿 Checkpoint branch: 04-demographics-page: This is the finished
demographics page. Check out this branch to see the completed page and catch
up to this point in the course.
⚠️ If you built earlier lessons yourself and then switch to this
branch: your database was set up by your own migrations, not this
branch's, so npm run db:migrate will report a mismatch and offer to
reset the database. Accepting the reset is the right move during the
course: it deletes your practice data and rebuilds the tables to match
this branch. Never accept a reset once real participant data is in the
database.