Skip to content

Custom Extensions

Knitly loads an optional custom/ directory so a deployment can add its own server routes and frontend pages without forking. The directory is gitignored, so the public repository ships no built-in extensions — what you put there is yours.

The system has two halves:

  • Server: app.js dynamically imports custom/server/index.js and mounts its exported customRouter under /api/custom. If the file is missing, the import is caught and skipped.
  • Frontend: main.tsx and Navigation.tsx use import.meta.glob on custom/frontend/index.ts to pull in its exported customRoutes and customNavItems.
custom/
├── package.json # workspace member (custom deps live here)
├── server/
│ └── index.js # must export `customRouter`
└── frontend/
└── index.ts # must export `customRoutes` and/or `customNavItems`

custom/server/index.js exports a single Hono router named customRouter. Compose sub-routers onto it; everything mounts under /api/custom.

custom/server/index.js
import { Hono } from "hono";
import { ensureSession } from "../../server/src/middleware/auth.js";
export const customRouter = new Hono();
// Reachable at GET /api/custom/status
customRouter.get("/status", (c) => {
return c.json({ ok: true });
});
// Protect a route with the same auth as the core API
customRouter.get("/whoami", ensureSession, (c) => {
const user = c.get("user");
return c.json({ username: user.username });
});

Custom routes can import from the server’s lib/ — database helpers, logging, and so on — using relative paths into server/src/.

import { logInfo } from "../../server/src/lib/logging.js";
logInfo("Custom route hit.");

custom/frontend/index.ts exports two optional arrays.

  • customRoutes: { path, component } entries registered as top-level routes.
  • customNavItems: { to, label, icon } entries added to the navigation, where icon is a lucide-preact component.
custom/frontend/Dashboard.tsx
import { useQuery } from "@tanstack/react-query";
import { api } from "../../frontend/src/api/client";
export function Dashboard() {
const { data, isLoading } = useQuery({
queryKey: ["custom", "status"],
queryFn: () => api.get("/custom/status"),
});
if (isLoading) return <p>Loading…</p>;
return (
<div class="p-4">
<h1>Custom Dashboard</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
custom/frontend/index.ts
import { LayoutDashboard } from "lucide-preact";
import { Dashboard } from "./Dashboard";
export const customRoutes = [
{ path: "/dashboard", component: Dashboard },
];
export const customNavItems = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
];

Follow the same frontend conventions as the core app: server state through TanStack Query, hooks from preact/hooks, no useEffect for data fetching.

Custom extensions load automatically in bun run dev. Server routes are available immediately; frontend routes and nav items appear after Vite picks up custom/frontend/index.ts.

Terminal window
bun run dev
curl http://localhost:3000/api/custom/status

Because custom/ is gitignored, include it explicitly wherever you deploy.

Docker — copy it into the image before it starts:

FROM knitly-app/knitly
COPY custom/ /app/custom/

Bare metal — copy the directory alongside the rest of the app, then restart the service. If your extension has its own dependencies, run bun install so the custom workspace resolves them.

Custom routes run inside the same server, so treat them with the same care as core code:

  • Guard anything sensitive with ensureSession (and requireRole for admin-only actions).
  • Validate and sanitize all input.
  • Never commit secrets — read them from environment variables.