@pajecawav/yamf
v0.0.9
Published
Yet another meta framework
Readme
yamf
SSR meta-framework on top of Vite, Nitro, and Hono JSX. File-based routing for HTML pages, islands architecture for client interactivity, routing hooks in islands via wouter, React-ecosystem compatibility through @hono/react-compat, head/SEO via unhead, and full Nitro feature set (presets, caching, middleware, API routes).
Install
npm install @pajecawav/yamf hono vite
# or
yarn add @pajecawav/yamf hono vite
# or
pnpm add @pajecawav/yamf hono viteProject structure
src/
server.tsx # server entry with export default defineServerEntry(...)
client/index.ts # client entry with import "@pajecawav/yamf/client"
pages/*.page.tsx # file-based routes (.page suffix required)
root/index.tsx # optional root layout
template.html # optional HTML shell with <!--ssr-outlet-->
routes/ # optional nitro API routes
vite.config.tsVite plugin
// vite.config.ts
import yamf from "@pajecawav/yamf/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [yamf()],
});Server entry
// src/server.tsx
import { defineServerEntry } from "@pajecawav/yamf/server";
export default defineServerEntry({
head: {
titleTemplate: "%s | my app",
htmlAttrs: { lang: "en" },
},
});Pages
// src/pages/index.page.tsx
import { definePage } from "@pajecawav/yamf";
export default definePage({
render: (event, { head }) => {
head.push({ title: "Home" });
return <h1>Hello, {event.url.hostname}!</h1>;
},
});File routing
Files in src/pages/ with .page suffix are mapped to routes following nitro conventions:
| File | Route |
| ------------------------- | --------------- |
| index.page.tsx | / |
| about.page.tsx | /about |
| [owner].page.tsx | /:owner |
| post/[postId].page.tsx | /post/:postId |
| docs/[...rest].page.tsx | /docs/** |
Redirects and non-HTML responses
import { definePage } from "@pajecawav/yamf";
import { HTTPResponse, redirect } from "nitro/h3";
export default definePage({
render: async () => {
return redirect("/calc");
// or
return new HTTPResponse(null, {
status: 302,
headers: { location: "/calc" },
});
},
});Streaming and mid-stream errors
Set stream: true to flush the shell early and stream Suspense boundaries as they resolve (better TTFB for slow data). If an async component rejects after the shell has been sent, the streaming renderer swallows the error and the fallback would stay forever — wrap risky async components with safeAsync to render an error fallback instead:
import { definePage, safeAsync } from "@pajecawav/yamf";
import { Suspense } from "hono/jsx";
const SlowSection = safeAsync(Slow, () => <p>Failed to load.</p>);
export default definePage({
stream: true,
render: () => (
<Suspense fallback={<p>Loading…</p>}>
<SlowSection />
</Suspense>
),
});Caching
export default definePage({
cache: 60, // Cache-Control: public, max-age=60
// or
cache: { maxAge: 60, swr: 10, private: true },
render: () => ...,
});Params and query validation
params and query accept any standard-schema validator (zod, valibot, arktype…). Validation runs before render: invalid path params produce 404, invalid query produces 400. Validated, typed values are passed to render:
import { z } from "zod";
import { definePage } from "@pajecawav/yamf";
export default definePage({
params: z.object({ id: z.coerce.number().int() }),
query: z.object({ page: z.coerce.number().default(1) }),
render: (_event, { params, query }) => (
<p>
{params.id} — page {query.page}
</p>
),
});Islands
Any file matching *.island.{tsx,ts,jsx,js} is automatically wrapped. Each exported function becomes an island that server-renders inside <yamf-island> and hydrates on the client.
// src/components/Counter.island.tsx
import { type IslandProps, useHead } from "@pajecawav/yamf";
import { useState } from "hono/jsx";
export interface CounterProps extends IslandProps {
initialValue?: number;
}
export const Counter = ({ initialValue = 0 }: CounterProps) => {
const [value, setValue] = useState(initialValue);
useHead({ title: `Counter: ${value}` });
return <button onClick={() => setValue(value + 1)}>{value}</button>;
};// src/pages/index.page.tsx
import { Counter } from "~/components/Counter.island";
export default definePage({
render: () => (
<>
<Counter initialValue={2} />
<Counter initialValue={5} />
<Counter yamf-client="visible" />
<Counter yamf-client="skip" />
</>
),
});Hydration directives
yamf-client prop controls when hydration happens:
| Value | Behavior |
| ---------------- | ----------------------------------- |
| load (default) | Hydrate immediately on connection. |
| idle | Defer via requestIdleCallback. |
| visible | Hydrate when scrolled into view. |
| skip | Server-rendered only, no hydration. |
Props are serialized with devalue (supports Date, Map, Set, URL, RegExp, Error, BigInt, cycles). Islands with yamf-client="skip" (or false) do not serialize their props at all — the client never reads them. In dev, yamf warns when serialized props exceed 16KB (configurable via the YAMF_ISLAND_PROPS_LIMIT define/env).
React ecosystem compatibility
The Vite plugin aliases react and react-dom to @hono/react-compat, which reimplements the React API on top of hono/jsx. This means libraries from the React ecosystem (wouter, tanstack/react-query, etc.) work inside islands and the render tree without shipping React. use-sync-external-store is also aliased to @hono/react-compat.
Routing
Every page renders inside a wouter <Router>, seeded with the current request's pathname and search for SSR. wouter hooks and components work inside islands and the root layout:
// src/components/Search.island.tsx
import { useSearchParams } from "wouter";
export const Search = () => {
const [params, setParams] = useSearchParams();
return (
<input
value={params.get("q") ?? ""}
onChange={e =>
setParams(prev => {
prev.set("q", e.target.value);
return prev;
})
}
/>
);
};wouter's Route, useLocation, useRoute, and useSearchParams are all available for in-page routing state. wouter is an optional peer dependency of yamf — declare it in your own package.json when you use it in islands.
Navigation between pages is always a full page load — pages are server-rendered HTML documents and are not shipped to the client. Use plain <a href> links for page navigation (optionally with Speculation Rules prefetching/prerendering).
Footgun: wouter's
<Link>inside an island intercepts the click and changes the URL withhistory.pushState— but the page content does not change, leaving the URL and the document out of sync. Do not use<Link>for cross-page navigation in yamf.
Client entry
// src/client/index.ts
import "@pajecawav/yamf/client";Side-effect import. Registers the yamf-island custom element via a tiny (~1KB) bootstrap and initializes the client head. The full hydration runtime (hono/jsx DOM renderer, devalue, unhead client) is dynamically imported only when the document actually contains an island — island-free pages don't pay for it.
The client head is hydrated from the server: the entry-head config (including titleTemplate) and the server's head entries are re-registered on the client, and head patches streamed after the shell are applied on hydration.
Head and SEO
import { useHead, useSeoMeta } from "@pajecawav/yamf";
// In any component inside the render tree:
useHead({
title: "Page title",
meta: [{ name: "description", content: "..." }],
link: [{ rel: "canonical", href: "https://..." }],
});
useSeoMeta({
title: "Page title",
ogTitle: "Page title",
ogImage: "https://example.com/og.png",
});In the render function itself, use the head argument directly (SSR context is not set up yet):
export default definePage({
render: (event, { head }) => {
head.push({ title: "Page" });
return <Content />;
},
});Default head from defineServerEntry is applied first, then page-specific head overrides individual fields.
Root layout
// src/root/index.tsx
import type { PropsWithChildren } from "hono/jsx";
import { useEvent } from "@pajecawav/yamf";
import "./index.css";
export default function Root({ children }: PropsWithChildren) {
const event = useEvent();
return (
<>
<nav>...</nav>
<main>{children}</main>
</>
);
}Optional. Wraps every page's content. CSS imported here is included in the asset manifest automatically. useEvent() works here because root renders inside the SSR context.
HTML template
<!-- src/template.html -->
<!doctype html>
<html>
<head></head>
<body>
<!--ssr-outlet-->
</body>
</html><!--ssr-outlet--> is replaced with rendered content. Head tags are injected by unhead. Falls back to a minimal default if the file is missing.
Hooks
useEvent()— currentH3Event. Works in components inside the render tree and root layout, not inrenderitself.useSSRContext()— returns{ head, event } | null.useHead(input)— push head tags. On the client this is a real hook: the head entry is created once per component, patched on re-render and disposed on unmount — call it unconditionally at the top level of the component.useSeoMeta(input)— shorthand for SEO meta. Same lifecycle on the client.
API routes and error handling
Handled by Nitro directly. Place files in src/routes/:
// src/routes/api/badge.get.ts
import { defineHandler, getQuery, setHeader } from "nitro/h3";
export default defineHandler(event => {
setHeader(event, "cache-control", "public, max-age=60");
return "Cached response";
});// src/error.ts
import { defineErrorHandler } from "nitro";
export default defineErrorHandler(error => {
console.error(error);
return new Response(`${error.statusCode} ${error.statusMessage}`);
});