name: melina description: Bun-native web framework with file routing. Use this for building server-rendered pages with client mount scripts for interactivity.
Melina.js — Framework Skill
Melina.js is a Bun-native web framework. Pages are server-rendered JSX, client interactivity is added via mount scripts — .client.tsx files compiled to lightweight VNodes with a ~2KB reconciler runtime.
Current version: 2.3.1
1. When to Use Melina
Use Melina when the user wants:
- Server-rendered HTML pages with file-based routing (Next.js App Router style)
- Client-side interactivity WITHOUT a full SPA framework (no React on the client)
- CSS styling (Tailwind CSS v4 or vanilla CSS)
- API endpoints alongside pages
- View Transitions for app-like navigation
- Lightweight client bundles (zero React, zero framework overhead)
- Static Site Generation (SSG) for pre-rendered pages
Melina replaces the full Next.js / Vite stack — server pages, API routes, SSG, and client interactivity — with zero React on the client.
2. Project Setup
New Project (from scratch)
mkdir my-app && cd my-app
bun init -y
bun add melina
Create server.ts:
import { start } from 'melina';
import path from 'path';
const appDir = path.join(import.meta.dir, 'app');
await start({
port: parseInt(process.env.BUN_PORT || "3000"),
appDir,
defaultTitle: 'My App',
});
Create tsconfig.json:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "melina/client",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["app/**/*", "server.ts"]
}
Critical:
"jsxImportSource": "melina/client"is required. This tells TypeScript and Bun to use Melina's VDOM runtime for JSX instead of React.
Within the Melina.js monorepo
When building examples inside the melina.js repo, import from local source:
// server.ts (inside examples/)
import { start } from '../../src/web';
And use paths in tsconfig.json:
{
"compilerOptions": {
"paths": {
"melina/client/*": ["../../src/client/*"],
"melina/*": ["../../src/*"]
}
}
}
3. File Structure & Routing
my-app/
├── app/
│ ├── layout.tsx # Root layout (wraps all pages)
│ ├── layout.client.tsx # Persistent client mount script
│ ├── globals.css # Global styles (auto-discovered)
│ ├── page.tsx # Home page (/)
│ ├── page.client.tsx # Home page mount script
│ ├── page.css # Scoped CSS for home page
│ ├── middleware.ts # Root middleware
│ ├── error.tsx # Error boundary
│ ├── about/
│ │ └── page.tsx # /about
│ ├── post/[id]/
│ │ ├── page.tsx # /post/:id (dynamic route)
│ │ └── page.client.tsx # Client interactivity for post
│ └── api/
│ └── messages/
│ └── route.ts # API: GET/POST /api/messages
├── server.ts
├── tsconfig.json
└── package.json
Routing Rules
| File Pattern | URL | Type |
|---|---|---|
app/page.tsx |
/ |
Page |
app/about/page.tsx |
/about |
Page |
app/post/[id]/page.tsx |
/post/:id |
Dynamic page |
app/api/messages/route.ts |
/api/messages |
API route |
app/layout.tsx |
— | Layout (wraps children) |
app/middleware.ts |
— | Runs before page render |
app/error.tsx |
— | Catches render errors |
Auto-discovered files
globals.css,global.css, orapp.css→ processed with PostCSS + Tailwind v4page.cssorstyle.css→ scoped CSS for that route segmentlayout.tsx→ layout wrapping child pages (nested layouts compose automatically)layout.client.tsx→ persistent client mount (survives navigations)page.client.tsx→ per-page transient client mountmiddleware.ts→ runs root→leaf before page rendererror.tsx→ catches render errors with full layout chrome
4. Server Pages (page.tsx)
Pages run on the server. They can access databases, read files, anything server-side. They return JSX rendered to HTML via renderToString.
// app/page.tsx
export default function Page() {
return (
<div>
<h1>Welcome</h1>
<p>Server-rendered at {new Date().toISOString()}</p>
<div id="app-root" />
</div>
);
}
Important patterns:
- Place
id=""attributes on containers that client scripts will target - Server pages render HTML — client scripts add interactivity
5. Layouts (layout.tsx)
Root layout wraps all pages. Must include {children}.
// app/layout.tsx
export default function RootLayout({ children }: { children: any }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My App</title>
</head>
<body>
<nav><a href="/">Home</a> | <a href="/about">About</a></nav>
<main id="melina-page-content">{children}</main>
</body>
</html>
);
}
Key points:
{children}is where the current page rendersid="melina-page-content"enables targeted page swaps during navigation- Nested layouts compose automatically — place
layout.tsxin any subdirectory
6. <Head> Component
Declarative per-page <head> management:
// app/features/about/page.tsx
import { Head } from 'melina/web';
export default function AboutPage() {
return (
<>
<Head>
<title>About Us — My App</title>
<meta name="description" content="Learn about our team" />
<link rel="canonical" href="https://example.com/about" />
</Head>
<main><h1>About Us</h1></main>
</>
);
}
Head elements are collected during SSR and injected into <head>.
7. Client Architecture (page.client.tsx)
Melina uses a pure VDOM architecture for client interactivity. No hooks, no signals. Call render(vnode, container) to update the UI.
Mount Script Pattern
// app/counter/page.client.tsx
import { render } from 'melina/client';
function Counter({ count, onIncrement }: { count: number; onIncrement: () => void }) {
return (
<div>
<span>Count: {count}</span>
<button onClick={onIncrement}>+1</button>
</div>
);
}
export default function mount() {
const root = document.getElementById('counter-root');
if (!root) return;
let count = 0;
const update = () => {
render(<Counter count={count} onIncrement={() => { count++; update(); }} />, root);
};
update();
// Cleanup — called when navigating away
return () => render(null, root);
}
Mount Script Lifecycle
| File | Lifecycle | Use Case |
|---|---|---|
page.client.tsx |
Mounts on page load, unmounts on navigate away | Page-specific interactions |
layout.client.tsx |
Mounts once, survives across navigations | Global widgets, persistent state |
Both must export default function mount():
- Called when the script enters the DOM
- Return value is a cleanup function, called on unmount
- Uses
render(vnode, container)to mount/update content
Pluggable Reconciler
Three diffing strategies:
- Keyed diff — O(n log n) via LIS for list reorders
- Sequential diff — O(n) linear patch for static layouts
- Replace — Full replace (baseline)
import { setReconciler } from 'melina/client';
setReconciler('auto'); // Default — inspects children for keys
setReconciler('keyed'); // Best for dynamic lists
setReconciler('sequential'); // Best for static layouts
| Use Case | Strategy |
|---|---|
| Dynamic lists with reorder | keyed |
| Static forms, fixed layouts | sequential |
| Large lists (1000+) | keyed |
| Not sure | auto (default) |
8. API Routes (route.ts)
Export named HTTP method handlers:
// app/api/messages/route.ts
export async function GET(req: Request) {
return Response.json([{ id: 1, text: 'Hello' }]);
}
export async function POST(req: Request) {
const body = await req.json();
return Response.json({ ok: true });
}
Streaming (SSE)
Return an AsyncGenerator for Server-Sent Events:
// app/api/stream/route.ts
export async function* GET(req: Request) {
for (let i = 0; i < 10; i++) {
yield `data: ${JSON.stringify({ count: i })}\n\n`;
await new Promise(r => setTimeout(r, 1000));
}
}
9. Middleware (middleware.ts)
Middleware functions run before page rendering, root→leaf:
// app/middleware.ts
export default async function middleware(req: Request) {
const token = req.headers.get('authorization');
if (!token) {
return new Response('Unauthorized', { status: 401 });
}
// Return nothing to continue to the page
}
10. Error Boundaries (error.tsx)
Catches render errors and displays them with full layout chrome:
// app/error.tsx
export default function ErrorPage({ error }: { error: { message: string; stack?: string } }) {
return (
<div style={{ padding: '40px', color: '#ef4444' }}>
<h1>Something went wrong</h1>
<pre>{error.message}</pre>
</div>
);
}
11. SSG (Static Site Generation)
Pre-render pages at startup, serve from memory:
// app/pricing/page.tsx
export const ssg = true;
export default function PricingPage() {
return <main><h1>Pricing</h1></main>;
}
With TTL (time-to-live):
export const ssg = { revalidate: 60 }; // re-render after 60 seconds
SSG API:
import { getPrerendered, setPrerendered, clearSSGCache } from 'melina/server';
// Check if a page is cached
const html = getPrerendered('/pricing');
// Clear cache (e.g., after content update)
clearSSGCache('/pricing');
12. Styling
Tailwind CSS v4
Create app/globals.css:
@import "tailwindcss";
@theme {
--color-background: #0a0a0f;
--color-surface: #111118;
--color-foreground: #e4e4e7;
--color-accent: #6366f1;
}
Melina auto-discovers globals.css, global.css, or app.css.
Scoped CSS
Place page.css or style.css alongside a page — auto-injected for that route only.
Vanilla CSS
You don't need Tailwind — any CSS works. Just use standard stylesheets.
13. Package Exports
| Import Path | Module | Use Case |
|---|---|---|
melina |
src/web.ts |
Server: start, serve, createAppRouter |
melina/web |
src/web.ts |
Same as above |
melina/server |
src/server/index.ts |
Server: renderToString, Head, SSG, build helpers |
melina/server/ssr |
src/server/ssr.ts |
Server: renderToString only |
melina/client |
src/client/index.ts |
Client: render, createElement, setReconciler, navigate, Link |
melina/client/render |
src/client/render.ts |
Client: same as above (direct) |
melina/client/types |
src/client/types.ts |
Types: VNode, Props, Component |
melina/client/reconcilers |
src/client/reconcilers/index.ts |
Reconciler strategies |
melina/client/jsx-runtime |
src/client/jsx-runtime.ts |
JSX transform (auto, don't import manually) |
melina/client/jsx-dom |
src/client/jsx-dom.ts |
Real DOM JSX factory |
Architecture: SSR vs Client separation
- Client bundles (
src/client/) are bundled for the browser — no server code - SSR (
renderToString) lives atsrc/server/ssr.ts— never in client bundles - Server imports from
melina/serverormelina/web - Client mount scripts import from
melina/client
14. Server API Reference
start(options) — Quick start
import { start } from 'melina';
await start({
appDir: './app', // default: './app'
port: 3000, // default: 3000 or BUN_PORT env
defaultTitle: 'My App',
});
serve(handler, options) + createAppRouter(options) — Advanced
import { serve, createAppRouter } from 'melina';
const router = createAppRouter({
appDir: './app',
defaultTitle: 'My App',
globalCss: './app/globals.css',
});
serve(router, { port: 3000 });
Build Helpers
import { buildScript, buildStyle, buildAsset } from 'melina/server';
const scriptUrl = await buildScript('./src/app.ts'); // JS/TS → hashed URL
const cssUrl = await buildStyle('./src/style.css'); // CSS → processed URL
const imgUrl = await buildAsset(Bun.file('./icon.png')); // Static → hashed URL
15. Common Patterns
Event Delegation for Lists
For dynamic lists, use data-* attributes with delegated event handlers:
// In page.client.tsx
document.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('[data-item-id]');
if (btn) {
const id = (btn as HTMLElement).dataset.itemId;
selectItem(id);
}
});
// In component
function Item({ item }: { item: { id: string; name: string } }) {
return <button data-item-id={item.id}>{item.name}</button>;
}
Persistent Floating Widget
// layout.client.tsx — survives across navigations
import { render } from 'melina/client';
export default function mount() {
const root = document.getElementById('widget-root');
if (!root) return;
let isOpen = false;
const update = () => render(
<div>
{isOpen && <div className="widget-panel">Widget content</div>}
<button onClick={() => { isOpen = !isOpen; update(); }}>
{isOpen ? '✕' : '?'}
</button>
</div>,
root
);
update();
return () => render(null, root);
}
Client-Side Navigation
import { navigate } from 'melina/client';
// Programmatic navigation with View Transitions
navigate('/dashboard');
16. Gotchas & Troubleshooting
❌ async Server Components
Melina's renderToString does NOT support async server components.
- Wrong:
export default async function Page() { ... } - Right:
export default function Page() { ... }
If you need async data, fetch it before the component renders and pass it as props.
❌ Missing mount() export
Client scripts MUST export default function mount(). Without this, no interactivity.
❌ CSS 404
Melina auto-maps app/globals.css → /globals.css. Don't use custom paths in <link> tags.
❌ Ghost Processes (EADDRINUSE)
On Windows, stale Bun processes may hold ports:
netstat -ano | findstr ":3000"
taskkill /F /PID <PID>
❌ Stale Dependencies
After switching package versions or linking locally:
Remove-Item -Recurse -Force node_modules, bun.lock; bun install