Back to Blog
4 min read

From Frustration to Flow: How We Fixed Form UX in Our Next.js Rebuild

The Problem: Hidden Errors, Lost Users

We were losing people on one of AustinsElite’s core user flows—a multi-step onboarding form originally built in Laravel 12. Even though we’re migrating to Laravel 12, the old patterns followed us. The form looked modern, but underneath, it carried legacy assumptions that broke the experience.

The biggest issue? Poor feedback. Users would fill out three steps, hit submit, and get a generic error at the top: "Please correct the errors below." No indication of where. No real-time cues. And worse—certain fields were pre-filled with defaults that made sense in a server-rendered PHP world but caused silent validation failures in our React-driven rebuild.

We saw 42% drop-off at the final step. That wasn’t just bad UX—it was a broken contract with users who’d invested time to get that far.

The Fix: Real-Time Validation and Killing the Defaults

We tore it down and rebuilt with two principles: show feedback early, and never assume user intent.

First, we dropped the pre-filled defaults. In the Laravel version, fields like "Phone Type" defaulted to "Mobile"—harmless, right? But in our new form state, that meant the field was technically "filled," so our validation skipped it. Except when the backend rejected it for format issues, the error surfaced too late. We made every field truly optional unless required, and forced explicit selection. No more invisible assumptions.

Then, we implemented field-level validation with immediate feedback using React Hook Form and Zod. As users tabbed through inputs, we validated in real time—not on blur, but on change, with debounce to avoid noise. For example:

const { register, formState: { errors }, trigger } = useForm<FormData>({
  resolver: zodResolver(schema),
});

// Inline handling in component
const handleEmailChange = async (e: ChangeEvent<HTMLInputElement>) => {
  await trigger('email'); // Validate immediately
};

This gave users instant clarity. Red borders? Wrong format. Green check? You’re good. We added microcopy like "Looks good!" or "Please enter a real email"—tiny touches that reduce cognitive load.

We also decoupled validation from submission. Instead of waiting until the end to check everything, we validated each step on navigation. If Step 1 wasn’t clean, you couldn’t proceed. This shifted errors from reactive to preventative.

Defensive Submission: Wrapping Logic in Try/Catch Guards

Even with better validation, we hit a nasty edge case: form data serialization failing silently during submission. One user’s browser was mangling nested objects (looking at you, Safari 14), and our JSON.stringify() call was throwing uncaught errors. The spinner spun. Nothing happened. No error. No retry.

We added defensive guards around the submission handler:

const onSubmit = async (data: FormData) => {
  try {
    const serialized = JSON.stringify(data);
    const response = await fetch('/api/submit', {
      method: 'POST',
      body: serialized,
      headers: { 'Content-Type': 'application/json' },
    });

    if (!response.ok) throw new Error('Submission failed');
    trackEvent('form_submit_success');
    router.push('/success');
  } catch (err) {
    trackEvent('form_submit_error', { error: err.message });
    setError('global', { message: 'Something went wrong. Please try again.' });
  }
};

This didn’t prevent bad data—but it did ensure users weren’t left hanging. We logged the error, showed a clear message, and offered a retry button. Resilience over perfection.

The Result? Fewer Tears, More Conversions

After deploying, we saw immediate gains: final-step drop-off dropped to 18%, and support tickets about "broken form" fell by 60%. More importantly, user testing showed people felt more in control.

This wasn’t just a technical refactor—it was a mindset shift. Moving from Laravel to Next.js forced us to re-examine every assumption. The defaults that once seemed helpful? They were ghosts of a server-centric past. The lack of client-side guards? A debt we inherited.

If you’re rebuilding a form-heavy app in Next.js—especially one migrating from a legacy stack—don’t just port the logic. Interrogate it. Test the edges. And never let a default value speak for the user.

Newer post

How We Decoupled PDF Generation in Next.js Using Queued Workers

Older post

Why We Bumped Vite 0.11 Versions and What It Revealed About Our Build Chain