---
title: "The Interest Form"
description: "Build an interest form that checks contact details and shows useful feedback. Keep it clearly labeled as an unsent test until email is connected."
canonical_url: "https://vercel.com/academy/build-and-launch-with-ai/the-interest-form"
md_url: "https://vercel.com/academy/build-and-launch-with-ai/the-interest-form.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-09-25T16:42:53.965Z"
content_type: "lesson"
course: "build-and-launch-with-ai"
course_title: "Build and Launch with AI"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# The Interest Form

# The interest form

Someone has found a workshop they want to attend. We'll add a way to ask about the next date, beginning with the fields and their feedback. We'll connect email in 4.4, so for now the form will confirm that the details are valid and nothing was sent.

Check that we're still on the feature branch, then start fx:

```sh
git branch --show-current
git status
fx
```

The branch should be `workshop-interest`, with the detail-page work committed. Keep the development server running in another terminal.

## Outcome

Add an accessible interest form that checks the visitor's details and confirms that email isn't connected yet.

## Hands-on exercise 4.2

### Know what to review

Our job is to check what visitors can enter and what happens when they submit. fx will write the React component and server code. You should be able to find the field labels, explain why an invalid request stops, and check that a failed attempt keeps the visitor's draft.

Use this map while reviewing the files:

| File                            | What to check                                                  |
| ------------------------------- | -------------------------------------------------------------- |
| `components/interest-form.tsx`  | Visible fields, labels, and submission feedback                |
| `lib/interest.ts`               | Rules for contact details, consent, and a known workshop       |
| `app/actions/interest.ts`       | Receives the submission and returns the explicit unsent result |
| `app/workshops/[slug]/page.tsx` | Gives the form the current workshop                            |

If your project uses `src`, keep the same files under its existing structure. Record these locations once so you can find them in the remaining lessons.

### Define what the form does

We need a name and email address, plus an optional question. The visitor must agree to be contacted about the selected workshop. This is an inquiry; the site doesn't take a payment or reserve a place.

Use this request inside fx, with `/permissions ask` enabled:

```text
Add an interest form to each workshop detail page. Collect a name,
email, optional message, and explicit consent to email about this
workshop. Include the current workshop slug in the submission.
Validate on the server and display field-specific errors. Preserve
entered values after errors, label every field, and announce the
result. Disable submission while it is pending.
For valid data, return: Your details passed validation. Email isn't
connected yet; nothing was sent.
Use components/interest-form.tsx for InterestForm, lib/interest.ts
for validateInterest and InterestState, and app/actions/interest.ts
for the requestWorkshopInfo Server Action. Use the existing src
structure if present. Connect the form to the current detail page.
Make this temporary behavior visible beside the form. Do not send
email, add packages, read secrets, commit, push, or deploy.
```

Let fx finish all four files, then review the diff using the map above. A *Server Action* is the function the form calls on the server when someone submits. It checks the submitted values even if a request bypasses the visible form.

The reference panels below contain complete code for comparison or repair. If you use that code, add the validator and temporary action before the component and route integration. Finish all four files before opening the page; the form needs its imported action to exist.

### Validate the submitted values

Open `lib/interest.ts` and find the rules for a name and email address. The name must have 2 to 80 characters, and the consent checkbox must be checked. Keep the supplied validation rules while we connect the rest of the form. You can recognize which field a rule checks without learning the TypeScript types or regular-expression syntax yet.

Compare with this implementation if a rule is missing:

Advanced: complete validator (optional)

```ts
import { getWorkshop } from "./workshops";

export type InterestField = "name" | "email" | "message" | "consent";
export type InterestState = {
  status: "idle" | "error" | "success";
  message: string;
  errors?: Partial<Record<InterestField, string>>;
};

export type Interest = {
  workshopSlug: string;
  name: string;
  email: string;
  message: string;
};

export type ValidationResult =
  | { ok: true; interest: Interest }
  | { ok: false; state: InterestState };

function text(form: FormData, field: string): string {
  const value = form.get(field);
  return typeof value === "string" ? value.trim() : "";
}

export function validateInterest(form: FormData): ValidationResult {
  const workshopSlug = text(form, "workshopSlug");
  if (!getWorkshop(workshopSlug) || text(form, "website")) {
    return { ok: false, state: { status: "error", message: "We couldn't submit this request. Please reload the workshop page and try again." } };
  }

  const name = text(form, "name");
  const email = text(form, "email");
  const message = text(form, "message");
  const errors: Partial<Record<InterestField, string>> = {};

  if (name.length < 2 || name.length > 80 || /[\r\n]/.test(name)) {
    errors.name = "Enter a name between 2 and 80 characters.";
  }
  if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    errors.email = "Enter a valid email address.";
  }
  if (message.length > 1000) {
    errors.message = "Keep your message under 1,001 characters.";
  }
  if (form.get("consent") !== "on") {
    errors.consent = "Confirm that we can email you about this workshop.";
  }

  if (Object.keys(errors).length > 0) {
    return { ok: false, state: { status: "error", message: "Check the highlighted fields and try again.", errors } };
  }
  return { ok: true, interest: { workshopSlug, name, email, message } };
}
```

The validator checks that the workshop exists as well as checking the contact fields. The hidden `workshopSlug` input can be changed by a visitor, so the server must verify it. The `website` field is a honeypot: it should stay empty, and a filled value causes rejection. It won't prevent every automated submission.

The returned state includes an overall message and any field errors. We can test this function without a running website, which will help us check server behavior in 4.5.

### Connect the browser form

Open `components/interest-form.tsx` and find the visible labels. Check that the form shows its temporary no-email notice and gives the visitor a result after submitting. React keeps the entered details in *state*, which is how the component remembers them while the visitor edits.

Leave the state wiring and accessibility attributes in place while checking the visible behavior. This reference uses `"use client"` because the component handles interaction in the browser:

Advanced: complete form component (optional)

```tsx
"use client";

import { useActionState, useState } from "react";
import { requestWorkshopInfo } from "@/app/actions/interest";
import type { InterestState } from "@/lib/interest";

const initialState: InterestState = { status: "idle", message: "" };

export function InterestForm({
  workshopSlug,
  workshopTitle,
}: {
  workshopSlug: string;
  workshopTitle: string;
}) {
  const [state, formAction, pending] = useActionState(requestWorkshopInfo, initialState);
  const sent = state.status === "success";
  // React resets uncontrolled fields after an action resolves, including errors.
  // Keep the visitor's draft available for correcting or retrying a request.
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [message, setMessage] = useState("");
  const [consent, setConsent] = useState(false);

  return (
    <form action={formAction} className="interest-form" aria-label={`Ask about ${workshopTitle}`} aria-busy={pending}>
      <input type="hidden" name="workshopSlug" value={workshopSlug} />
      <div className="honeypot" aria-hidden="true">
        <label htmlFor="website">Leave this field empty</label>
        <input id="website" name="website" autoComplete="off" tabIndex={-1} />
      </div>
      <fieldset disabled={pending || sent}>
        <legend className="sr-only">Your contact details</legend>
        <div className="field">
          <label htmlFor="name">Your name</label>
          <input id="name" name="name" value={name} onChange={(event) => setName(event.target.value)} autoComplete="name" required minLength={2} maxLength={80} aria-invalid={Boolean(state.errors?.name)} aria-describedby={state.errors?.name ? "name-error" : undefined} />
          {state.errors?.name ? <p id="name-error" className="field-error">{state.errors.name}</p> : null}
        </div>
        <div className="field">
          <label htmlFor="email">Email address</label>
          <input id="email" name="email" value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" required maxLength={254} aria-invalid={Boolean(state.errors?.email)} aria-describedby={state.errors?.email ? "email-error" : undefined} />
          {state.errors?.email ? <p id="email-error" className="field-error">{state.errors.email}</p> : null}
        </div>
        <div className="field">
          <label htmlFor="message">Anything you'd like to ask? <span>(optional)</span></label>
          <textarea id="message" name="message" value={message} onChange={(event) => setMessage(event.target.value)} rows={4} maxLength={1000} aria-invalid={Boolean(state.errors?.message)} aria-describedby={state.errors?.message ? "message-error" : undefined} />
          {state.errors?.message ? <p id="message-error" className="field-error">{state.errors.message}</p> : null}
        </div>
        <div className="field">
          <label className="consent-label" htmlFor="consent">
            <input id="consent" name="consent" checked={consent} onChange={(event) => setConsent(event.target.checked)} type="checkbox" required aria-invalid={Boolean(state.errors?.consent)} aria-describedby={state.errors?.consent ? "consent-error" : "privacy-note"} />
            <span>You can email me about this workshop.</span>
          </label>
          {state.errors?.consent ? <p id="consent-error" className="field-error">{state.errors.consent}</p> : null}
        </div>
        <button className="button" type="submit">{pending ? "Checking details…" : sent ? "Request sent" : "Let me know the next date"}</button>
      </fieldset>
      <p id="privacy-note" className="form-note">This is a validation demo. Email is not connected and nothing is sent. Use test details. Submitting does not reserve a place.</p>
      <p className={`form-message ${state.status}`} role="status" aria-live="polite" aria-atomic="true">{state.message}</p>
    </form>
  );
}
```

`useActionState` connects the form to its Server Action and tracks the pending result. The individual field states keep the visitor's draft available after a failed attempt. Labels and error references connect feedback with the correct inputs. [React's useActionState reference](https://react.dev/reference/react/useActionState) explains how the Hook tracks the action's result.

The action's `"use server"` directive keeps its execution on the server. It returns the unsent message after validation, so no credentials are needed. The Solution includes its complete code.

Check the integration in `app/workshops/[slug]/page.tsx`: it should import `InterestForm` and place it beneath the workshop facts, passing `workshop.slug` and `workshop.title`. The heading should explain that this is a demo inquiry, with no booking or payment. Use the Solution if fx missed that connection.

Before testing, point to the name label and its validation rule. You should be able to find both for a later edit.

## Try It

Submit an empty form and inspect the required-field feedback. Then enter a one-character name, an invalid email, and an unchecked consent box one case at a time. Browser feedback should identify each invalid entry before a valid submission proceeds.

Use “Alex Maker” and `alex@example.com` for a valid local test. The page should display: “Your details passed validation. Email isn't connected yet; nothing was sent.” The fields should remain available to edit.

Use Tab through the form and submit with the keyboard. Confirm labels remain visible, the focus indicator is clear, and the feedback appears near the form. Browser checks cover the interface; server-level validation tests arrive in 4.5.

### The page reports that the action import is missing

Create `app/actions/interest.ts` from the solution and check the import path. If the project uses `src`, keep all corresponding files under that structure and use its existing alias configuration. Don't create a second competing app tree.

### A failed attempt clears the entered details

Compare the component's `value` or `checked` props and change handlers with the implementation above. Those controlled values keep the draft in React state. Replacing them with default values can let the form reset after an action resolves.

## Commit

Exit fx and review all changed files. Stage the form, validator, action, and route integration using the project's actual paths:

```sh
git add components/interest-form.tsx lib/interest.ts app/actions/interest.ts 'app/workshops/[slug]/page.tsx'
git diff --cached
git commit -m "feat(interest): add validated workshop inquiry form"
```

Include reviewed styling changes by their exact path if your generated design needed them. Keep this work on `workshop-interest`.

## Done-When

- [ ] Each detail page submits its current workshop slug.
- [ ] Invalid contact details receive useful feedback.
- [ ] Valid data returns the explicit unsent message.
- [ ] Keyboard use works and entered values survive a failed attempt.

## Solution

fx should have created the temporary action and connected the form to each workshop page. Compare those files with this reference if either part is missing:

Advanced: temporary action and route integration (optional)

Use the complete validator and form above. Add this temporary action at `app/actions/interest.ts`:

```ts
"use server";

import { validateInterest, type InterestState } from "@/lib/interest";

export async function requestWorkshopInfo(
  _previousState: InterestState,
  formData: FormData,
): Promise<InterestState> {
  const result = validateInterest(formData);
  if (!result.ok) return result.state;

  return {
    status: "idle",
    message: "Your details passed validation. Email isn't connected yet; nothing was sent.",
  };
}
```

A valid submission returns an idle state so the visitor can keep testing. We will replace this action when we connect email.

In the detail route, add the import:

```tsx
import { InterestForm } from "@/components/interest-form";
```

Below the facts, render the form with the selected workshop:

```tsx
<h2 id="interest-title">Catch your interest?</h2>
<p className="interest-intro">Try the inquiry form for this example workshop.</p>
<p className="demo-note">This is a demo. Submitting this form does not book a workshop or take a payment. Use test details rather than sensitive information.</p>
<InterestForm workshopSlug={workshop.slug} workshopTitle={workshop.title} />
```

Check the temporary message and confirm that the form uses the workshop on the page before preparing email settings in the next lesson.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
