Contents
Data Fetching
Server props are the only data channel Carats provides. A culet fetches on the server, and a burnished component receives the result.
Culet Lifecycle
The server resolves the culet for the requested route before the page component runs, so the component always renders with complete data and there is no loading state to handle. When a culet throws, the error is passed to the error suspense component.
// 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');
}
const res = await fetch(`https://api.example.com/quote/${symbol}`);
if (!res.ok) {
throw new Error(`Upstream quote failed with ${res.status}`);
}
return res.json();
});
A thrown error, whether from the culet or from the component, reaches the `error` suspense component as an `Error`. An unmatched URL reaches `notFound` instead.
Request Shape
A culet handler receives everything the request carried: params for the route parameters, plus url, query, headers, cookies and method. There is no data property, because culets are only ever reached by a GET.
import { culet } from '@carats/ssr';
culet<Dashboard>('/dashboard', (req) => {
return {
// Route parameters
// req.params
// Query string: req.query
// Cookies: req.cookies
// Headers: req.headers
// req.url, req.method
theme: req.cookies.theme ?? 'dark',
tab: req.query.tab ?? 'overview',
};
});
Because `cookies` and `headers` are available, a culet can personalise a page per visitor without any extra plumbing. The response is still cached per route, so do not put per-visident data in a culet that is not marked `recast` on the consuming component.
Async Handlers
A culet can be async, and awaiting inside it is the normal way to talk to a database or a remote API. Returning a promise is enough; there is no need to await it at the call site.
culet<TradeData>('/trade/:symbol', async (req) => {
const [quote, book] = await Promise.all([
fetchQuote(req.params.symbol),
fetchOrderBook(req.params.symbol)
]);
return { ...quote, orderBook: book };
});
Route Matching Rules
A culet is bound to its page purely by route string. The route of the culet and the route of the facet must be character-for-character identical, including parameter names, otherwise the component renders without data. A route with no culet simply renders with no props, which is the normal case for static pages.
// The facet route
'/trade/:symbol'
// The culet route — must be identical, including the parameter name
culet<TradeData>('/trade/:symbol', handler)
There is no implicit matching by file name or by component name. A typo in the route string fails silently: the page renders, the props are `undefined`, and the error surfaces as a crash inside the component. Registering the culet route as a named constant and importing it into both places removes the failure mode.
Caching and recast
The framework keeps the most recent server props for a route and reuses them, so a burnished component does not refetch on every render. When a page must always show fresh data, opt out per component with the recast option.
Forcing a Refetch
Wrap the component with { recast: true } to bypass the cache on every render. It applies to that component only, which makes it a good fit for pages such as a live dashboard rather than a whole-site default.
import { Burnish } from '@carats/render';
// Cached: reuses the most recent server props for the route
export default Burnish<TradeData>(function (data) {
return <h1>{data.symbol} {data.price}</h1>;
});
import { Burnish } from '@carats/render';
// Not cached: refetches on every render
export default Burnish<TradeData>(function (data) {
return <h1>{data.symbol} {data.price}</h1>;
}, { recast: true });
`recast` belongs on the component, not on the culet, so a single live page does not force every consumer of that route to refetch.
The Server Props Global
The serialized props are exposed on window.carats.ssp. ssp.for is the route they belong to and ssp.data is the value. This is what lets a non-burnished component, or plain code in an afterMount callback, read data that the server already fetched, instead of issuing a second request.
import { afterMount } from '@carats/hooks';
export default function LiveBadge() {
afterMount(() => {
// Read the props the server already resolved for this route
const ssp = window.carats.ssp;
const el = document.getElementById('live-badge');
if (!el || !ssp) return;
el.textContent = `${ssp.for}: ${JSON.stringify(ssp.data)}`;
});
return <span id="live-badge"></span>;
}
The client global is typed, so `window.carats` needs no cast. `ssp.for` is the route the payload belongs to, which lets a shared component verify that the data it is about to read was resolved for the page it is rendered on, and skip it when it was not.