Server-Side Rendering

Carats provides automatic server-side rendering (SSR) through its @carats/ssr package.

Server Entrypoint

The server entrypoint is defined in src/server/entrypoint.ts using defineServerEntry from @carats/ssr. It binds the shared facets, registers culets, and exposes the render and getServerProps functions used by the server plugin.

import { defineServerEntry } from '@carats/ssr';
import facets from '../client/facets.cara';

export default defineServerEntry(facets);

What the Entry Exposes

The object returned by `defineServerEntry` is what the server plugin drives:

  • `render(req)` resolves the facet for the requested URL, runs the page component and returns the rendered `html` together with the collected `head` tags.
  • `getServerProps(req)` resolves the culet for the requested URL and returns its value, or `undefined` when the route has no culet.
  • `facets` is the shared facets object, and `culets` is the registry built by `culet` and `seat`.

This is why the default export must be the entry itself: the Express plugin calls these functions for every request.

Defining Culets

Culets are defined using the culet function from @carats/ssr. Define one only when a route actually needs a server-side prop, and only when the data can be retrieved by a single GET request. The handler receives the request, including the route parameters, cookies, headers, query and method.

import { culet } from '@carats/ssr';

culet<User>('/profile', (req) => {
    return {
        id: '1',
        name: 'Alexander Whitmore',
        username: 'awhitmore',
        email: 'a.whitmore@vault.io',
        phone: '+1 (212) 555-0193',
        website: 'whitmore.capital',
    };
});

The generic parameter is optional but worth keeping: it is what makes the return value match the props of the page that consumes it.

Seating Culets

A culet created in its own file must be registered with the seat function in the server entrypoint. Seating does not wrap or re-create the culet; it returns the same function so it can be used inline.

import { culet, seat } from '@carats/ssr';
import getTradeData from './culets/trade';

seat(getTradeData);

A culet declared inline in the entrypoint registers itself, so it needs no `seat` call. A culet imported from `src/server/culets` was created by `culet()` in its own module and must be seated once. Do not wrap a seated culet in another `culet()` call, because that would register the same handler under a second route.

Express Integration

Carats is integrated with Express through @carats/express. The exported carats function returns a Router that renders pages, resolves server props and serves the built client assets, so it is mounted with app.use. This package requires Express 5.

Server Entrypoint

import { culet, defineServerEntry, seat } from '@carats/ssr';
import facets from '../client/facets.cara';
import getTradeData from './culets/trade';

seat(getTradeData);

culet<User>('/profile', () => {
    return {
        id: '1',
        name: 'Alexander Whitmore',
        username: 'awhitmore',
        email: 'a.whitmore@vault.io',
        phone: '+1 (212) 555-0193',
        website: 'whitmore.capital',
    };
});

export default defineServerEntry(facets);

Express App

import { carats } from '@carats/express'
import express from 'express'

const app = express()
const port = process.env.PORT || 5173

app.use(carats())

const server = app.listen(port, () => {
    console.log(`Server started at http://localhost:${port}`)
})

export default server;

Order the middleware so that `carats()` runs after the routes and static handlers it should not swallow. The plugin returns an Express `Router`, which is why it is mounted with `app.use` rather than registered as a route.

Server Vite Configuration

The server bundle is built from src/server/entrypoint.ts into dist/server. publicDir is disabled because static assets belong to the client build.

import { defineConfig } from 'vite'
import path from 'path'

export default defineConfig({
  publicDir: false,
  build: {
    ssr: path.resolve(__dirname, 'src/server/entrypoint.ts'),
    outDir: path.resolve(__dirname, 'dist/server'),
    emptyOutDir: true,
    minify: true,
    rollupOptions: {
      treeshake: true
    }
  }
})

Full Server Entrypoint Example

// src/server/culets/trade.ts
import { culet } from '@carats/ssr';

export default culet<TradeData>('/trade/:symbol', async (req) => {
    const symbol = req.params.symbol;
    if (!symbol) {
        throw new Error('Symbol is required');
    }
    return {
        symbol: symbol.toUpperCase(),
        price: '$67,234.18',
        change24h: '+2.34%',
        volume: '$28.4B',
        high24h: '$68,102.50',
        low24h: '$65,890.00',
        orderBook: { bids: [], asks: [] },
    };
});
// src/server/entrypoint.ts
import { culet, defineServerEntry, seat } from '@carats/ssr';
import facets from '../client/facets.cara';
import getTradeData from './culets/trade';

seat(getTradeData);

culet<User>('/profile', () => {
    return {
        id: '1',
        name: 'Alexander Whitmore',
        username: 'awhitmore',
        email: 'a.whitmore@vault.io',
        phone: '+1 (212) 555-0193',
        website: 'whitmore.capital',
    };
});

export default defineServerEntry(facets);

Note that the facet route and the culet route are identical strings, including the `:symbol` parameter. A mismatch is the most common reason a burnished page renders without data.

Type Definitions

import { CaratsRequest } from '@carats/core';
import { Facets } from '@carats/render';

interface CaratsServerEntry {
    render: (req: CaratsRequest<never>) => Promise<{
        html?: string;
        head?: string;
    }>;
    getServerProps: <T = any>(req: CaratsRequest<never>) => Promise<T> | T;
    facets: Facets;
    culets: Record<string, Culet>;
}

type CuletArgs = Omit<CaratsRequest, 'data'> & {
    params: Record<string, string>;
};

type Culet<T = any> = (request: CuletArgs) => T;

declare const seat: <T extends Culet>(f: T) => T;
declare function culet<T = any>(route: string, culet: Culet<T>): Culet<T>;
declare function defineServerEntry(facets: Facets): CaratsServerEntry;