The Button That Sent People to a Blank Form

Every service page on my portfolio ends with the same call to action: a big Start Project button. Click it on the Automation page, and you land on the contact form ready to talk about automation. That was the idea, anyway. What actually happened: you'd click Start Project on /services/automation-solutions, get redirected to /contact, and the "What do you need?" picker would be sitting there completely unselected. The one piece of context the user had just handed me — I want automation — was thro
Every service page on my portfolio ends with the same call to action: a big Start Project button. Click it on the Automation page, and you land on the contact form ready to talk about automation. That was the idea, anyway.
What actually happened: you'd click Start Project on /services/automation-solutions, get redirected to /contact, and the "What do you need?" picker would be sitting there completely unselected. The one piece of context the user had just handed me — I want automation — was thrown away the moment they navigated. They'd have to re-tell me what they came to tell me.
This is a small bug. It's also exactly the kind of friction that quietly costs conversions. Here's the fix.
The Setup
There are actually two contact forms in this codebase, which is where the confusion started.
The service pages (ServicePage.tsx) each render their own inline contact form near the bottom, and that one already pre-selected the right service — it derives the default from the URL slug:
const SLUG_TO_SERVICE: Record<string, string> = {
'software-development': 'software',
'shopify-development': 'shopify',
'automation-solutions': 'automation',
// ...
};
const defaultService = (SLUG_TO_SERVICE[slug ?? ''] ?? '') as ServiceValue;
Enter fullscreen mode Exit fullscreen mode
But the Start Project button in the hero didn't scroll to that inline form. It navigated to the standalone contact page, a completely separate component (ContactPage.tsx):
<Button onClick={() => navigate('/contact')}>
Start Project <ArrowRight size={20} />
</Button>
Enter fullscreen mode Exit fullscreen mode
And ContactPage always initialized its form with an empty service:
const [form, setForm] = useState<FormData>({
name: '', email: '', service: '', budget: '',
timeline: '', website: '', message: '', honeypot: '',
});
Enter fullscreen mode Exit fullscreen mode
So the button crossed a boundary — from a page that knew what the user wanted to a page that had no idea — and nothing carried the context across.
The Fix: Carry the Intent in the URL
The cleanest way to pass a small piece of state across a navigation is the URL itself. A query param is shareable, bookmarkable, survives a refresh, and doesn't need any global store. So the button now appends the service it already knows about:
<Button
onClick={() => navigate(defaultService ? `/contact?service=${defaultService}` : '/contact')}
>
Start Project <ArrowRight size={20} />
</Button>
Enter fullscreen mode Exit fullscreen mode
Note the guard: if the slug maps to nothing (defaultService is ''), it falls back to a plain /contact instead of producing an ugly /contact?service=.
On the receiving end, ContactPage reads the param on mount and validates it before trusting it. That validation matters — a query param is user-editable, and I don't want ?service=drop-table (or just a typo) to put the form into a weird state:
import { useSearchParams } from 'react-router-dom';
const VALID_SERVICES = SERVICES.map((s) => s.value) as readonly string[];
const normalizeService = (raw: string | null): ServiceValue =>
raw && VALID_SERVICES.includes(raw) ? (raw as ServiceValue) : '';
Enter fullscreen mode Exit fullscreen mode
Then I use a lazy initializer for the form state so the param is applied exactly once, when the component first mounts:
const [searchParams] = useSearchParams();
const [form, setForm] = useState<FormData>(() => ({
name: '', email: '', service: normalizeService(searchParams.get('service')), budget: '',
timeline: '', website: '', message: '', honeypot: '',
}));
Enter fullscreen mode Exit fullscreen mode
That's the whole change — two files, about eleven lines. The service picker now highlights the right option the moment the page loads, and because the budget selector only appears after a service is chosen, arriving from a service page also reveals the correct price ranges immediately.
A Subtlety About When It Runs
The lazy initializer only runs once, on mount. That's deliberate, and it's worth understanding why it's correct here.
When a user clicks Start Project, they navigate from the /services/... route to the /contact route. React Router unmounts one page component and mounts the other — a fresh mount, so the initializer runs and reads the param.
But if you were already on /contact and only the query string changed, React Router would keep the same component mounted and the initializer would not re-run. For a second I wondered if I needed a useEffect to sync on param changes. I decided against it: there's no UI path where the query param changes while you're sitting on the contact page. Adding an effect to handle a situation that can't happen is just a new bug waiting to be misunderstood later. If that ever changes, the effect is easy to add.
Testing It for Real
I didn't want to eyeball this one. A pre-select bug is exactly the kind of thing that "looks fine" and silently regresses, so I drove a real browser against the dev server and checked the actual DOM state instead of trusting a screenshot.
The selected service button carries a -translate-y-1 class (the little neubrutalist "lift"), so I could assert on it directly:
| Scenario | URL after action | Selected option | Budget section |
|---|---|---|---|
| Click Start Project on Automation | /contact?service=automation |
Automation | Visible, automation ranges |
Land on /contact directly |
/contact |
(none) | Hidden |
Tampered ?service=bogus
|
/contact?service=bogus |
(none) | Hidden |
The middle and bottom rows are the ones I actually cared about — they prove the feature doesn't leak into the no-param case and that normalizeService throws away garbage instead of choking on it.
One gotcha while testing: my first attempt navigated between cases with history.pushState inside the running app, and every case came back showing "Automation" still selected. That wasn't a bug in the fix — it was the same behavior I described above. pushState doesn't remount ContactPage, so the initializer never re-ran and the old state persisted. Switching to full page loads for each case gave honest results. A nice reminder that your test harness has to respect the component lifecycle you're actually shipping.
The Takeaway
The bug wasn't a broken function. Everything worked in isolation — the button navigated, the form rendered, the picker picked. The bug lived in the gap between two components, where a piece of user intent quietly fell on the floor.
The URL is a great place to catch things falling through those gaps. It's the one piece of state both sides of a navigation can always agree on. Put the intent in the query string, validate it on the way in, and the two pages don't need to know anything about each other.

