Create your own
Lesson illustration

Progressive Forms with TanStack Start

Hello! Welcome back to our course on mastering TanStack Start.

In our last lesson, you successfully refactored a client-side router to use TanStack's file-based conventions. This was a crucial step, as it laid the foundation for tightly integrating server-side logic with your UI. Now, we'll build directly on that foundation.

Today's learning outcome is to implement a progressively enhanced form using TanStack Start server functions for submission and validation. This is a cornerstone pattern in modern web development, allowing you to build forms that are robust, accessible, and provide a great user experience. We'll create a form that works with a simple HTML submission but is "enhanced" with client-side validation and state management when JavaScript is available.

1. The Philosophy: Progressive Enhancement

Before we dive into code, let's clarify the "why." Progressive enhancement is the principle of starting with a baseline of functionality that works for all users (e.g., a standard HTML form submission) and then adding more advanced features (like client-side validation and no-reload submissions) for users with capable browsers.

In the context of a TanStack Start application, this means:

  • Baseline: The form submits data to a server endpoint via a standard POST request and works even if JavaScript is disabled or fails to load.
  • Enhancement: When JavaScript loads, we use TanStack Form to take over, providing instant validation feedback, managing submission states (e.g., showing a spinner), and handling server responses without a full page reload.

This approach creates a more resilient and accessible application, a key benefit of moving from a pure SPA to an SSR framework.

2. The Core Technologies: Server Functions and TanStack Form

To achieve this, we'll use two key parts of the TanStack ecosystem.

Server Functions: Your Bridge to the Server

As we've touched on before, server functions (createServerFn) are the mechanism for defining server-only logic that can be called from anywhere, including client components. For forms, they act as the API endpoint that receives and processes the submitted data.

Server Functions | TanStack Start React Docs

Let's start with a quick review of what server functions are and how they work. This official documentation provides a concise overview.

Read the sections "What are Server Functions?", "Basic Usage", and the subsection on "Form Data". Focus on how createServerFn can be configured for a POST method and how it can process FormData objects.

Under the hood, createServerFn simply generates a standard API route. When you call the function from the client, it's just making a fetch request to that route. This video explains the mechanics clearly.

Tanstack Start vs NextJS - Server Functions Battle

To understand what's happening behind the scenes, watch this segment from Jack Herrington's video.

Watch from 04:31 to 06:19. The key takeaway is that server functions are not magic; they are a convenient, type-safe wrapper around standard HTTP requests and API endpoints.

TanStack Form: The Client-Side Powerhouse

While server functions handle the backend, @tanstack/react-form manages the frontend experience. It's a modern, headless, and type-safe library designed for exactly this kind of integration.

Tanner just fixed forms (I'm so hyped)

This video from Theo - t3.gg gives a great, high-energy overview of why TanStack Form is so well-suited for modern SSR frameworks.

Watch from 15:12 to 19:28. Pay close attention to how he describes the first-class integration with server-side code, the use of createServerValidate, and the concept of the form working seamlessly even if JavaScript hasn't loaded.

3. Step-by-Step Implementation

The best way to understand this pattern is to build it. We'll follow the official integration guide, which lays out the process perfectly. The entire process can be broken down into defining the server logic and then wiring it up to the client component.

React Meta-Framework Usage | TanStack Form React Docs

This documentation page is our primary guide for the rest of the lesson. We will walk through its sections together. I recommend keeping it open as a reference.

Familiarize yourself with the overall structure of the example in the "Using TanStack Form in TanStack Start" section. Notice how it defines form options, server functions, a loader, and the final component all within the same route file.

Let's break down the code from that guide into logical steps. Imagine we are creating a new route at src/routes/signup.tsx.

Step 1: Define Shared Form Options

To ensure type safety between the client and server, we start by defining the shape of our form.

// src/routes/signup.tsx
import { formOptions } from '@tanstack/react-form';

export const formOpts = formOptions({
  defaultValues: {
    name: '',
    age: 18,
  },
});

Step 2: Create the Server-Side Submission Handler

This is the most critical part. We create a server function that will receive the FormData, validate it, and perform an action (like saving to a database).

  • createServerValidate: A helper from @tanstack/react-form/start that creates a server-side validation function. It can run rules that should only exist on the server (e.g., checking if a username is already taken).
  • createServerFn: Our main server function. We specify method: 'POST' and use a .validator to ensure the incoming data is FormData.
  • Handler Logic: Inside the .handler, we try to run our serverValidate function.
    • Success: If validation passes, we can proceed with our logic (e.g., database operations).
    • Validation Error: If serverValidate fails, it throws a ServerValidateError. We catch this and return e.response, which contains the validation errors in a format TanStack Form understands.
// src/routes/signup.tsx (continued)
import { createServerFn } from '@tanstack/react-start';
import {
  createServerValidate,
  ServerValidateError,
} from '@tanstack/react-form/start';

// Server-side validation logic
const serverValidate = createServerValidate({
  ...formOpts,
  onServerValidate: ({ value }) => {
    if (value.age < 18) {
      return 'Server validation: You must be at least 18 to sign up.';
    }
  },
});

// The server function that handles the POST request
export const handleForm = createServerFn({
  method: 'POST',
})
  .validator((data: unknown) => {
    if (!(data instanceof FormData)) {
      throw new Error('Invalid form data');
    }
    return data;
  })
  .handler(async (ctx) => {
    try {
      const validatedData = await serverValidate(ctx.data);
      console.log('Data is valid:', validatedData);
      // TODO: Persist data to your database here
    } catch (e) {
      if (e instanceof ServerValidateError) {
        // If validation fails, return the error response
        return e.response;
      }
      // Handle other unexpected errors
      console.error(e);
      return 'An internal error occurred.';
    }
    return 'Form submitted successfully!';
  });

Step 3: Create the Form Component

Now we build the React component.

  1. The <form> element: This is the key to progressive enhancement. The action attribute is set to handleForm.url. This special .url property gives us the unique URL for our server function. If JS is off, the browser will POST directly to this URL.
  2. useForm hook: We initialize TanStack Form with our shared formOpts.
  3. form.Field: We create our input fields. Here, you can add client-side validation via the validators prop for instant feedback.
  4. Displaying Errors: We can display both client-side (field.state.meta.errors) and server-side (formErrors) validation messages.
// src/routes/signup.tsx (continued)
import { createFileRoute } from '@tanstack/react-router';
import { useForm, useStore } from '@tanstack/react-form';

export const Route = createFileRoute('/signup')({
  component: SignupForm,
});

function SignupForm() {
  const form = useForm({
    ...formOpts,
  });

  const formErrors = useStore(form.store, (formState) => formState.errors);

  return (
    <div>
      <h3>Sign Up</h3>
      {/* The action prop enables progressive enhancement */}
      <form
        action={handleForm.url}
        method="post"
        encType="multipart/form-data"
        onSubmit={(e) => {
          e.preventDefault();
          e.stopPropagation();
          form.handleSubmit(); // JS enhancement: handle submit client-side
        }}
      >
        {/* Display general form errors from the server */}
        {formErrors.map((error) => (
          <p key={error as string} style={{ color: 'red' }}>{error}</p>
        ))}

        <form.Field
          name="name"
          validators={{
            onChange: ({ value }) => !value ? 'Client: Name is required' : undefined,
          }}
        >
          {(field) => (
            <div>
              <label htmlFor={field.name}>Name:</label>
              <input
                id={field.name}
                name={field.name}
                value={field.state.value}
                onChange={(e) => field.handleChange(e.target.value)}
              />
              {field.state.meta.touchedErrors.map((error) => (
                <p key={error as string} style={{ color: 'red' }}>{error}</p>
              ))}
            </div>
          )}
        </form.Field>

        {/* ... other fields like 'age' ... */}

        <form.Subscribe
          selector={(formState) => [formState.canSubmit, formState.isSubmitting]}
        >
          {([canSubmit, isSubmitting]) => (
            <button type="submit" disabled={!canSubmit}>
              {isSubmitting ? 'Submitting...' : 'Submit'}
            </button>
          )}
        </form.Subscribe>
      </form>
    </div>
  );
}

Note: The official docs show a slightly more advanced pattern with getFormDataFromServer, loader, and mergeForm. That pattern is for preserving form state and errors across full-page reloads that can happen after a non-JS submission. The example above focuses on the core client-side enhancement via onSubmit. Both approaches are valid and achieve progressive enhancement. The loader-based approach is more robust for complex scenarios.

Conclusion

You have now implemented one of the most powerful patterns in modern SSR frameworks. By combining TanStack Form and Server Functions, you've created a form that is fast, resilient, and provides an excellent user experience.

Key Takeaways:

  • Progressive Enhancement: Your form works at a basic level with just HTML and gracefully "enhances" with JavaScript for a richer client-side experience.
  • Server Functions as Endpoints: createServerFn with method: 'POST' acts as the secure, server-only endpoint for your form submissions.
  • Dual Validation: You can provide instant feedback with client-side validation (validators on form.Field) and enforce critical rules on the server (onServerValidate).
  • The action Attribute is Key: Using handleForm.url in the <form> tag is the simple but crucial link that enables the non-JavaScript submission to work.

In our next lesson, we'll broaden our scope from a single component to the entire page. We will explore how to configure streaming SSR for progressive page rendering and handle potential hydration mismatches, further improving your application's performance and user experience.

Can't find a good explanation? Sign up and we'll make it for you

Sign up