React Server Components

Catmint uses React Server Components (RSC) as the default rendering model. Components are server components unless explicitly marked as client components. This guide covers the server/client boundary, file naming conventions, streaming SSR, hydration, data passing, and how the Vite plugin detects boundaries.

Server Components (Default)

All components in Catmint are server components by default. They render on the server and send HTML to the client. Server components can use async/await directly, access databases, read files, and use server-only APIs:

// app/blog/page.tsx (server component by default)
import { db } from '../lib/db.server'

export default async function BlogPage() {
  const posts = await db.post.findMany({
    orderBy: { createdAt: 'desc' },
  })

  return (
    <div>
      <h1>Blog</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <a href={`/blog/${post.slug}`}>{post.title}</a>
            <time>{post.createdAt.toLocaleDateString()}</time>
          </li>
        ))}
      </ul>
    </div>
  )
}

Server components have several advantages:

  • Zero client bundle impact. Their code is not sent to the browser, reducing JavaScript bundle size.
  • Direct data access. No need for API routes or data fetching hooks -- query your database directly in the component.
  • Async rendering. Use await at the top level of the component function.
  • Secure by default. Secrets, API keys, and server-only logic never leave the server.

Server components cannot use React hooks like useState, useEffect, or event handlers like onClick. For interactive UI, use client components.

Client Components (*.client.tsx)

To create a client component, use the .client.tsx file suffix. Client components are rendered on the server for the initial HTML, then hydrated on the client to become interactive:

// app/components/Counter.client.tsx
import { useState } from 'react'

export function Counter({ initialCount = 0 }: { initialCount?: number }) {
  const [count, setCount] = useState(initialCount)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  )
}

Client components can use all React hooks and browser APIs. They are included in the client JavaScript bundle and hydrated after the initial HTML is delivered.

Using Client Components in Server Components

Import and render client components from server components. Props passed across the boundary must be serializable (no functions, classes, or non-plain objects):

// app/page.tsx (server component)
import { Counter } from './components/Counter.client'
import { db } from './lib/db.server'

export default async function HomePage() {
  const stats = await db.stats.findFirst()

  return (
    <div>
      <h1>Welcome</h1>
      <p>Total visitors: {stats.visitors}</p>

      {/* Client component with serializable props */}
      <Counter initialCount={stats.activeUsers} />
    </div>
  )
}

Server-Only Modules (*.server.ts)

Files with the .server.ts (or .server.tsx) suffix are excluded from the client bundle entirely. Attempting to import a .server module from a client component will produce a build error:

// app/lib/db.server.ts
import { PrismaClient } from '@prisma/client'

export const db = new PrismaClient()

// app/lib/auth.server.ts
export async function verifySession(token: string) {
  // This code never appears in the client bundle
  const secret = process.env.JWT_SECRET
  // ... verification logic
}

Use .server files for database clients, secret management, heavy libraries that should not be bundled for the client, and any logic that must never run in the browser.

File Suffix Summary

SuffixRuns OnIn Client BundleHooks / Interactivity
.tsx (default)ServerNoNo
.client.tsxServer (SSR) + ClientYesYes
.server.tsServer onlyNo (build error if imported)No
.fn.tsServer (compiled to fetch on client)Stub onlyNo

Streaming SSR

Catmint streams server-rendered HTML to the client as it becomes available. When a server component uses await, the framework streams the content that is ready immediately and delivers the rest as async operations complete:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { db } from '../lib/db.server'

async function RecentActivity() {
  const activities = await db.activity.findMany({ take: 20 })
  return (
    <ul>
      {activities.map((a) => (
        <li key={a.id}>{a.description}</li>
      ))}
    </ul>
  )
}

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading activity...</p>}>
        <RecentActivity />
      </Suspense>
    </div>
  )
}

The <Suspense> boundary displays the fallback immediately while RecentActivity fetches data. Once the data is ready, the streamed HTML replaces the fallback. You can also use loading.tsx files for automatic Suspense boundaries at the route level.

Client Hydration

After the server sends HTML, client components are hydrated on the browser. Hydration attaches event handlers and enables interactivity without re-rendering the entire page:

  1. Server renders all components (server and client) to HTML.
  2. HTML is streamed to the browser for immediate display.
  3. Client component JavaScript is loaded and executed.
  4. React hydrates client components, attaching event handlers to the existing DOM.
  5. Server component HTML remains static -- it is not hydrated or re-rendered.

This means server components contribute zero JavaScript to the client bundle. Only client components add to the bundle size.

Data Passing with provideRouteData and useRouteData

Use provideRouteData to load data on the server and useRouteData to consume it in components. The data is serialized and embedded in the HTML during SSR, making it available on the client without an additional fetch:

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

export const productsData = provideRouteData(
  createServerFn(async () => {
    const products = await db.product.findMany()
    return { products }
  })
)

// app/products/page.tsx
import { useRouteData } from 'catmint/server'
import { productsData } from './data.fn'
import { ProductFilter } from './ProductFilter.client'

export default function ProductsPage() {
  const { products } = useRouteData(productsData)

  return (
    <div>
      <h1>Products</h1>
      <ProductFilter products={products} />
    </div>
  )
}

Boundary Detection by Vite Plugin

Catmint's Vite plugin automatically detects the server/client boundary based on file suffixes. You do not need to add "use client" or "use server" directives. The plugin:

  • Treats .client.tsx files as client component entry points and includes them in the client bundle.
  • Strips .server.ts modules from the client build entirely, erroring if a client module tries to import one.
  • Transforms .fn.ts imports into fetch stubs on the client side.
  • All other .tsx files are treated as server components and excluded from the client bundle.

This convention-based approach eliminates ambiguity and makes the boundary explicit at the file level rather than inside the code.

Composition Patterns

Server Component Wrapping a Client Component

// app/page.tsx (server)
import { SearchBar } from './SearchBar.client'
import { db } from './lib/db.server'

export default async function HomePage() {
  const categories = await db.category.findMany()

  return (
    <div>
      <SearchBar categories={categories.map((c) => c.name)} />
      <p>Explore our {categories.length} categories.</p>
    </div>
  )
}

Passing Server Components as Children

// app/components/Tabs.client.tsx
import { useState, ReactNode } from 'react'

export function Tabs({ tabs }: { tabs: { label: string; content: ReactNode }[] }) {
  const [active, setActive] = useState(0)

  return (
    <div>
      <div>
        {tabs.map((tab, i) => (
          <button key={i} onClick={() => setActive(i)}>{tab.label}</button>
        ))}
      </div>
      <div>{tabs[active].content}</div>
    </div>
  )
}

// app/page.tsx (server)
import { Tabs } from './components/Tabs.client'

export default async function Page() {
  return (
    <Tabs
      tabs={[
        { label: 'Overview', content: <p>Server-rendered overview content</p> },
        { label: 'Details', content: <p>Server-rendered details content</p> },
      ]}
    />
  )
}

Server-rendered ReactNode children can be passed to client components. The server renders them to HTML, and the client component receives the rendered output as a prop.

Next Steps