# Tutorial: Build a Contact page
By the end of this tutorial, ResolutionFlow will have a working `/contact` page. A visitor can land on it, fill out a form, and see a thank-you state when it submits. You'll touch the router, build a page component, style it with the design system, manage form state, and link to it from the landing footer.
This is a **tutorial**, not a reference. It's one concrete path that's known to work. The point is to learn how the pieces fit together by actually building something. Don't substitute steps. After you finish, you'll be ready to read the code with confidence.
**Estimated time:** 30–45 minutes.
---
## What you'll know by the end
- Where new pages live in the codebase
- How the router lazy-loads page components
- How public pages differ from in-app pages
- How to apply the design system without inventing chrome
- How to wire form state, validation, and submit
- How to verify your work and ship a clean commit
---
## Before you start
You need:
- The frontend container running (`docker ps` should show `resolutionflow_frontend` listening on 5173). Vite hot-module-reload is what makes this tutorial pleasant. Every file save shows up in the browser within a second.
- An editor open at the repo root.
- A logged-out browser tab pointed at the dev server. The contact page is public, so you don't need an account to visit it. (If you've been logged in, open a private window or sign out.)
> Quick sanity check: navigate to `/landing` in the browser. If you see the marketing page, you're set up correctly. If you see anything else, fix that first.
---
## Step 1: Decide where the page lives
Two parts of the app could host a "contact" page: the **public marketing layer** (`/landing`, `/privacy`, `/terms`) or the **in-app shell** (`/account`, `/sessions`, etc.). The right answer depends on the audience.
A contact page is for visitors who *aren't* logged in: prospects, leads, support requests from people without accounts. So it belongs at the public layer, parallel to `/privacy` and `/terms`. No app shell, no sidebar, just a simple centered page.
**Decision:** route it at `/contact`, no auth required, model it after `frontend/src/pages/PrivacyPage.tsx` for layout.
---
## Step 2: Create the page component
Create a new file at `frontend/src/pages/ContactPage.tsx`. Start with the smallest possible skeleton so we can confirm the route works before adding form complexity.
```tsx
import { Link } from 'react-router-dom'
import { PageMeta } from '@/components/common/PageMeta'
export default function ContactPage() {
return (
<>
← Back to home
Contact
Send us a note and we'll get back to you within one business day.
>
)
}
```
A few things worth pointing out:
- **`PageMeta`** sets the document title and description. Every page should have one. It's how you keep tab titles informative without scattering `` calls everywhere.
- **`min-h-screen bg-background`** ensures the page fills the viewport with the brand background color. Critical for public pages that don't sit inside an app layout.
- **`mx-auto max-w-xl`** caps line length around 65–75 characters of body text, per the shared design laws. `max-w-xl` is ~36rem; for the form we'll keep at this width.
- **`font-heading`** maps to the heading font defined in `frontend/src/index.css`. Use it on H1s, not body text.
Save the file. Nothing visible happens yet: we haven't told the router that `/contact` exists.
---
## Step 3: Wire up the route
Open `frontend/src/router.tsx`. Near the top of the file, you'll see a list of `lazyWithRetry` imports for every page. Add yours, alphabetized in the public-page group:
```tsx
const ContactPage = lazyWithRetry(() => import('@/pages/ContactPage'))
```
`lazyWithRetry` is a thin wrapper around React's `lazy()` that retries once if the chunk fails to load (which can happen during a deploy). Use it for everything; never plain `lazy()`.
Now scroll down to the `sentryCreateBrowserRouter` array and add a route entry next to the other public ones (`/landing`, `/privacy`, `/terms`):
```tsx
{
path: '/contact',
element: page(ContactPage),
errorElement: ,
},
```
The `page()` helper wraps the component in `` and `}>`. That gives you a graceful loader while the chunk loads and an error boundary if something throws. The `errorElement: ` handles router-level errors (e.g., a 404 thrown deeper in the tree).
Save. Vite reloads. Navigate to `http://your-dev-host:5173/contact` (or whatever URL serves the dev frontend). You should see the heading, the description, and the back-to-home link.
> If you see a blank page or an error, check the browser console first. The two common mistakes here are: (1) wrong import path, (2) forgetting `export default`. Fix and re-save.
---
## Step 4: Add the form
Now we add the actual contact form. Replace the body of the page (everything inside `max-w-xl`) with the form scaffolding. Keep imports for now; we'll add more in the next step.
```tsx
← Back to home
Contact
Send us a note and we'll get back to you within one business day.
```
Notice what we **did not** do:
- No outer card wrapper (`rounded-2xl border bg-card p-6`). The page background and the centered `max-w-xl` container are enough structure. Wrapping a single form in a card adds chrome that says nothing. Per `PRODUCT.md`: *"Cards are the lazy answer."*
- No icons next to labels. The labels carry the meaning; icons would be decoration.
- No fancy gradient on the submit button. The accent color is reserved for ≤5% of the UI; one solid button is the pattern.
- No nested borders or shadows.
Save. The form renders. The fields are real HTML inputs: they accept focus, browser autofill works, validation messages appear if you submit empty.
> If your form fields look unstyled, check that the `className` strings copied without line breaks. Tailwind compiles class strings literally; a stray newline inside the quotes breaks every utility on that line.
The `inputClass` you see here is duplicated three times. That's intentional for the tutorial; repetition makes it easy to read. In real code you'd extract a constant once you have three matching calls. Look at `frontend/src/pages/account/ProfileSettingsPage.tsx` for the project's existing convention.
---
## Step 5: Manage form state
Right now the inputs are uncontrolled (the browser owns their values) and submitting reloads the page. We need React state so we can read the values, validate them, and prevent the default submit.
At the top of the file, add `useState`:
```tsx
import { useState } from 'react'
```
Inside the component, above the `return`, add three pieces of state and a submit handler:
```tsx
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !email.trim() || !message.trim()) return
setIsSubmitting(true)
try {
// Replaced with a real API call in Step 7.
await new Promise((resolve) => setTimeout(resolve, 600))
// Success handling lands in Step 6.
} finally {
setIsSubmitting(false)
}
}
```
Then wire the inputs and the form:
```tsx
```
What changed:
- **`value` + `onChange`** makes each input a controlled component. React owns the truth; the input mirrors it.
- **`e.preventDefault()`** stops the browser's default form submit (which would do a full page reload).
- **`isSubmitting`** disables the button during the in-flight request and swaps the label. Users get immediate feedback that something happened.
- **The trim() guards** catch empty submissions even when the browser's `required` attribute is bypassed (e.g., autofill anomalies).
Save. Try typing in the fields. Click Send message. The button briefly says "Sending…" then re-enables. Nothing user-visible happens after that yet. That's the next step.
---
## Step 6: Show a success state
When the submit succeeds, the form should disappear and a confirmation should take its place. That's both a clearer signal and a stronger feeling than a toast that vanishes after three seconds.
Add one more piece of state:
```tsx
const [submitted, setSubmitted] = useState(false)
```
Update the submit handler so it flips `submitted` on success:
```tsx
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !email.trim() || !message.trim()) return
setIsSubmitting(true)
try {
await new Promise((resolve) => setTimeout(resolve, 600))
setSubmitted(true)
} finally {
setIsSubmitting(false)
}
}
```
Now branch the JSX so the form renders only when `!submitted`:
```tsx
{submitted ? (
Message sent
Thanks, {name.trim()}. We'll reply at{' '}
{email.trim()} within one business day.
) : (
)}
```
A few teaching moments here:
- **The success state is a single bordered region**, not a confetti card with a check icon. PRODUCT.md's tone is "competent, no fluff."
- **It echoes the user's name and email back** so they know the right address received their message. This is a small touch that builds trust.
- **There's a "Send another message" affordance** that resets the form. Don't trap users in success. Give them a way back.
Save. Submit the form. The fields disappear and the confirmation appears. Click "Send another message" and you're back to the empty form.
---
## Step 7: Wire it to a real API endpoint
So far the submit is a mock 600ms delay. To make it real, we need three things: an API endpoint, a frontend client function, and updated error handling.
The backend endpoint setup is its own tutorial; for now we'll add the frontend client and call a not-yet-existing path, so the call fails gracefully with a toast. When the backend lands, you change one line of your client and you're done.
Create `frontend/src/api/contact.ts`:
```ts
import { apiClient } from './client'
export const contactApi = {
submit: (data: { name: string; email: string; message: string }) =>
apiClient.post('/contact', data).then((r) => r.data),
}
```
That's the whole pattern. `apiClient` is a pre-configured Axios instance from `frontend/src/api/client.ts` with the base URL, auth, and error interceptors already wired. Every API module in `frontend/src/api/` follows this same shape. Read `frontend/src/api/betaFeedback.ts` to see another minimal example.
Now in `ContactPage.tsx`, swap the mock for a real call. Add to imports:
```tsx
import { contactApi } from '@/api/contact'
import { toast } from '@/lib/toast'
```
Update the submit handler:
```tsx
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim() || !email.trim() || !message.trim()) return
setIsSubmitting(true)
try {
await contactApi.submit({
name: name.trim(),
email: email.trim(),
message: message.trim(),
})
setSubmitted(true)
} catch (err) {
console.error('Failed to send contact message:', err)
toast.error("We couldn't send your message. Please try again.")
} finally {
setIsSubmitting(false)
}
}
```
What this gets you:
- Backend errors (500, network failure, etc.) show a toast and keep the form filled. The user can retry without retyping.
- The success path only fires if the API call succeeds, with no false positives.
- `toast` comes from `@/lib/toast`, the project's wrapper around Sonner. It's themed and consistent with every other toast in the app.
Save. Submit the form. Because there's no `/contact` backend endpoint yet, the call will fail and you'll see an error toast. That's correct behavior. The frontend is doing exactly what it should. When someone implements the backend, no frontend change is required.
---
## Step 8: Link from the landing page
A page that nobody can reach isn't a page. Open `frontend/src/pages/LandingPage.tsx` and find the `