TanStack Form v1 with Next.js 15: Complete Type-Safe Forms Tutorial

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Forms are everywhere in web applications, yet building them correctly is deceptively difficult. State management, validation, accessibility, server integration, and performance all collide into one of the most error-prone surfaces in a codebase. TanStack Form v1 was designed to solve this with headless, framework-agnostic primitives that are fully type-safe from the input name to the submitted payload.

In this tutorial, you will build a complete multi-step form in Next.js 15 App Router using TanStack Form v1. You will implement synchronous and asynchronous validation with Zod, dynamic field arrays, integration with Next.js server actions, and a production-ready submit flow.

Prerequisites

Before starting, ensure you have:

  • Node.js 20 or newer installed
  • Familiarity with Next.js App Router and React Server Components
  • Working knowledge of React hooks and TypeScript generics
  • A code editor such as VS Code
  • npm, pnpm, or bun package manager

What You Will Build

A job application form with three sections:

  1. Personal information with synchronous validation
  2. Work experience as a dynamic field array
  3. Portfolio links with async URL validation

The form persists progress, submits through a Next.js server action, and handles errors gracefully.

Step 1: Project Setup

Create a new Next.js application and install dependencies.

npx create-next-app@latest tanstack-form-demo --typescript --app --tailwind --no-src-dir
cd tanstack-form-demo
npm install @tanstack/react-form @tanstack/zod-form-adapter zod

TanStack Form is distributed as separate packages so you only bundle what you need. The React binding is @tanstack/react-form, and @tanstack/zod-form-adapter provides the Zod validation bridge.

Verify installation by starting the dev server.

npm run dev

Step 2: Define the Form Schema with Zod

Strong typing starts with the schema. Create lib/schemas/application.ts.

import { z } from "zod"
 
export const ExperienceSchema = z.object({
  company: z.string().min(1, "Company name is required"),
  role: z.string().min(1, "Role is required"),
  years: z.number().min(0).max(50),
})
 
export const ApplicationSchema = z.object({
  fullName: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  phone: z.string().regex(/^\+?[0-9\s-]{8,}$/, "Invalid phone number"),
  experience: z.array(ExperienceSchema).min(1, "Add at least one experience"),
  portfolio: z.array(z.string().url("Must be a valid URL")).optional(),
  coverLetter: z.string().min(50, "Cover letter must be at least 50 characters"),
})
 
export type Application = z.infer<typeof ApplicationSchema>
export type Experience = z.infer<typeof ExperienceSchema>

The schema becomes the single source of truth. TanStack Form will use it to validate fields and infer all form types automatically.

Step 3: Create a Reusable Field Component

TanStack Form is headless, so you control every piece of markup. Create components/form/TextField.tsx.

import type { AnyFieldApi } from "@tanstack/react-form"
 
interface TextFieldProps {
  field: AnyFieldApi
  label: string
  type?: string
  placeholder?: string
}
 
export function TextField({ field, label, type = "text", placeholder }: TextFieldProps) {
  const errors = field.state.meta.errors
  const hasError = field.state.meta.isTouched && errors.length > 0
 
  return (
    <div className="flex flex-col gap-1">
      <label htmlFor={field.name} className="text-sm font-medium">
        {label}
      </label>
      <input
        id={field.name}
        name={field.name}
        type={type}
        placeholder={placeholder}
        value={field.state.value ?? ""}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
        aria-invalid={hasError}
        aria-describedby={hasError ? `${field.name}-error` : undefined}
        className="rounded border px-3 py-2 focus:ring-2 focus:ring-blue-500"
      />
      {hasError && (
        <span id={`${field.name}-error`} className="text-sm text-red-600">
          {errors.map((err: { message?: string } | string) =>
            typeof err === "string" ? err : err.message
          ).join(", ")}
        </span>
      )}
    </div>
  )
}

Notice three things: the aria-invalid and aria-describedby attributes for screen readers, the onBlur handler that marks the field as touched, and the fact that errors are only shown after the user interacts with the field. These three patterns prevent the most common accessibility bugs in React forms.

Step 4: Build the Application Form

Create app/apply/page.tsx.

"use client"
 
import { useForm } from "@tanstack/react-form"
import { zodValidator } from "@tanstack/zod-form-adapter"
import { ApplicationSchema } from "@/lib/schemas/application"
import { TextField } from "@/components/form/TextField"
import { submitApplication } from "./actions"
 
export default function ApplyPage() {
  const form = useForm({
    defaultValues: {
      fullName: "",
      email: "",
      phone: "",
      experience: [{ company: "", role: "", years: 0 }],
      portfolio: [],
      coverLetter: "",
    },
    validatorAdapter: zodValidator(),
    validators: {
      onChange: ApplicationSchema,
    },
    onSubmit: async ({ value }) => {
      const result = await submitApplication(value)
      if (!result.success) {
        throw new Error(result.error)
      }
    },
  })
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        e.stopPropagation()
        form.handleSubmit()
      }}
      className="mx-auto max-w-2xl space-y-6 p-6"
    >
      <h1 className="text-2xl font-bold">Job Application</h1>
 
      <form.Field name="fullName">
        {(field) => <TextField field={field} label="Full Name" />}
      </form.Field>
 
      <form.Field name="email">
        {(field) => <TextField field={field} label="Email" type="email" />}
      </form.Field>
 
      <form.Field name="phone">
        {(field) => <TextField field={field} label="Phone" type="tel" />}
      </form.Field>
    </form>
  )
}

The form.Field component uses render props. Inside the render function, field gives you everything you need: current value, errors, handlers, and field-level state. Because useForm received defaultValues, TypeScript infers the shape and will error at compile time if you type form.Field name="fullname" with the wrong case.

Step 5: Add Dynamic Field Arrays

Work experience is a list that grows and shrinks. TanStack Form has first-class support for this through form.Field with the mode="array" option.

Add below the phone field.

<form.Field name="experience" mode="array">
  {(field) => (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <h2 className="text-lg font-semibold">Work Experience</h2>
        <button
          type="button"
          onClick={() =>
            field.pushValue({ company: "", role: "", years: 0 })
          }
          className="rounded bg-blue-600 px-3 py-1 text-white"
        >
          Add Experience
        </button>
      </div>
 
      {field.state.value.map((_, index) => (
        <div key={index} className="space-y-2 rounded border p-4">
          <form.Field name={`experience[${index}].company`}>
            {(subField) => <TextField field={subField} label="Company" />}
          </form.Field>
          <form.Field name={`experience[${index}].role`}>
            {(subField) => <TextField field={subField} label="Role" />}
          </form.Field>
          <form.Field name={`experience[${index}].years`}>
            {(subField) => (
              <TextField field={subField} label="Years" type="number" />
            )}
          </form.Field>
          <button
            type="button"
            onClick={() => field.removeValue(index)}
            className="text-sm text-red-600"
          >
            Remove
          </button>
        </div>
      ))}
    </div>
  )}
</form.Field>

The pushValue and removeValue methods mutate the array while keeping type safety intact. Each nested field is typed according to the schema, so experience[0].years is known to be a number.

Step 6: Async Validation for Portfolio URLs

Sometimes validation requires a network call. For portfolio URLs, you want to verify the link actually resolves. TanStack Form supports async validators per field.

Add the portfolio section to the form.

<form.Field
  name="portfolio"
  mode="array"
  validators={{
    onChangeAsync: async ({ value }) => {
      if (!value || value.length === 0) return undefined
      const checks = await Promise.all(
        value.map(async (url) => {
          try {
            const res = await fetch(`/api/check-url?url=${encodeURIComponent(url)}`)
            const data = await res.json()
            return data.ok ? null : `${url} is unreachable`
          } catch {
            return `Could not verify ${url}`
          }
        })
      )
      const errors = checks.filter(Boolean)
      return errors.length > 0 ? errors.join(", ") : undefined
    },
    onChangeAsyncDebounceMs: 500,
  }}
>
  {(field) => (
    <div className="space-y-2">
      <h2 className="text-lg font-semibold">Portfolio Links</h2>
      {field.state.value?.map((_, index) => (
        <form.Field key={index} name={`portfolio[${index}]`}>
          {(sub) => <TextField field={sub} label={`URL ${index + 1}`} />}
        </form.Field>
      ))}
      <button
        type="button"
        onClick={() => field.pushValue("")}
        className="rounded border px-3 py-1"
      >
        Add URL
      </button>
    </div>
  )}
</form.Field>

The onChangeAsyncDebounceMs option prevents hammering the server while the user types. TanStack Form handles the debouncing, race conditions, and cancellation internally.

Create the API route at app/api/check-url/route.ts.

import { NextResponse } from "next/server"
 
export async function GET(request: Request) {
  const url = new URL(request.url).searchParams.get("url")
  if (!url) return NextResponse.json({ ok: false })
 
  try {
    const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3000) })
    return NextResponse.json({ ok: res.ok })
  } catch {
    return NextResponse.json({ ok: false })
  }
}

Step 7: Integrate with Next.js Server Actions

The form submits through a server action. Create app/apply/actions.ts.

"use server"
 
import { ApplicationSchema, type Application } from "@/lib/schemas/application"
import { revalidatePath } from "next/cache"
 
export async function submitApplication(data: Application) {
  const parsed = ApplicationSchema.safeParse(data)
  if (!parsed.success) {
    return {
      success: false as const,
      error: "Invalid application data",
      issues: parsed.error.flatten(),
    }
  }
 
  try {
    await saveToDatabase(parsed.data)
    revalidatePath("/apply")
    return { success: true as const }
  } catch (error) {
    const message = error instanceof Error ? error.message : "Unknown error"
    return { success: false as const, error: message }
  }
}
 
async function saveToDatabase(data: Application) {
  console.info("Saving application", data.email)
}

Always re-validate on the server even though the client already validated. The client is never the source of truth.

Step 8: Submit Button with Subscribe

TanStack Form uses a fine-grained subscription model. Only components that read a piece of state will re-render when that piece changes. Use form.Subscribe to watch the submit button state.

<form.Subscribe
  selector={(state) => [state.canSubmit, state.isSubmitting]}
>
  {([canSubmit, isSubmitting]) => (
    <button
      type="submit"
      disabled={!canSubmit}
      className="w-full rounded bg-blue-600 py-3 font-semibold text-white disabled:opacity-50"
    >
      {isSubmitting ? "Submitting..." : "Submit Application"}
    </button>
  )}
</form.Subscribe>

The rest of the form does not re-render when isSubmitting flips. This matters when forms have more than 20 or 30 fields, since React will otherwise reconcile the entire tree on every keystroke.

Step 9: Persisting Progress to localStorage

Users expect long forms to survive a page refresh. Use form.Subscribe to persist state.

Add inside the page component after useForm.

import { useEffect } from "react"
 
useEffect(() => {
  const stored = localStorage.getItem("application-draft")
  if (stored) {
    try {
      form.reset(JSON.parse(stored))
    } catch {
      console.warn("Failed to restore draft")
    }
  }
}, [form])
 
useEffect(() => {
  const unsubscribe = form.store.subscribe(() => {
    localStorage.setItem("application-draft", JSON.stringify(form.state.values))
  })
  return unsubscribe
}, [form])

The form.store.subscribe hook fires on any state change, letting you sync to external stores without triggering React re-renders.

Step 10: Testing the Form

Install Vitest and React Testing Library.

npm install -D vitest @testing-library/react @testing-library/user-event jsdom @vitejs/plugin-react

Create app/apply/page.test.tsx.

import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import ApplyPage from "./page"
 
test("shows error for invalid email", async () => {
  render(<ApplyPage />)
  const user = userEvent.setup()
  const emailInput = screen.getByLabelText(/email/i)
 
  await user.type(emailInput, "not-an-email")
  await user.tab()
 
  expect(await screen.findByText(/invalid email/i)).toBeInTheDocument()
})
 
test("submit is disabled until form is valid", () => {
  render(<ApplyPage />)
  const button = screen.getByRole("button", { name: /submit/i })
  expect(button).toBeDisabled()
})

Testing Your Implementation

Run the development server and test each scenario.

  1. Start with an empty form and verify the submit button is disabled
  2. Fill the personal section with invalid data and confirm inline errors
  3. Add three experience entries, remove the middle one, and confirm the first and third remain intact
  4. Paste an invalid URL in the portfolio and observe the async error after 500ms
  5. Refresh the page and verify your progress is restored from localStorage

Troubleshooting

Errors appear before the user types. You probably have validatorAdapter but forgot to wrap it with zodValidator(). The function call is required.

Types show as any in field renderers. Make sure defaultValues is fully typed. If you use an inline object, TypeScript infers the exact shape. Passing a partial object will widen types.

Async validation fires too often. Increase onChangeAsyncDebounceMs to 800 or use onBlurAsync instead, which only runs when the field loses focus.

Submit handler not called. Check that you prevented default on the form element and that canSubmit is true. Run console.info(form.state.errors) inside a form.Subscribe to inspect form-wide errors.

Next Steps

  • Add multi-step navigation using form.state.fieldMeta to validate per step
  • Replace localStorage with a server-side draft using Upstash Redis
  • Add file uploads using UploadThing integrated with TanStack Form
  • Build a reusable form-kit package for your monorepo using Turborepo

Conclusion

TanStack Form v1 delivers what other form libraries promise but rarely achieve: true end-to-end type safety, headless flexibility, and performance that scales to forms with hundreds of fields. By combining it with Zod for schema validation and Next.js server actions for submission, you have a production-grade pattern that works equally well for a simple contact form and a complex multi-step wizard.

The key mental shift is accepting that forms are state machines. Once you see fields as subscriptions over a normalized store, the performance model, the validation timing, and the accessibility contract all become clear. Your users get faster, more reliable forms, and your team stops re-inventing the same broken patterns across every project.


Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build a Full-Stack App with Appwrite Cloud and Next.js 15

Learn how to build a complete full-stack application using Appwrite Cloud as your backend-as-a-service and Next.js 15 App Router. Covers authentication, databases, file storage, and real-time features.

30 min read·

Build a Real-Time Full-Stack App with Convex and Next.js 15

Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.

30 min read·