The full-stack Deno framework powering Hushkey
Howl is a backend-first, Deno-native full-stack framework built on top of Fresh. It was created to power Hushkey — a multi-vertical platform for foreigners living in Japan — and is open-sourced under MIT for others to use.
Fresh is excellent. But building a production platform on top of it revealed gaps that required workarounds:
- No native cookie API on
ctx - Response headers set in middleware didn't propagate to page renders
- No React ecosystem compatibility without Vite
- No first-class typed endpoint system with Zod validation
- No auto-generated OpenAPI spec
- No role-based access control built in
Howl solves all of these natively.
| Import | Description |
|---|---|
@hushkey/howl |
Core runtime — routing, context, islands, SSR |
@hushkey/howl/dev |
Build pipeline — esbuild, HMR |
@hushkey/howl/plugins |
Official plugins — Tailwind v4, typed http client gen |
@hushkey/howl/api |
Endpoint contracts — defineApi, Zod validation, OpenAPI |
my-app/
├── client/
│ ├── pages/
│ │ ├── _app.tsx ← root shell (html/head/body)
│ │ ├── _layout.tsx ← shared UI layout (nav, sidebar, etc.)
│ │ └── index.tsx
│ └── islands/
│ └── counter.island.tsx
├── server/
│ ├── main.ts ← app entrypoint
│ ├── middleware/
│ │ └── _index.middleware.ts
│ └── apis/
│ └── public/
│ └── ping.api.ts
├── static/
│ └── style.css
├── howl.config.ts ← State type + defineApi factory
├── dev.ts ← dev/build entrypoint
└── deno.json
howl.config.ts
import { defineConfig, memoryCache, redisCache, tryCache } from "@hushkey/howl/api";
import { Redis } from "ioredis";
const redis = new Redis(Deno.env.get("REDIS_URL") ?? "redis://localhost:6379");
export interface State {
userContext?: UserContext;
}
export interface UserContext {
user?: { id: string; roles: Role[] };
}
export const roles = ["user", "admin"] as const;
export type Role = typeof roles[number];
// defineConfig returns a pre-typed defineApi — import it in your .api.ts files
// so you don't need explicit <State, Role> type params everywhere.
export const { defineApi, config: apiConfig } = defineConfig<State, Role>({
roles,
// memory-first, Redis fallback — swap primary/fallback freely
cache: tryCache(memoryCache({ maxSize: 1000 }), redisCache(redis)),
checkPermissionStrategy: (ctx, allowedRoles) => {
const user = ctx.state.userContext?.user;
if (!user) return ctx.json({ message: "Unauthorized" }, { status: 401 });
if (!allowedRoles.some((r) => user.roles.includes(r))) {
return ctx.json({ message: "Forbidden" }, { status: 403 });
}
// return nothing = allow
},
});server/main.ts
import { Howl, staticFiles } from "@hushkey/howl";
import { preactEngine } from "@hushkey/howl-preact";
import type { State } from "../howl.config.ts";
import { apiConfig } from "../howl.config.ts";
import { middleware } from "./middleware/_index.middleware.ts";
// Page rendering is a registered engine (no implicit default) — Preact here.
export const app = new Howl<State>({ logger: true, engines: { preact: preactEngine() } });
app.use(staticFiles());
app.configure(middleware);
app.fsApiRoutes(apiConfig); // crawls server/apis/, registers all .api.ts
app.fsClientRoutes(); // crawls client/pages/, mounts all routes
export default { app };
app.configure(fn)returnsthissynchronously whenfnis sync, andPromise<this>whenfnis async — so you canawait app.configure(async (a) => { await db(); })for boot-time async work and keep chaining sync calls below it.
dev.ts
import { HowlBuilder } from "@hushkey/howl/dev";
import { tailwindPlugin } from "@hushkey/howl/plugins";
import { app } from "./server/main.ts";
import type { State } from "./howl.config.ts";
const builder = new HowlBuilder<State>(app, {
root: import.meta.dirname ?? "",
importApp: () => app,
outDir: "dist",
serverEntry: "./server/main.ts",
clientEntry: "./client/pages/_app.ts",
});
tailwindPlugin(builder.getBuilder("default")!);
if (Deno.args.includes("build")) {
await builder.build();
} else {
await builder.listen();
}client/pages/_app.tsx — root HTML shell
import type { RouteConfig } from "@hushkey/howl";
import type { FunctionComponent, JSX } from "preact";
export const config: RouteConfig = {};
export default function App({ Component }: { Component: FunctionComponent }): JSX.Element {
return (
<html>
<head>
<title>My App</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<Component />
</body>
</html>
);
}client/pages/_layout.tsx — shared UI layout (nav, sidebar, etc.)
import type { FunctionComponent, JSX } from "preact";
export default function ({ Component }: { Component: FunctionComponent }): JSX.Element {
return (
<>
<div class="navbar bg-base-200">
<div class="navbar-start">
<a href="/" class="btn btn-ghost text-xl">🐺 Howl</a>
</div>
<div class="navbar-end">
<a href="/" class="btn btn-ghost btn-sm">Home</a>
<a href="/docs" class="btn btn-ghost btn-sm">Docs</a>
</div>
</div>
<main>
<Component />
</main>
</>
);
}Note:
@hushkey/howl/runtimemust point toshared.ts, notclient/mod.ts.client/mod.tsimportspartials.tswhich callsdocument.addEventListenerat module level — it crashes server-side.shared.tsexportsPartial,IS_BROWSER,asset, andHeadsafely for both server and client.
Partial-nav and non-HTML responses: when a
f-client-navlink points to a non-HTML resource (a file download, an image, aContent-Disposition: attachmentresponse, etc.), the client SPA detects the non-HTMLContent-Typeand falls back to a full browser navigation instead of trying to apply the response as a partial. API routes are not the intended target for<a href>— usefetch()for those — but the same fallback applies if you accidentally link to one.
Links inside an f-client-nav boundary are prefetched on intent — when the pointer hovers
(after a brief ~65 ms dwell so quick pass-overs don't fire) or a touch / keyboard-focus signals
intent. AOT routes pre-import() their JS chunk; SSR routes pre-fetch their partial response. The
eventual click reuses the warmed result, so navigation feels instant — the same idea as Hotwired
Turbo / instant.page.
It's on by default and respects the user's data-saver preference (Save-Data /
prefers-reduced-data). Opt a link or whole subtree out with f-prefetch="false":
<a href="/huge-report" f-prefetch="false">Report</a>
<nav f-prefetch="false"> … </nav> {/* opt out an entire region */}client/pages/index.tsx
import type { Context } from "@hushkey/howl";
import type { State } from "../../howl.config.ts";
export default function Index(ctx: Context<State>) {
return <h1>Hello, {ctx.state.userContext?.user?.id}</h1>;
}Each .api.ts file is a self-contained, typed endpoint contract: method, roles, Zod-validated query
params / request body / responses, optional caching. No wiring needed — drop the file and it's live.
server/apis/public/ping.api.ts
import { defineApi } from "../../../howl.config.ts"; // pre-typed, no <State, Role> needed
import { z } from "zod";
export default defineApi({
name: "Ping",
directory: "public",
method: "GET",
// path is optional — auto-generated as /api/public/ping
roles: [],
caching: { ttl: 5 },
query: z.object({
page: z.string().optional(),
limit: z.string(),
}),
responses: {
200: z.object({ ok: z.boolean(), message: z.string() }),
},
handler: (ctx) => {
const { limit } = ctx.query(); // typed: { page?: string; limit: string }
const page = ctx.query("page"); // typed: string | undefined
return {
statusCode: 200,
ok: true,
message: `pong 🐺 — page ${page ?? 1}, limit ${limit}`,
};
},
});server/apis/private/users/get-me.api.ts
import { defineApi } from "../../../../howl.config.ts";
import { z } from "zod";
export default defineApi({
name: "Get Me",
directory: "private/users",
method: "GET",
roles: ["user", "admin"], // typed — autocomplete works
responses: {
200: z.object({ data: z.any() }),
},
handler: async (ctx) => ({
statusCode: 200,
data: ctx.state.userContext, // ctx.state typed as State
}),
});With typed request body:
import { defineApi } from "../../../howl.config.ts";
import { z } from "zod";
const body = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export default defineApi({
name: "Sign In",
directory: "authentication",
method: "POST",
roles: [],
requestBody: body,
responses: {
200: z.object({ data: z.object({ token: z.string() }) }),
401: z.object({ message: z.string() }),
},
handler: async (ctx) => {
const { email, password } = ctx.req.body; // fully typed
return { statusCode: 200, data: { token: "jwt..." } };
},
});The OpenAPI spec is generated automatically. Expose it on any route you choose, with whatever auth middleware you need:
import { getApiSpecs } from "@hushkey/howl/api";
// public
app.get("/api/docs", (ctx) => ctx.json(getApiSpecs()));
// or gated behind a role
app.get("/api/docs", requireRole("admin"), (ctx) => ctx.json(getApiSpecs()));getApiSpecs() returns null before the server starts, and the fully typed OpenAPIV3_1.Document
once routes are registered — query params, request body, path params, roles, and responses all
included.
Response caching is configured once in howl.config.ts and applied per-endpoint via
caching: { ttl }.
Three adapters ship out of the box:
| Adapter | Use case |
|---|---|
memoryCache() |
Default. In-process LRU, zero deps |
redisCache(client) |
Shared cache across instances. Accepts any ioredis-compatible client |
kvCache(kv) |
Deno KV — globally consistent on Deno Deploy, SQLite-backed locally |
tryCache(primary, fallback) |
Tries primary first, falls back on miss or error |
All built-in adapters expose an atomic incr(key, ttl) op which the rate limiter uses to count
requests safely under concurrent load on shared backends. Custom adapters that omit incr fall back
to a non-atomic read-modify-write path — safe only when the backend isn't shared.
import { memoryCache, redisCache, tryCache } from "@hushkey/howl/api";
import { Redis } from "ioredis";
const redis = new Redis(Deno.env.get("REDIS_URL"));
// Redis-first, memory fallback
cache: tryCache(redisCache(redis), memoryCache({ maxSize: 1000 }));
// with timeout — falls back if primary doesn't respond within 150ms
cache: tryCache(redisCache(redis), memoryCache(), { timeoutMs: 150 });
// two Redis nodes (e.g. regional primary + global fallback)
cache: tryCache(redisCache(redisSG), redisCache(redisUS));redisCache attaches an error listener automatically so ioredis reconnection events don't become
unhandled crashes — errors are logged via console.warn so they remain visible. Implement
CacheAdapter to plug in any other backend.
Rate limit counters are written via cache.incr(key, ttl). Redis maps this to INCR + EXPIRE
(atomic server-side); Deno KV uses an atomic().check().set() CAS loop; the in-memory adapter is
trivially atomic. Custom adapters without incr fall back to read-modify-write — don't use that on
a shared backend.
Counters key on whatever getRateLimitIdentifier(ctx) returns on HowlApiConfig — Howl doesn't
assume a State shape. Falls back to the client IP when unset or undefined.
defineConfig({
getRateLimitIdentifier: (ctx) => ctx.state.user?.id,
});API errors are returned as { error, correlationId } plus an X-Howl-Correlation-Id response
header. The full route descriptor is logged server-side only — it is no longer leaked on the wire.
Howl does not auto-mutate response payloads. The previous "redact any field named password"
behaviour was security theatre (apiKey, token, secret, pwd, etc. all leaked) and has been
removed. Strip sensitive fields in your handler before returning.
// Cookies — first class, append semantics preserved
ctx.cookies.set("token", jwt, { httpOnly: true, sameSite: "Strict" });
ctx.cookies.get("token");
ctx.cookies.delete("session");
// Response headers — auto-merged into all responses including page renders
ctx.headers.set("X-Request-Id", crypto.randomUUID());
// Query params
const search = ctx.query("q");
const all = ctx.query();React libs work transparently — no configuration needed:
// client/islands/ToastIsland.island.tsx
import { toast, Toaster } from "sonner";
import { useState } from "preact/hooks";
import { ClientOnly } from "@hushkey/howl";
export default function ToastIsland() {
const [count, setCount] = useState(0);
return (
<div>
<ClientOnly>{() => <Toaster />}</ClientOnly>
<button
onClick={() => {
setCount((c) => c + 1);
toast.success(`${count + 1}`);
}}
>
Click
</button>
</div>
);
}Three escape hatches for browser-only code, ordered from coarse to fine:
| Tool | Scope | Use when |
|---|---|---|
export const howl = { ssr: false } |
Whole island | The component itself can't SSR (Mapbox, WebGL, libs that touch window on import) |
<ClientOnly>{() => <X />}</ClientOnly> |
One nested element | Most of the island SSRs fine but one child crashes (sonner <Toaster />) |
import { IS_SERVER, IS_BROWSER } from "@hushkey/howl" |
One branch in code | Need a different value or skip a side-effect on the server |
// One nested widget
<ClientOnly>{() => <ThirdPartyWidget />}</ClientOnly>;
// Inline guard
const stored = IS_BROWSER ? localStorage.getItem("prefs") : null;For islands that opt out of SSR you can render a layout-matching placeholder so the page doesn't shift while the JS loads:
export const howl = {
ssr: false,
skeleton: () => <div class="h-64 bg-base-200 animate-pulse rounded" />,
};The skeleton receives the same props as the island and is replaced by the real component on hydration.
Default islands (
ssr: true, no directive) hydrate against their SSR output — no flash, no wipe. The hydrate switch is automatic; nothing to configure.
Island files must be named *.island.tsx — both inside islands/ directories and inside
(_islands) route groups. The crawler now throws on mismatch instead of warning; this surfaced
silent hydration bugs in prior releases.
Vue islands (experimental). Top-level
*.island.vuefiles are recognised by the crawler and handled by the optional@hushkey/howl-vuepackage — author an island as a Vue SFC and drop it in with<VueIsland name="…" />. It uses Howl's existing esbuild toolchain (no Vite). RequiresvuePlugin()in your builder; see the package README.
Pluggable render engines — Preact, Vue & React. Page rendering is a registered engine — there is no implicit default. The framework is split into four packages:
@hushkey/howl(core + the Preact runtime) ·@hushkey/howl-preact·@hushkey/howl-vue·@hushkey/howl-react. Select an engine on the app —new Howl({ engines: { preact: preactEngine() } })(orvue: vueEngine()/react: reactEngine()) — plus the matching builder plugin (preactPlugin()/vuePlugin()/reactPlugin()). The shared backend — routing, APIs, middleware, client-nav + prefetch, AOT/SSG,deno compile— is reused unchanged; only the component renderer differs, and all three engines use the sameclient-nav/client-prefetchattributes. Each engine also backsctx.renderToString(component, props?)— render a standalone template to an HTML string (emails, notifications) in whatever engine you picked, no page shell.Programmatic navigation. The Vue and React engines expose a router from
@hushkey/howl-{vue,react}/router—navigate(to, { replace?, scroll? }),navigate(-1)for back/forward,useNavigate(), and a reactiveuseRoute()({ href, path, query, params, hash, route }). It drives the same client-nav swap path as link clicks and falls back to a full load before hydration. In dev each engine also ships a route inspector: Vue populates Vue DevTools' built-in Routes tab (via avue-router-shaped$routershim — novue-routerdependency), and React auto-mounts an in-app floating Howl Routes panel (React DevTools has no plugin-tab API). See the engine READMEs.If a client entry with page routes is configured but no engine is registered, the build throws (telling you to select one). Backend-only apps (no client entry) are unaffected. Demos:
examples/vuety·examples/reacty.
Howl#handler() is built lazily on first call and cached per listener. Registering routes after
handler() has been built throws — wire everything up before requesting the handler.
Two filename prefixes opt a page into client-side navigation and/or build-time prerendering. Direct URL hits always get SSR'd HTML (good for SEO and first-paint); the prefix changes how subsequent navigation works.
| Prefix | Mode | First paint | Client nav to this page |
|---|---|---|---|
| (none) | SSR | Renderer runs per request | Partial-nav fetches the partial fragment |
__page.tsx |
AOT | Renderer runs per request | Dynamic-imports a client chunk, no server hit |
___page.tsx |
SSG | Prerendered HTML served from snapshot (no JS run) | Dynamic-imports a client chunk, no server hit |
__ builds an ESM chunk per page that contains everything that would appear inside the active
<Partial> markers on an SSR response — inner layouts (if any) plus the page. Files above the
partial in the chain (the _app.tsx shell and any outer _layout.tsx) are not bundled: they're
already in the DOM on first paint and stay there across AOT navs, so layout-level islands (navbars,
sidebars) keep their state across page changes. On click, the chunk is import()-ed and rendered
into the active <Partial> outlet. ___ additionally runs the handler at build time, captures the
HTML, and bakes it into the production snapshot so request-time renders are skipped entirely.
AOT chunks need a <Partial> in _app.tsx or the layout chain to mount into. The boundary is
detected by a static scan of each file's source for the literal Partial identifier in JSX
(<Partial …>) or h/jsx-call form (h(Partial, …)). Aliased imports ({ Partial as P }) are
not detected — use the literal Partial name. When no <Partial> is found in an AOT page's chain,
chunk emission is silently skipped: the route still SSRs (or serves prerendered HTML for
___-prefixed SSG pages), and in-app navigation to it falls through to a full document load. This
makes it safe to keep __/___ prefixes when you intentionally don't use f-client-nav /
<Partial>.
AOT navigation honours f-client-nav the same way SSR partial nav does. Drop the attribute from
<body> (or set it to "false") and clicks on AOT links fall through to full document navigation —
same behaviour as SSR routes. Use f-client-nav="false" on a nested element to opt out a single
subtree (e.g. external dashboards) while leaving the rest of the app on SPA-style routing.
// pages/__dashboard.tsx — dynamic SSR, client-navigable
export default function Dashboard(ctx) {
return <p>Hello, {ctx.state.user?.name}</p>;
}// pages/___about.tsx — prerendered once at build time
import { Head } from "@hushkey/howl/runtime";
export default function About() {
return (
<>
<Head>
<title>About</title>
</Head>
<p>Static content.</p>
</>
);
}SSG limits and gotchas:
- The build invokes the handler with an empty
ctx— noreq, no cookies, no per-user state. Anything user-specific must stay on the dynamic SSR path. - Dynamic params (e.g.
/properties/:id) are not yet enumerated at build time — agetStaticPathsAPI is on the roadmap. SSG-flagged routes with params fall through to dynamic SSR with a build-time warning. - Build IDs rotate per build, so AOT chunks are served with
Cache-Control: public, max-age=31536000, immutablein production. - Every SSR response for an AOT/SSG route injects two globals:
window.__HOWL_AOT__(the route → chunk URL map) andwindow.__HOWL_USER_STATE__(the snapshot ofctx.stateat SSR time).
| Convention | Path |
|---|---|
| Root HTML shell | client/pages/_app.tsx |
| Shared UI layout | client/pages/_layout.tsx |
| Pages | client/pages/ |
| Islands | client/islands/ |
| Endpoint contracts | server/apis/**/*.api.ts |
| Middleware | server/middleware/ |
| Static files | static/ |
| Config | howl.config.ts |
| Build output | dist/ |
| OpenAPI spec | getApiSpecs() from @hushkey/howl/api |
| Client runtime import | @hushkey/howl/runtime → shared.ts |
const app = new Howl<State>({
logger: true, // timestamps + PID on all console output
debug: true, // enables console.debug
});Howl is the framework behind Hushkey — a platform helping foreigners navigate housing, jobs, and daily life in Japan.
Every feature was built to solve a real production problem.
MIT — see LICENSE
Built with 🐺 by Leo Termine and the Hushkey team.