3.9 KiB
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