npm i evikit@^2.3.3 # 2026-07-08

EviKit: Preact SSR framework.
For small web apps hosted on a VPS or in local network.

Standard modern JavaScript, no JSX or TS. Forms accessible when JS doesn't load. Automatic Swagger specification. First-class API input/output validation. Built-in node:sqlite ORM. Support for UI translation through .po files.

See live demo of the example code from below. Todos are deleted every 10 minutes. OpenAPI demo (Swagger) is generated from API route schema.

Quick start

npm init evikit my-app
cd my-app
npm run dev
src/lib/db/schema.js
import { primary } from "evikit";
import { integer, number, object, optional, pipe, string } from "valibot";

export const Todo = object({
  createdAt: pipe(number(), integer()),
  doneAt: optional(pipe(number(), integer())),
  text: pipe(string(), primary()),
});
src/routes/api/todo/types.js
import { array, object, string } from "valibot";

import * as schema from "../../../lib/db/schema.js";

export const TodoGet = {
  input: object({ lang: string() }),
  outputs: { 200: object({ Todo: array(schema.Todo) }) },
};

export const TodoPost = {
  input: object({ lang: string(), text: string() }),
  outputs: { 302: string(), 400: string() },
};
src/routes/api/todo/server.js
import { endpoint, json, redirect } from "evikit/server";

import { db } from "../../../lib/db/index.js";
import { TodoGet, TodoPost } from "./types.js";

export const TodoServer = {
  get: endpoint(TodoGet, async () => json({ Todo: db.Todo.select() })),

  post: endpoint(TodoPost, async ({ input }) => {
    const todo = db.Todo.select().find(({ text }) => text == input.text);
    if (todo) {
      db.Todo.update({
        set: { doneAt: todo.doneAt ? undefined : Date.now() },
        where: { text: todo.text },
      });
    } else {
      db.Todo.upsert({ createdAt: Date.now(), text: input.text });
    }
    return redirect("/todo", 302);
  }),
};
src/routes/todo/page.js
import classNames from "classnames";
import { a, button, form, input, main, p, useApi, useEnhance, useI18n, } from "evikit";
import { useMeta, useTitle } from "hoofd/preact";

import { TodoGet, TodoPost } from "../api/todo/types.js";

export function TodoPage() {
  const { data } = useApi(TodoGet);
  const { busy, onSubmit } = useEnhance(TodoPost);
  const i18n = useI18n();
  useTitle(
    data?.Todo.length
      ? i18n.ngettext("%1 todo", "%1 todos", data.Todo.length, data.Todo.length)
      : i18n.gettext("Todos"),
  );
  useMeta({ content: "noindex", name: "robots" });

  return main(
    { class: "m-8" },
    form(
      { action: "/api/todo", method: "POST", onSubmit },
      input({
        class: "input",
        name: "text",
        placeholder: i18n.gettext("Text"),
        required: true,
      }),
      button(
        { class: classNames("btn", busy && "btn-disabled") },
        i18n.gettext("Create todo"),
      ),
    ),
    (data?.Todo || []).map(({ doneAt, text }) =>
      form(
        { action: "/api/todo", method: "POST", onSubmit },
        button(
          { class: classNames("btn", busy && "btn-disabled") },
          doneAt ? i18n.gettext("Back to plan") : i18n.gettext("Done"),
        ),
        input({ class: "input", name: "text", readOnly: true, value: text }),
      ),
    ),
  );
}
src/routes/error.js
import { a, h1, main, p, useStatus } from "evikit";
import { useMeta, useTitle } from "hoofd/preact";

/** @param {{ children?: import("preact").ComponentChildren, error?: unknown }} props */
export function ErrorPage({ children, error }) {
  const status = useStatus(error);
  useMeta({ content: "noindex", name: "robots" });
  useTitle(status);

  return main(
    { class: "m-8" },
    h1({ class: "text-2xl" }, status),
    children,
  );
}
src/lib/index.js
import acceptLanguage from "accept-language";

export function acceptLanguages() {
  acceptLanguage.languages(["en", "uk"]);
}

🤝 Translate this page to your language using Poedit and send a merge request!

src/api.js
import { route, router } from "evikit/server";

import pkg from "../package.json" with { type: "json" };
import { acceptLanguages } from "./lib/index.js";
import { TodoServer } from "./routes/api/todo/server.js";

acceptLanguages();

export function api() {
  return router(
    { pkg },
    route("/api/todo", TodoServer),
  );
}
src/app.js
import { hydrate, Router, ssr } from "evikit";
import { h } from "preact";
import { Route } from "preact-iso";

import { acceptLanguages } from "./lib/index.js";
import { ErrorPage } from "./routes/error.js";
import { TodoPage } from "./routes/todo/page.js";

acceptLanguages();

/** @param {any} data */
export const prerender = (data) => ssr(App, data);

/** @param {{ dehydratedState?: any, i18n: any }} props */
export function App(props) {
  return h(
    Router,
    props,
    h(Route, { component: TodoPage, path: "/todo" }),
    h(Route, { component: ErrorPage, default: true }),
  );
}

hydrate(App, "#app");
index.js
import { listen, production } from "evikit/server";
import express from "express";
import { readFile } from "node:fs/promises";
import swaggerUi from "swagger-ui-express";

import { api } from "./src/api.js";
import { prerender } from "./src/app.js";

const app = express();
app.use("/static", express.static("./static"));
app.use(api().onRequest);
app.use("/openapi", swaggerUi.serve, swaggerUi.setup(api().openapi));
app.use("/", express.static("./dist", { index: false, redirect: false }));
app.use(
  production({
    html: await readFile("./dist/index.html", "utf-8"),
    prerender,
  }),
);
listen(app);
vite.config.js
import preact from "@preact/preset-vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import { analyzer } from "vite-bundle-analyzer";

import { api } from "./src/api.js";

export default defineConfig({
  plugins: [
    analyzer({ analyzerMode: "static" }),
    {
      configureServer(server) {
        server.middlewares.use(api().onRequest);
      },
      name: "configure-server",
    },
    preact({ prerender: { enabled: true, renderTarget: "#app" } }),
    tailwindcss(),
  ],
});
src/lib/db/index.js
import { sqlite } from "evikit/sqlite";

import pkg from "../../../package.json" with { type: "json" };
import * as schema from "./schema.js";

export const db = sqlite({ pkg, schema });

if (
  process.env["npm_command"] == "start" ||
  process.env["npm_lifecycle_event"] == "dev"
) {
  setInterval(() => {
    db.Todo.delete({
      text: db.Todo.select()
        .filter(({ createdAt }) => createdAt < Date.now() - 10 * 60e3)
        .map(({ text }) => text),
    });
  }, 1000);
}
index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="color-scheme" content="dark light" />
    <!--app-head-->
  </head>
  <body>
    <div id="app"><!--app-html--></div>
    <script prerender type="module" src="/src/app.js"></script>
  </body>
</html>

There’s intentionally not much API. Documentation entirely fits in a long but straightforward README. Like the Django tutorial, it guides you through writing an application using all the primary features of the EviKit framework.