Server Functions

Server functions let you write server-side logic that can be called directly from React components. On the server, they execute as normal function calls. On the client, Catmint's Vite plugin compiles them into fetch requests to auto-generated API endpoints. This guide covers createServerFn, validation, isomorphic functions, route data bridging, and the compilation model.

createServerFn

The createServerFn function from catmint/server defines a server function by wrapping a handler with optional configuration:

// app/lib/posts.fn.ts
import { createServerFn } from 'catmint/server'
import { db } from './db.server'

export const getPosts = createServerFn(async () => {
  const posts = await db.post.findMany({
    orderBy: { createdAt: 'desc' },
  })
  return posts
})

The handler runs exclusively on the server. You can safely use database clients, file system access, environment variables, and other server-only APIs inside it.

Calling from a Component

// app/blog/page.tsx
import { getPosts } from '../lib/posts.fn'

export default async function BlogPage() {
  const posts = await getPosts()

  return (
    <div>
      <h1>Blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  )
}

Typed Input

Server functions accept a typed input as the first argument to the handler. Pass the input when calling the function:

// app/lib/posts.fn.ts
import { createServerFn } from 'catmint/server'
import { db } from './db.server'

export const getPost = createServerFn(async (input: { slug: string }) => {
  const post = await db.post.findUnique({ where: { slug: input.slug } })
  if (!post) {
    throw new Error('Post not found')
  }
  return post
})

export const createPost = createServerFn(
  async (input: { title: string; body: string }) => {
    return db.post.create({
      data: { title: input.title, body: input.body, slug: input.title.toLowerCase().replace(/\s+/g, '-') },
    })
  },
  { method: 'POST' },
)
// Calling with input
const post = await getPost({ slug: 'hello-world' })
await createPost({ title: 'New Post', body: 'Content here' })

Validation

The validate option adds runtime validation to the input. It runs before the handler and rejects invalid data. You can pass any Standard Schema object (Zod, Valibot, ArkType, etc.) or a plain validation function that throws on invalid input:

// app/lib/posts.fn.ts
import { createServerFn } from 'catmint/server'
import { z } from 'zod'
import { db } from './db.server'

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
  tags: z.array(z.string()).optional(),
})

export const createPost = createServerFn(
  async (input: z.infer<typeof CreatePostSchema>) => {
    return db.post.create({
      data: { title: input.title, body: input.body, tags: input.tags },
    })
  },
  {
    method: 'POST',
    validate: CreatePostSchema,
  },
)

The validator receives the raw input and should return the validated data (or throw an error). The validated output is passed to the handler.

Options

The second argument to createServerFn is an optional configuration object:

createServerFn(handler, {
  method: 'POST',       // HTTP method (default: 'GET')
  validate: schema,     // Standard Schema or validation function
})
FieldRequiredDescription
methodNoHTTP method for the auto-generated RPC endpoint ('GET', 'POST', 'PUT', 'DELETE'). Default: 'GET'
validateNoInput validation. Accepts a Standard Schema object or a plain function that throws on invalid input

Client-Side Compilation

When a server function is imported in a client component, Catmint's Vite plugin transforms the import. Instead of bundling the handler code into the client, it replaces the function with a fetch call to an auto-generated endpoint:

// What you write (client component):
import { createPost } from '../lib/posts.fn'
await createPost({ title: 'Hello', body: 'World' })

// What the client bundle contains (conceptually):
async function createPost(input) {
  const res = await fetch('/_catmint/fn/posts/createPost', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  })
  return res.json()
}

Server function files use the *.fn.ts suffix to signal this transformation to the Vite plugin. The handler code, its dependencies, and any server-only imports are excluded from the client bundle.

Content-Type Validation

Server functions validate the Content-Type header on incoming requests. By default, only application/json is accepted. Requests with an unexpected content type receive a 415 Unsupported Media Type response. This prevents CSRF attacks by ensuring that only intentional API calls trigger the handler.

createIsomorphicFn

Use createIsomorphicFn from catmint/server when you need different implementations for server and client environments. The correct implementation is selected at build time:

// app/lib/analytics.ts
import { createIsomorphicFn } from 'catmint/server'

export const trackEvent = createIsomorphicFn({
  server: (event: string, data: Record<string, unknown>) => {
    // Server: write to database
    console.log('Server tracking:', event, data)
  },
  client: (event: string, data: Record<string, unknown>) => {
    // Client: send to analytics service
    window.analytics?.track(event, data)
  },
})

The server handler is stripped from the client bundle, and the client handler is stripped from the server bundle. Both must have the same function signature.

Route Data Bridge: provideRouteData and useRouteData

The provideRouteData and useRouteData functions create a typed bridge for passing data from server functions to components. This is the recommended way to load initial data for a route:

// app/blog/data.fn.ts
import { createServerFn } from 'catmint/server'
import { provideRouteData } from 'catmint/server'
import { db } from '../lib/db.server'

export const blogData = provideRouteData(
  createServerFn(async () => {
    const posts = await db.post.findMany({
      orderBy: { createdAt: 'desc' },
      take: 10,
    })
    return { posts }
  })
)
// app/blog/page.tsx
import { useRouteData } from 'catmint/server'
import { blogData } from './data.fn'

export default function BlogPage() {
  const { posts } = useRouteData(blogData)

  return (
    <div>
      <h1>Blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  )
}

The useRouteData hook is fully typed based on the return type of the server function passed to provideRouteData. On the server, it invokes the function directly. On the client, it reads the serialized data embedded during SSR.

Error Handling

Errors thrown inside a server function handler are serialized and sent back to the caller. On the client, they are re-thrown as errors with the original message:

// app/lib/posts.fn.ts
export const deletePost = createServerFn(
  async (input: { id: string }) => {
    const post = await db.post.findUnique({ where: { id: input.id } })
    if (!post) {
      throw new Error('Post not found')
    }
    await db.post.delete({ where: { id: input.id } })
    return { success: true }
  },
  { method: 'DELETE' },
)

// Client usage with error handling
try {
  await deletePost({ id: '123' })
} catch (error) {
  console.error('Failed to delete:', error.message)
}

Next Steps