Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Type-Safe Server Functions with `createServerFn`

Hello! Welcome back to our course on migrating React SPAs to SSR with Tanstack Start.

In our last lesson, we established the fundamental separation between the server and client entry points. We saw how Tanstack Start's isomorphic nature presents a challenge for server-only code and introduced createServerFn as the primary tool to solve this, ensuring sensitive logic and environment variables remain securely on the server.

Today, we'll take a deep dive into that very tool. Our learning outcome is to create type-safe server functions (createServerFn) to execute server-only logic. We will move beyond the concept and into the practical mechanics of building robust, secure, and type-safe server operations, which are the backbone of any full-stack application.

This lesson will cover:

  • The detailed syntax for creating a server function.
  • The critical importance of input validation for type safety.
  • Using Zod to define and enforce validation schemas.
  • Calling server functions from both route loaders and component event handlers.
  • How server functions handle return values and errors.

Mastering server functions is a pivotal step in your goal of transforming a SPA, as it's the mechanism for shifting data fetching and mutation logic from the client to the server.

1. The Anatomy of a Server Function

As a quick recap, code in Tanstack Start is isomorphic by default, meaning it can run on both the server and the client. This is a problem for logic that relies on server-only capabilities, like accessing a database or using secret API keys.

createServerFn solves this by creating a function that is guaranteed to run only on the server. When called from client-side code, the framework transparently creates a network request (an RPC call) to execute the function on the server and returns the result.

Let's look at the basic structure.

Server Functions | TanStack Start React Docs

The official Tanstack Start documentation, in the 'Server Functions' guide, provides the foundational syntax. We'll start here.

Please read the sections 'What are Server Functions?' and 'Basic Usage' to review server function basics. Focus on the createServerFn().handler() chain and how to specify the HTTP method (GET or POST).

As you read, you'll see the core pattern is:

  1. Import createServerFn from @tanstack/react-start.
  2. Call createServerFn(), optionally passing an object to configure it (e.g., { method: 'POST' }). By default, the method is GET.
  3. Chain the .handler() method, passing it an async function that contains your server-only logic.

Here's a simple example defining both a GET and a POST server function:

import { createServerFn } from '@tanstack/react-start';

// A GET function, suitable for fetching data.
export const getStatus = createServerFn('GET', async () => {
  // This code only runs on the server.
  // It can safely access process.env, databases, etc.
  return { status: 'ok', serverTime: new Date().toISOString() };
});

// A POST function, suitable for actions that change state.
export const createUser = createServerFn('POST', async (userData: { name: string }) => {
  // Logic to create a user in the database...
  console.log(`Creating user on the server: ${userData.name}`);
  return { success: true, userId: '123' };
});

The following video provides a great visual demonstration of this concept, showing how a secret key placed inside a regular loader is exposed to the client, but remains secure when moved into a createServerFn.

TanStack Start vs Next.js: Is It Really Better?

In the video 'Learn TanStack Start in 15 Minutes', the presenter clearly demonstrates the problem and the createServerFn solution.

Watch <span data-type="resource_video_timerange" data-resource-subitem-id="0c2bfedc" data-range-start="404" data-range-end="527">creating server functions</span>. Pay attention to how the server function is created and how, when you inspect the browser's console and network tab, the secret is no longer exposed. The mention of it being an RPC call that looks like a simple API URL is a key insight.

2. Ensuring Type Safety with Input Validation

A server function defines a boundary between your client and server. Any data crossing this boundary from the client is untrusted and must be validated. createServerFn has a built-in way to handle this, which also provides end-to-end type safety.

You can chain an .inputValidator() method before the .handler(). This validator receives the raw input from the client, and its return value is what gets passed, fully typed, to your handler.

While you can write a manual validation function, the standard and most robust approach is to use a schema validation library like Zod.

Server Functions | TanStack Start React Docs

Let's return to the official documentation to see how validation is formally implemented, with a specific focus on using Zod.

Read the section 'Parameters & Validation' to understand validation and safety. Review the 'Basic Parameters' example, then focus on the 'Validation with Zod' example, specifically robust schema validation. This pattern is what you will use most often.

Let's combine these ideas into a practical, type-safe server function for creating a blog post.

  1. Define a Zod schema: This describes the expected shape and types of the input data.
  2. Use the schema in .inputValidator(): Tanstack Start will automatically parse and validate the incoming data against this schema.
  3. Access the typed data in .handler(): The data property in the handler's argument is now fully typed according to your Zod schema.
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';

// 1. Define the Zod schema for creating a post
const CreatePostSchema = z.object({
  title: z.string().min(5, 'Title must be at least 5 characters long.'),
  content: z.string().min(20, 'Content must be at least 20 characters long.'),
  tags: z.array(z.string()).optional(),
});

// 2. Create the type-safe server function
export const createPost = createServerFn({
  method: 'POST',
})
  // 3. Use the schema for validation
  .inputValidator(CreatePostSchema)
  // 4. The 'data' parameter is now fully typed as `z.infer<typeof CreatePostSchema>`
  .handler(async ({ data }) => {
    console.log('Received validated title on server:', data.title);
    // `data.content` and `data.tags` are also available and typed.

    // Here you would interact with your database.
    // const newPost = await db.post.create({ data });

    // For this example, we'll just simulate a successful creation.
    const newPost = { id: Math.random().toString(36).substring(7), ...data };

    return newPost;
  });

If the client sends data that doesn't match the CreatePostSchema, the server function will automatically throw a validation error before your handler code is even executed, protecting your server logic from invalid input.

3. Calling Server Functions & Handling Responses

Now that we know how to define server functions, how do we call them? You can call them from anywhere in your app just like a regular async function. The most common places are in route loaders and component event handlers.

The LogRocket article, "A step-by-step guide to building a full-stack app with Tanstack Start," provides excellent, real-world examples of this.

A step-by-step guide to building a full-stack app with ...

This blog post demonstrates a full-stack recipe application, showing how server functions are used for all data operations.

You don't need to read the entire article, just focus on these specific patterns: Defining Functions (in 'Creating server functions'): Skim <tf start="To populate the UI components" end="Failed to update recipe"); } });">the server functions to see a collection of CRUD functions (getRecipes, createRecipe, etc.). This reinforces the pattern of centralizing your server logic. Calling from a Loader (in 'Next, go to the main index.tsx file...'): See how getRecipes is called directly inside the home loader to fetch initial page data. Calling from an Event Handler (in 'The only potentially confusing part is the handleSubmit function:'): Observe how createRecipe is called inside the submit handler to handle a form submission.

Handling Success, Errors, and Redirects

When you call a server function, it returns a promise that resolves or rejects based on the outcome of the handler.

  • Success: If the handler returns a value, the promise resolves with that value. The data is automatically serialized as JSON.
  • Errors: If your handler throws an Error, the promise on the client will reject with a serialized version of that error. You can wrap the call in a try...catch block to handle it gracefully.
  • Redirects & Not Found: You can also throw special responses from @tanstack/react-router, like redirect() or notFound(), which the router will automatically handle.

Let's see this in action with our createPost function.

// In a React component, e.g., src/routes/posts/new.tsx

function NewPostForm() {
  const [error, setError] = useState<string | null>(null);
  
  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    setError(null);
    
    const formData = new FormData(event.currentTarget);
    const postData = {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    };

    try {
      // Call the server function with client-side data
      const newPost = await createPost(postData);
      console.log('Post created successfully!', newPost);
      // You would typically navigate to the new post's page here
      // navigate({ to: `/posts/${newPost.id}` });
    } catch (err: any) {
      // Catches validation errors or any other error thrown in the handler
      console.error('Failed to create post:', err);
      setError(err.message); // Display the error message to the user
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Form inputs for title and content */}
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <button type="submit">Create Post</button>
    </form>
  );
}

4. A Glimpse Under the Hood

How does this "magic" work? How does the client bundle know not to include the server function's body?

During the build process, the Tanstack Start Vite plugin finds all createServerFn definitions.

  1. It assigns each function a unique ID.
  2. On the server, it keeps the full function body and maps the ID to it.
  3. In the client bundle, it replaces the entire server function implementation with a small "stub." This stub's only job is to make a fetch request to a special API endpoint (/api/server-fn/:id), sending along any arguments.

The server receives this request, uses the ID to find and execute the correct function, and sends the result back. This is how your server-side code, database clients, and secret keys are never exposed to the browser.

The official documentation has an advanced section on this, which we will explore in the next lesson. For now, just know that this build-time transformation is what makes server functions both powerful and secure.

Conclusion

In this lesson, we moved from a conceptual understanding to a practical mastery of createServerFn. You now have the tools to create secure, server-only logic and integrate it seamlessly into your React application.

Key Takeaways:

  • Server functions are defined with createServerFn('METHOD').inputValidator(schema).handler(logic).
  • Type safety is achieved by using a validation library like Zod with the .inputValidator() method. This is not optional; it's essential for robust applications.
  • Server functions can be called like any other async function, typically from route loaders for data fetching and component event handlers for mutations.
  • The framework handles the RPC mechanism, serializing data, errors, and redirects across the network boundary.
  • The build process is what guarantees security by stripping server logic from the client bundle.

In our next lesson, we will put this last point to the test. We will analyze the production build output to verify that server functions are excluded from the client bundle, giving you concrete proof and a deeper understanding of the security model.

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

Sign up