Files
findyourpilot/.agents/skills/tanstack-start-best-practices/rules/sf-create-server-fn.md
2026-03-02 21:16:26 +01:00

3.9 KiB

sf-create-server-fn: Use createServerFn for Server-Side Logic

Priority: CRITICAL

Explanation

createServerFn() creates type-safe server functions that can be called from anywhere - loaders, components, or other server functions. The code inside the handler runs only on the server, with automatic RPC for client calls.

Bad Example

// Using fetch directly - no type safety, manual serialization
async function createPost(data: CreatePostInput) {
  const response = await fetch('/api/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  })
  if (!response.ok) throw new Error('Failed to create post')
  return response.json()
}

// Or using API routes - more boilerplate
// api/posts.ts
export async function POST(request: Request) {
  const data = await request.json()
  // No type safety from client
  const post = await db.posts.create({ data })
  return new Response(JSON.stringify(post))
}

Good Example

// lib/posts.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { db } from './db.server'

const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  published: z.boolean().default(false),
})

export const createPost = createServerFn({ method: 'POST' })
  .validator(createPostSchema)
  .handler(async ({ data }) => {
    // This code only runs on the server
    const post = await db.posts.create({
      data: {
        title: data.title,
        content: data.content,
        published: data.published,
      },
    })
    return post
  })

// Usage in component
function CreatePostForm() {
  const createPostMutation = useServerFn(createPost)

  const handleSubmit = async (formData: FormData) => {
    try {
      const post = await createPostMutation({
        data: {
          title: formData.get('title') as string,
          content: formData.get('content') as string,
          published: false,
        },
      })
      // post is fully typed
      console.log('Created post:', post.id)
    } catch (error) {
      console.error('Failed to create post:', error)
    }
  }
}

Good Example: GET Function for Data Fetching

// lib/posts.functions.ts
export const getPosts = createServerFn()  // GET is default
  .handler(async () => {
    const posts = await db.posts.findMany({
      orderBy: { createdAt: 'desc' },
      take: 20,
    })
    return posts
  })

export const getPost = createServerFn()
  .validator(z.object({ id: z.string() }))
  .handler(async ({ data }) => {
    const post = await db.posts.findUnique({
      where: { id: data.id },
    })
    if (!post) {
      throw notFound()
    }
    return post
  })

// Usage in route loader
export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    return await getPost({ data: { id: params.postId } })
  },
})

Good Example: With Context and Dependencies

// Compose server functions
export const getPostWithComments = createServerFn()
  .validator(z.object({ postId: z.string() }))
  .handler(async ({ data }) => {
    const [post, comments] = await Promise.all([
      getPost({ data: { id: data.postId } }),
      getComments({ data: { postId: data.postId } }),
    ])

    return { post, comments }
  })

Key Benefits

  • Type safety: Input/output types flow through client and server
  • Automatic serialization: No manual JSON parsing
  • Code splitting: Server code never reaches client bundle
  • Composable: Call from loaders, components, or other server functions
  • Validation: Built-in input validation with schema libraries

Context

  • Default method is GET (idempotent, cacheable)
  • Use POST for mutations that change data
  • Server functions are RPC calls under the hood
  • Validation errors are properly typed and serialized
  • Import is safe on client - build process replaces with RPC stub