🚀 Complete Full-Stack Tutorial - Coherent.js
Build a server-rendered, interactive app with Coherent.js in about 10 minutes.
This tutorial builds a small counter app: the server renders the HTML with @coherent.js/core, and the browser makes it interactive with hydrate() from @coherent.js/client.
🎯 What You'll Build
A counter app that demonstrates:
- ✅ Server-Side Rendering (SSR)
- ✅ Client-Side Hydration
- ✅ Event Handling
- ✅ Client State
Time to complete: 10 minutes Difficulty: Beginner Prerequisites: Node.js 22.12+
📁 Step 1: Project Setup
mkdir my-coherent-app
cd my-coherent-app
npm init -y
npm pkg set type=module
npm install @coherent.js/core @coherent.js/client
npm install -D esbuildCreate these files:
my-coherent-app/
├── components/
│ └── Counter.js
├── client.js
├── public/
│ └── hydration.js (generated)
├── server.js
└── package.json🎨 Step 2: Create the Counter Component
Create components/Counter.js. The same component runs on the server (to produce HTML) and in the browser (to attach the handlers):
export function Counter({ count = 0 }) {
return {
div: {
className: 'counter',
children: [
{ h2: { text: 'Interactive Counter' } },
{ p: { className: 'count-display', text: `Count: ${count}` } },
{
div: {
className: 'button-group',
children: [
{ button: { className: 'btn', text: '−', onClick: (event) => event.setState({ count: event.state.count - 1 }) } },
{ button: { className: 'btn', text: 'Reset', onClick: (event) => event.setState({ count: 0 }) } },
{ button: { className: 'btn', text: '+', onClick: (event) => event.setState({ count: event.state.count + 1 }) } }
]
}
}
]
}
};
}Key Points:
- Props in, object out -
Counter({ count })returns a plain object describing the HTML. - Event handlers are functions - on the server they render nothing; in the browser
hydrate()attaches them. - One event argument - a handler receives a wrapped event with
event.state,event.setState(),event.props,event.targetandevent.preventDefault(). event.setState({ count })- updates the hydrated component's state and patches the DOM.
🖥️ Step 3: Create the Client and the Server
Create client.js:
import { hydrate } from '@coherent.js/client';
import { Counter } from './components/Counter.js';
// Hydrate the element the component's root renders
const counterEl = document.querySelector('.counter');
if (counterEl) hydrate(Counter, counterEl, { initialState: { count: 0 } });Bundle it for the browser:
npx esbuild client.js --bundle --format=esm --outfile=public/hydration.jsCreate server.js:
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { render } from '@coherent.js/core';
import { Counter } from './components/Counter.js';
const hydrationBundle = readFileSync(new URL('./public/hydration.js', import.meta.url), 'utf-8');
// The whole page is a component too
const createPage = () => ({
html: {
lang: 'en',
children: [
{
head: {
children: [
{ meta: { charset: 'utf-8' } },
{ title: { text: 'My Coherent.js App' } },
{
style: {
text: `
body { font-family: Arial, sans-serif; padding: 40px; }
.counter { background: #f0f0f0; padding: 20px; border-radius: 8px; }
.count-display { font-size: 2rem; font-weight: bold; margin: 20px 0; }
.button-group { display: flex; gap: 10px; }
.btn { padding: 10px 20px; border: none; border-radius: 4px;
cursor: pointer; background: #007bff; color: white; }
.btn:hover { background: #0056b3; }
`
}
}
]
}
},
{
body: {
children: [
{ h1: { text: 'My Coherent.js App' } },
Counter({ count: 0 }),
{ script: { type: 'module', src: '/hydration.js' } }
]
}
}
]
}
});
const server = createServer((req, res) => {
if (req.url === '/hydration.js') {
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8' });
res.end(hydrationBundle);
return;
}
if (req.url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`<!DOCTYPE html>${render(createPage())}`);
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not found');
});
server.listen(3000, () => {
console.log('🚀 Server running at http://localhost:3000');
});Key Points:
render()- Renders the page to an HTML string (synchronously); add the doctype yourself.<style>and<script>text - is not HTML-escaped, so CSS and JavaScript work as written (a closing</style>/</script>inside is neutralised)./hydration.js- Serves the client bundle.hydrate()- Makes the server-rendered HTML interactive.
▶️ Step 4: Run Your App
node server.jsOpen your browser at http://localhost:3000 and click the buttons. 🎉
🎓 How It Works
1. Server-Side Rendering (SSR)
Browser Request → Server
↓
Counter({ count: 0 })
↓
render()
↓
<div class="counter">…<button class="btn">+</button>…</div>
↓
Browser ← HTML (instant display)Benefits:
- Fast initial load
- SEO-friendly
- Content visible without JavaScript
2. Client-Side Hydration
Browser loads /hydration.js
↓
hydrate(Counter, element, { initialState })
↓
Calls Counter(state) and pairs its output with the existing DOM
↓
Attaches onClick through event delegation
↓
event.setState() re-renders and patches the DOM ✨Benefits:
- The server-rendered HTML is kept, not re-created
- Progressive enhancement
🔧 Common Patterns
More State
State is passed to the component as props:
hydrate(Profile, el, { initialState: { name: '', items: [] } });
// In a handler
onClick: (event) => event.setState({ items: [...event.state.items, 'new'] })
// Or with an updater function
onClick: (event) => event.setState((state) => ({ count: state.count + 1 }))Multiple Event Handlers
Any DOM event works: onClick, onInput, onSubmit, onMouseEnter, onDoubleClick...
{
button: {
text: 'Click',
onClick: (event) => event.setState({ clicked: true }),
onMouseEnter: (event) => event.setState({ hovering: true })
}
}Handlers bubble like DOM events: a click inside nested elements that both have onClick runs both, innermost first; call event.stopPropagation() to stop.
Conditional Rendering
false, null and undefined children render nothing:
{
div: {
children: [
count > 10 && { p: { text: 'Count is high!' } },
count === 0 && { p: { text: 'Count is zero' } }
]
}
}Lists
{
ul: {
children: items.map((item) => ({ li: { key: item.id, text: item.name } }))
}
}With a key on every item, re-renders keep the DOM nodes of unchanged items.
⚠️ Important Rules
1. Hydrate the component's root element
hydrate(Counter, element) pairs Counter's output with element, so element must be the element the component's root renders (here .counter), not a wrapper around it.
2. Render the same thing on both sides
The server and the browser must call the component with the same props/state. Keep values that differ between the two (the current time, random ids, window reads) out of the first render. In development (NODE_ENV=development), or with hydrate(..., { detectMismatch: true }), differences are reported in the console.
3. Raw HTML is explicit
text is always escaped. Only use html: or dangerouslySetInnerContent() for markup you control or have sanitized.
🚀 Next Steps
Add a Todo List
export function TodoList({ todos = [], input = '' }) {
return {
div: {
className: 'todo-list',
children: [
{
input: {
value: input,
onInput: (event) => event.setState({ input: event.originalEvent.target.value })
}
},
{
button: {
text: 'Add',
onClick: (event) => event.setState({
todos: [...event.state.todos, event.state.input],
input: ''
})
}
},
{
ul: {
children: todos.map((todo, i) => ({ li: { key: i, text: todo } }))
}
}
]
}
};
}
// client.js
hydrate(TodoList, document.querySelector('.todo-list'), { initialState: { todos: [], input: '' } });Add Routing
const server = createServer((req, res) => {
if (req.url === '/') {
res.end(`<!DOCTYPE html>${render(homePage())}`);
} else if (req.url === '/about') {
res.end(`<!DOCTYPE html>${render(aboutPage())}`);
} else {
res.writeHead(404);
res.end('Not found');
}
});For Express, Fastify or Koa, @coherent.js/integrations adds res.coherent() / reply.coherent() / ctx.coherent() (see Framework Integrations); in the browser, @coherent.js/client/router handles client-side navigation (see Router).
Add API Endpoints
if (req.url === '/api/data') {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ data: 'Hello' }));
}For more than a couple of endpoints, use @coherent.js/api (see the API usage guide).
📚 Reference
Component Structure
{
tagName: {
className: 'my-class', // or ['a', cond && 'b'], or { active: cond }
id: 'my-id',
children: [
{ h1: { text: 'Title' } },
{ p: { text: 'Paragraph' } }
]
}
}Event Handlers
{
button: {
onClick: (event) => {}, // event.state, event.setState(), event.props
onMouseEnter: (event) => {},
onSubmit: (event) => { event.preventDefault(); }
}
}✅ Checklist
Before deploying, make sure:
- The client bundle (
/hydration.js) is served -
hydrate()is called with each interactive component's root element - Server and client render the component with the same props/state
- No hydration mismatch warnings appear in development
🎉 Success!
You've built a server-rendered, hydrated Coherent.js application.
What you learned:
- ✅ Server-Side Rendering
- ✅ Client-Side Hydration
- ✅ Client State
- ✅ Event Handling
- ✅ Component Structure
Next: Check out the starter-app example for a complete working template, and the Hydration guide for the details.