npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 vite

Project 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.ts

Vite 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 with history.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() — current H3Event. Works in components inside the render tree and root layout, not in render itself.
  • 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}`);
});