mates-fullstack-beta
v1.0.0
Published
Full-stack Node.js framework built on the Mates SPA framework. RPC server functions, SSR, WebSockets, file-system routing.
Downloads
143
Maintainers
Readme
Mates SSR
Server-side rendering for Mates. If you know Mates, you know Mates SSR — learned in 5 minutes.
What it is
Mates SSR is a thin shell around Mates. It adds three things:
- File-system router — your folder structure is your router
- SSR renderer — runs your components on the server, sends real HTML
- Vite dev server — TypeScript, HMR, fast DX out of the box
Everything else — components, atom, useState, html, directives, lifecycle hooks — is plain Mates, unchanged.
Installation
bun add mates-ssr mates lit-html
bun add -d vite typescript @types/nodeProject structure
my-app/
├── src/
│ ├── pages/ ← file-system router lives here
│ │ ├── index.ts → /
│ │ ├── about.ts → /about
│ │ ├── blog/
│ │ │ ├── index.ts → /blog
│ │ │ └── [slug].ts → /blog/:slug
│ │ └── counter.ts → /counter
│ ├── components/ ← shared components (plain Mates)
│ ├── App.ts ← client-side SPA root (for hydration)
│ └── client.ts ← browser entry point (one line)
├── public/ ← static assets served as-is
├── mates.config.ts ← config: port, title, head tags, etc.
├── vite.config.ts
└── package.jsonQuick start
1. mates.config.ts
import { defineConfig } from 'mates-ssr';
export default defineConfig({
port: 3000,
pagesDir: './src/pages',
publicDir: './public',
clientEntry: './src/client.ts',
head: {
title: 'My App',
meta: [
{ name: 'description', content: 'My Mates SSR app' },
],
links: [
{ rel: 'stylesheet', href: '/style.css' },
],
},
});2. A page file
A page is a standard Mates component — nothing new.
// src/pages/index.ts → route: /
import { html, useState } from 'mates';
import type { Props } from 'mates';
import { Layout } from '../components/Layout';
// Optional: per-page <head> tags
export const meta = {
title: 'Home — My App',
description: 'Welcome to my app.',
};
// Optional: layout wrapper for this page
export const layout = Layout;
// Required: the page component (default export)
export default (_: Props<{}>) => {
const [state, update] = useState({ count: 0 });
return () => html`
<h1>Hello from SSR</h1>
<p>Count: ${state.count}</p>
<button @click=${() => update(() => state.count++)}>+</button>
`;
};The server renders this to HTML on first load. After hydration it becomes a fully reactive Mates component — no code changes needed.
3. src/client.ts
import { renderX } from 'mates';
import { App } from './App';
renderX(App, document.getElementById('app')!);4. src/App.ts (client-side router)
import { html, route, x, location } from 'mates';
import type { Props } from 'mates';
import IndexPage from './pages/index';
import AboutPage from './pages/about';
import { Layout } from './components/Layout';
export const App = (_: Props<{}>) => () => html`
${x(Layout, {
children:
route('/', { view: IndexPage }) ??
route('/about', { view: AboutPage }) ??
html`<div>404</div>`,
})}
`;5. Run
bun run dev # http://localhost:3000
bun run build # production build
bun run start # production serverRouting
Static routes
| File | Route |
|-----------------------------|-------------|
| src/pages/index.ts | / |
| src/pages/about.ts | /about |
| src/pages/blog/index.ts | /blog |
Dynamic routes
src/pages/blog/[slug].ts → /blog/:slug
src/pages/user/[id].ts → /user/:id
src/pages/[...all].ts → /* (catch-all)Dynamic params are passed to your component via props():
// src/pages/blog/[slug].ts
import { html } from 'mates';
import type { Props } from 'mates';
export default (props: Props<{ slug: string }>) => {
// Read params inside the inner function — always fresh on re-render
return () => html`
<h1>Post: ${props().slug}</h1>
`;
};Route priority
More specific routes always win:
- Static routes beat dynamic routes of the same depth (
/aboutbeats/:id) - Deeper routes beat shallower routes
- Catch-all routes (
[...all]) are always last
Page file exports
Every page file can have up to three exports:
// Required — the page component
export default MyPageComponent;
// Optional — layout that wraps this page
export const layout = MyLayoutComponent;
// Optional — <head> metadata for this page
export const meta = {
title: 'Page Title',
description: 'Page description for SEO.',
};That's the entire API surface of a page file.
Layouts
A layout is a standard Mates component that receives the page content as children.
// src/components/Layout.ts
import { html, x } from 'mates';
import type { Props } from 'mates';
import { Nav } from './Nav';
export const Layout = (props: Props<{ children?: any }>) => {
return () => html`
${x(Nav, {})}
<main class="main">
${props().children ?? ''}
</main>
`;
};Export it from a page file to wrap that page:
// src/pages/about.ts
import { Layout } from '../components/Layout';
export const layout = Layout;
export default () => () => html`<h1>About</h1>`;How it works on the server: Mates SSR renders the page component first, then passes the resulting HTML string as the children prop to the layout component and renders that too. The final output is the full wrapped HTML.
How it works on the client: Your App.ts wraps each route in the layout directly using x(Layout, { children: ... }), so the shell persists across client-side navigation without re-mounting.
Configuration reference
import { defineConfig } from 'mates-ssr';
export default defineConfig({
// HTTP port the dev / prod server listens on
// default: 3000
port: 3000,
// Path to the pages directory, relative to project root
// default: './src/pages'
pagesDir: './src/pages',
// Path to the static assets directory
// default: './public'
publicDir: './public',
// Client entry file that calls renderX() for hydration
// default: './src/client.ts'
clientEntry: './src/client.ts',
// CSS selector for the app container element
// default: '#app'
appSelector: '#app',
// Default <head> content for every page
head: {
title: 'My App',
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: 'My app description' },
{ property: 'og:title', content: 'My App' },
],
links: [
{ rel: 'stylesheet', href: '/style.css' },
{ rel: 'icon', href: '/favicon.ico' },
],
scripts: [
{ src: '/analytics.js', defer: true },
],
},
});How SSR + hydration works
Browser requests /blog/hello-world
│
▼
mates-ssr server
│
├── matches route: /blog/[slug].ts → params: { slug: "hello-world" }
│
├── imports the page module
│
├── calls component(propsFn) → templateFn (outer fn — setup)
│
├── calls templateFn() → TemplateResult (inner fn — template)
│
├── render(result, container) in happy-dom
│
├── strips lit-html comment markers
│
├── wraps in layout (if page exports one)
│
└── builds full HTML shell → sends to browser
│
▼
Browser paints the page immediately (no JS needed)
│
▼
client.ts loads, calls renderX(App, container)
│
▼
lit-html patches the existing DOM in-place
(attaches event listeners, activates atoms, useState, etc.)
│
▼
Fully interactive Mates SPAThe component setup runs twice — once on the server (for HTML), once on the client (for reactivity). Because both runs start with the same initial state, the DOM output is identical, and lit-html has nothing to replace — it just adds event bindings.
Rules for components
Components work exactly as in a plain Mates SPA, with one constraint:
Components must be synchronous. The outer function (setup) must return a template function immediately.
// ✅ Correct — synchronous setup
export default (props: Props<{ name: string }>) => {
const [state, update] = useState({ count: 0 });
return () => html`<p>Hello, ${props().name}</p>`;
};
// ❌ Wrong — async outer function
export default async (props) => {
const data = await fetch('/api/data'); // not allowed
return () => html`...`;
};If you need data from an API or database, load it before the render starts and pass it to the component as route props. A data-loading middleware or loader pattern (per-route) is planned for v2.
CLI reference
# Start the dev server
# — Vite handles TypeScript, HMR for client bundle
# — File watcher rescans pages/ when files are added or removed
mates-ssr dev
# Build for production
# — Vite bundles src/client.ts → dist/assets/client.js
# — Copies public/ → dist/public/
mates-ssr build
# Start the production server (run after build)
mates-ssr startAdd to your package.json:
{
"scripts": {
"dev": "mates-ssr dev",
"build": "mates-ssr build",
"start": "mates-ssr start"
}
}Runtime
Mates SSR automatically picks the best available runtime:
- Bun — used when
Bunis available in the environment. Faster startup, nativefetch,Bun.serve. - Node.js — fallback when Bun is not available. Uses
node:http, standardRequest/Responseglobals.
No configuration needed — it just works in both.
What Mates SSR is not
It is intentionally minimal. The following are out of scope:
- API routes / backend endpoints
- Server actions / form mutations
- Streaming HTML
- Built-in i18n
- Image optimisation
- Middleware chains
- Edge runtime adapters
It is a rendering shell for Mates, not a full-stack framework.
License
MIT
