Your first component
A server page renders static HTML. A client island adds interactivity.
A component is a plain function that takes props and returns JSX - no classes, no hooks, no framework imports. The only imports the compiler adds are from @marisjs/runtime when you use signals.
// @runsOn server
import { data } from '@marisjs/runtime';
import { Counter } from '../islands/Counter.tsx';
type Props = {};
export function Index(props: Props) {
const greeting = await data(async () => 'Hello from the server');
return (
<div>
<h1>{greeting}</h1>
<Counter client:hydrate />
</div>
);
}
// @runsOn client
import { signal } from '@marisjs/runtime';
type Props = {};
export function CounterDemo(props: Props) {
const count = signal(0);
function inc() { count.set(count.value + 1); }
function dec() { count.set(count.value - 1); }
function reset() { count.set(0); }
return (
<div class="counter-wrap">
<div class="counter-num">{count.value}</div>
<div class="counter-btns">
<button class="counter-btn" onClick={dec} disabled={count.value === 0} aria-label="decrement">-</button>
<button class="counter-btn" onClick={inc} aria-label="increment">+</button>
<button class="counter-reset" onClick={reset}>reset</button>
</div>
</div>
);
}
What happens at build time
On the first build, the server page is prerendered to static HTML - the page above produces dist/index.html with the counter markup already in it. The island is compiled to a separate module and hydrated only when the page loads in a browser.
The naming rule
The exported component must match its file name: counter.tsx must export Counter, not anything else. The compiler rejects mismatches with a FILENAME_MISMATCH error instead of guessing.
The component body
The only allowed statements in a component body, in this order: signal and computed declarations, plain local consts derived from them, event handler functions, and a single return. Nothing else - no let, no reassignment, no helper functions defined mid-body.
The props type is declared in the same file (immediately above the component) or imported from a dedicated *.types.ts file - never inline in the signature. Non-component helpers live in a separate plain .ts file and are imported.
Children: one typed slot
children is an explicit, typed prop - not implicit magic. A component that accepts nested JSX declares it like any other field:
// @runsOn client
type CardProps = {
title: string;
children: JSX.Element;
};
export function Card(props: CardProps) {
return (
<div class="card">
<h3>{props.title}</h3>
<div class="card-body">{props.children}</div>
</div>
);
}
// <Card title="Hello"><p>Nested content</p></Card> - The caller passes exactly one root element between the tags - multiple siblings must be wrapped.
- Passing children to a component whose props do not declare them is a hard error (
UNEXPECTED_CHILDREN). - The attribute form (
children={...}) is a hard error (CHILDREN_ATTRIBUTE) - nested JSX is the single sanctioned channel. - Islands may receive children: they are server-composed and adopted, never re-parsed or transformed.
signal(). See Signals and computed.