webspresso

Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling.

You describe routes as files under pages/, render with Nunjucks, validate HTTP input with Zod, and optionally plug in Knex-backed models, migrations, and first-party plugins (dashboard, sitemap, analytics, admin, and more). This page is a condensed reference; the authoritative source is the README.md in the repository.

Release: 0.1.0-alpha.0 · Stack: Hono · Nunjucks · Knex · Zod · Node 18+

What's new

Highlights in 0.1.0-alpha.0. Full history: CHANGELOG.md.

AreaSummary
HonocreateApp().app is WebspressoCompatApplisten, fetch, Express-shaped handlers. See Hono migration.
Build compilerwebspresso build — manifest + handlers under .webspresso/. See Deployment.
Cloudflare Workersadd deploy --provider cloudflare; precompiled templates.mjs (full extends / include graph). Worker runtime: createWorkerApp. See Cloudflare Workers.
Subpath exportswebspresso/build, webspresso/core/auth, webspresso/core/orm, split manifest entries for Node vs Worker.
D1 on WorkersWrangler env.DBreq.db / getDb() in compiled API routes.

At a glance

Webspresso is an opinionated, batteries-included server-side toolkit: one CLI scaffolds projects, watches your pages/ tree to build HTTP routes on Hono (with an Express-compatible handler API), and optional plugins add SEO, analytics, and an admin UI without bolting on a separate meta-framework. It targets teams who want Laravel- or Rails-like ergonomics on Node with transparent files on disk instead of a hidden compiler graph.

When it fits

  • Content-heavy sites and internal tools with SSR
  • JSON APIs colocated with HTML routes
  • PostgreSQL / MySQL / SQLite with a small ORM

Core ideas

  • Convention over configuration for URLs
  • Validation and types via Zod at the edge
  • Plugins fail soft (warn) so one bad plugin does not stop the app

Features

Installation

Requires Node.js 18+. Install the CLI globally or add the framework as a project dependency.

npm install -g webspresso
# or, inside a project:
npm install webspresso

After npm install webspresso, use npx webspresso or define an npm script if the binary is not on your PATH.

TypeScript: the package ships index.d.ts for the public API. Example: import { createApp, defineModel } from 'webspresso'. Route handlers use the compat (req, res, next) shape; createApp().app is a WebspressoCompatApp (Hono + listen / get / post helpers).

Peer dependencies

Database drivers, Faker, and dotenv are optional peers — install only what you use so production images stay lean.

PackageTypical use
pgPostgreSQL via Knex
mysql2MySQL / MariaDB
better-sqlite3SQLite (local dev, embedded)
dotenvLoad .env in development
zodwebspresso new scaffold validates env with Zod (config/env.schema.js)
@faker-js/fakerSeed scripts and factories

Quick start

Start here: step-by-step docs/getting-started.md (about 15 minutes: page, API, model, admin). Example apps live under examples/.

New projects ship with Tailwind CSS, a starter layout, i18n (e.g. en/tr), and npm scripts. Pass --no-tailwind to skip Tailwind. Presets: --template blog|admin|dashboard|landing.

webspresso new my-app
cd my-app
npm install
npm run build:css
webspresso dev
# or: npm run dev

webspresso new flows

Development server

webspresso dev expects a server.js in the project root, uses Node’s watch mode on pages, models, views when present, and can run Tailwind watch:css alongside.

webspresso dev
webspresso dev --port 3001
webspresso dev --no-css   # skip CSS watch if Tailwind is configured

Project structure

Typical application layout (database pieces appear after you opt in or add them manually):

my-app/
├── config/
│   ├── load-env.js           # .env chain (last file wins per key)
│   ├── env.schema.js         # Zod validation for process.env
│   └── app.js                # createApp() options (+ db if webspresso.db.js)
├── pages/
│   ├── locales/              # global i18n (en.json, de.json, …)
│   ├── _hooks.js             # global lifecycle hooks
│   ├── index.njk             # GET /
│   ├── about/
│   │   ├── index.njk
│   │   └── locales/          # route-level overrides
│   ├── tools/
│   │   ├── index.njk
│   │   ├── index.js          # load(), meta(), middleware, hooks
│   │   ├── [slug].njk
│   │   └── [slug].js
│   └── api/
│       ├── health.get.js
│       └── echo.post.js
├── views/
│   └── layout.njk
├── public/                   # static assets
├── models/                   # optional ORM models
├── migrations/               # Knex migrations
├── seeds/                    # optional seed entry (seeds/index.js)
├── webspresso.db.js          # optional Knex config
└── server.js                 # loadEnv() + createApp()

Environment variables

VariableDefaultDescription
NODE_ENVdevelopmentControls logging, plugin defaults (e.g. dashboard)
DEFAULT_LOCALEenFallback locale
SUPPORTED_LOCALESenComma-separated list (e.g. en,de)
BASE_URLhttp://localhost:3000Canonical URLs, sitemap, metadata
DATABASE_URLConnection string for Knex / ORM

New project scaffold merges env files in this order (each step overrides keys): .env.env.local.env.<NODE_ENV>.env.<NODE_ENV>.local. Use .env.example as a template; keep secrets in ignored *.local files.

webspresso start sets NODE_ENV=production and honors PORT via the --port flag.

CLI commands

Entry: webspresso (bin/webspresso.js). Use --help on any subcommand for flags.

CommandDescription
new [project-name]Scaffold a project (Tailwind, i18n, scripts; optional DB + seeds)
pageInteractive: add SSR page + optional route config + locales
apiInteractive: add API route + HTTP method
devDevelopment server (Node watch, optional CSS watch)
startProduction server via server.js
add tailwindAdd Tailwind, PostCSS, build + watch scripts
add deployScaffold deploy files — --provider cloudflare|docker|pm2 (comma-separated)
buildCompile manifest + adapter output under .webspresso/--adapter node|cloudflare|bun
db:migrateRun pending migrations
db:rollbackRollback last migration batch
db:statusShow migration status
db:make <name>Create migration file (optional --model scaffold)
seedRun seeds/index.js (Faker-based fake data)
admin:setupEmit admin_users migration for admin plugin
admin:listList admin users (needs migrated admin_users)
admin:passwordReset admin password (interactive or -e / -p)
audit:pruneDelete audit log rows older than --days (optional --table)
doctorSanity checks: Node version vs package.json engines, expected project files, optional DB ping — run from your app root
skill [name]Create an Agent Skill (SKILL.md) for Cursor — interactive name/description, or --preset webspresso copies SKILL.md + REFERENCE-framework.md + REFERENCE-kernel.md into .cursor/skills/webspresso-usage/
favicon:generate <source>PNG favicons, PWA manifest, Nunjucks partial

Selected flags

CommandFlags / notes
new-i, --install · --no-tailwind
dev / start-p, --port (default 3000) · dev --no-css · dev -a cloudflare prints Wrangler reminder
build-a, --adapter <name> · --skip-bundle · --fail-on-warnings
add deploy-p, --provider <name> (required) — cloudflare, docker, pm2
seed--setup · --config · --env
admin:password / admin:list-c, --config · -E, --env
favicon:generate-o · --no-layout · PWA --name / --short-name / --theme-color
doctor--db (test DB when webspresso.db.js / knexfile.js exists) · --strict (exit 1 on warnings) · -e, --env (with --db)
skill-g, --global (write to ~/.cursor/skills/) · -d, --description · -f, --force · -p, --preset <name> (e.g. webspresso)

API — createApp(options)

Returns { app, nunjucksEnv, pluginManager, authMiddleware } (auth helper may be no-op if auth is not configured). app is a Hono-based compat instance (app.fetch, app.listen, Express-shaped route helpers). The framework applies secure headers, optional sessions, JSON/form body parsers, static files, and the file router. See Migrating from Express if you upgrade from an older major.

OptionRole
pagesDirRequired. Root of routes and templates resolution.
viewsDirNunjucks views / layouts.
publicDirStatic files (default public).
dbORM instance → ctx.db (SSR), req.db on pages/api (before middleware); getDb() / attachDbMiddleware elsewhere.
pluginsArray of plugin factories or objects.
middlewaresNamed map: plain (req,res,next) or factories (opts) => (req,res,next). Routes use middleware: ['auth'] or tuples [['auth', { api: true }]].
helmettrue / false / custom secure-headers options object (CSP and related directives; mapped from the former Helmet-style config).
loggingHTTP logging (on by default in development).
timeoutString duration ('30s') or false — request timeout middleware (sets req.timedout for error pages).
errorPagesnotFound, serverError, timeout — handler fn or template path.
assetsVersion string or manifest path for cache-busted fsy.asset() URLs.
clientRuntimeOptional { alpine?: boolean | object, swup?: boolean | object }. When either is truthy, mounts vendored scripts at /__webspresso/client-runtime/* and passes resolved flags to Nunjucks as clientRuntime. Env overrides: WEBSPRESSO_ALPINE, WEBSPRESSO_SWUP (1 or true). Helpers: resolveClientRuntime(), CLIENT_RUNTIME_BASE on the package root. Details: Client runtime.
authOptional AuthManager from webspresso/core/auth — session stack, req.auth / req.user, named middleware: ['auth' | 'guest']. See Authentication.
setupRoutes(app, ctx) => {} — custom routes on the compat app after file routes / plugin onRoutesReady, before 404. ctx.authMiddleware when auth is set; ctx.nunjucksEnv for rendering in custom handlers; ctx.clientRuntime is { alpine, swup }.
const { createApp } = require('webspresso');

const { app } = createApp({
  pagesDir: './pages',
  viewsDir: './views',
  middlewares: {
    auth: (req, res, next) => {
      if (!req.session?.user) return res.redirect('/login');
      next();
    },
  },
  errorPages: {
    notFound: 'errors/404.njk',
    serverError: 'errors/500.njk',
  },
});

Client runtime (Alpine.js + swup)

Opt-in progressive enhancement for SSR pages: Alpine.js for lightweight UI state and swup for same-document navigations (default container #swup). The admin panel and dev dashboard remain separate Mithril SPAs and are not modified. Use data-no-swup on a link to force a full page load; paths under /_admin and /_webspresso are ignored by the default bootstrap.

In your layout: include the shipped partial views/partials/webspresso-client-runtime.njk (also published in the npm package), and when clientRuntime.swup is enabled, wrap the main content in <main id="swup">…</main> (or match containers in bootstrap). Dynamic data from the server can still use pages/api + fetch from Alpine.

Demo: enable clientRuntime: { alpine: true, swup: true } on createApp and include views/partials/webspresso-client-runtime.njk in your layout (npm run dev). See also examples/landing-page. Production CSP (helmet / secure-headers): allow script-src 'self' for /__webspresso/client-runtime/; if Alpine requires unsafe-eval for your version, adjust or use a CSP-friendly build.

Migrating from the Express-based stack

Recent major versions run on Hono instead of Express. Most application code that uses (req, res, next) and app.listen(port) continues to work; a few Express-only APIs are gone.

Before (Express era)After (Hono)
createApp().app was express.ApplicationWebspressoCompatApp — use app._hono for raw Hono
express-session, cookie-parser, helmet, multer as direct depsBuilt in: hono-sessions, secure-headers, parseBody / upload plugin
res.render(view)Use nunjucksEnv.render(view, data) then res.send(html) (SSR pages still use Nunjucks via the file router)
supertest in testsapp.fetch or the framework test helper pattern in tests/helpers/http.js
express-rate-limit peerOptional hono-rate-limiter peer; built-in rateLimitPlugin (in-memory)

server.js from webspresso new is unchanged: app.listen(PORT, callback) is implemented with @hono/node-server.

const { createApp } = require('webspresso');

const { app } = createApp({
  pagesDir: './pages',
  viewsDir: './views',
  publicDir: './public',
  clientRuntime: { alpine: true, swup: true },
});

Application kernel (kernel)

A separate optional in-process layer ships under core/kernel/. Import kernel from the package root (not the same symbol as SSR createApp): require('webspresso').kernel.createApp() exposes an event bus (dispatch / publish), registerPlugin, registerFlow, a minimal namespaced view resolver, and BaseRepository that emits orm.<resource>.* lifecycle events (in-memory store for demos). It does not replace HTTP file routing (createApp / Hono) or Knex ORM ModelEvents.

Export / pathRole
kernel.createApp()Registers event bus + view engine shell; distinct from SSR createApp(options) above.
kernel.definePlugin / defineFlowSmall plugin descriptors and trigger → condition → sequential actions.
kernel.BaseRepositorySimulated repository with beforeCreate / afterCreate / … events.
core/kernel/*.jsSource modules: events.js, view.js, app.js, …
const { kernel } = require('webspresso');
const app = kernel.createApp();
// app.events.dispatch / publish / on
// app.registerPlugin(kernel.definePlugin({ name: '…', events(app) { … }, views() { … } }));
// app.registerFlow(kernel.defineFlow({ trigger: 'orm.post.afterCreate', when: (ctx) => …, actions: [ … ] }));

Runnable demo from the repository clone: node core/kernel/run-demo.js. Types: index.d.ts (WebspressoKernel, KernelAppShell). Agent skill: webspresso skill --preset webspresso installs REFERENCE-kernel.md next to SKILL.md.

Authentication (session)

Webspresso ships an optional, adapter-style session auth layer in core/auth. It is not re-exported from the package root — import from webspresso/core/auth (the core/ tree is published on npm). Pass an AuthManager to createApp({ auth }) so the framework mounts hono-sessions (cookie-backed session store) and a per-request authenticate middleware that fills req.user and req.auth. Session secret must be at least 32 characters.

Public API (webspresso/core/auth)

ExportRole
createAuth(config)Builds AuthManager with your findUserById, findUserByCredentials, optional rememberTokens adapter, session options, rememberMe, routes (login / redirect defaults).
quickAuth({ db, ... })Opinionated createAuth wired to getRepository — default user model User, email + password fields, optional remember_tokens table via Knex.
setupAuthMiddleware(app, authManager)Applies hono-sessions and authenticate on the compat app; returns guards (requireAuth, requireGuest, requireCan, requireVerified, …) plus auth / guest for route configs.
hash / verifyBcrypt password helpers used by credentials adapters.
createRememberTokensTable(knex)Migration-style helper for the default remember_tokens shape (user_id, hashed token, expires_at).
createAuthTokensTable(knex)Migration helper for auth_tokens (password reset + email verification tokens). AuthManager exposes requestPasswordReset, completePasswordReset, requestEmailVerification, verifyEmail when an authTokens adapter is configured.
PolicyManagerDefine definePolicy / defineGate; AuthManager exposes the same via definePolicy, defineGate, beforePolicy.

Wiring with createApp

const { createApp } = require('webspresso');
const { createAuth, verify } = require('webspresso/core/auth');

const auth = createAuth({
  findUserById: (id) => userRepo.findById(id),
  findUserByCredentials: async (email, password) => {
    const user = await userRepo.findOne({ email });
    if (user && (await verify(password, user.password))) return user;
    return null;
  },
  session: { secret: process.env.SESSION_SECRET },
});

const { app, nunjucksEnv, authMiddleware } = createApp({
  pagesDir: './pages',
  viewsDir: './views',
  db,
  auth,
  setupRoutes(app, ctx) {
    const am = ctx.authMiddleware;
    if (!am) return;
    app.get('/login', am.requireGuest(), (req, res) => {
      const html = nunjucksEnv.render('login.njk', {});
      res.send(html);
    });
    app.post('/login', async (req, res) => {
      const user = await req.auth.attempt(req.body.email, req.body.password);
      if (user) return res.redirect('/');
      res.redirect('/login');
    });
  },
});

Request helpers (req.auth)

Bound per request after authenticate runs:

MethodRole
attempt(id, password, { remember })Validate credentials, log in, optional remember-me cookie when adapter configured.
login(user, options)Session login without re-checking password (user must have id).
logout({ everywhere })Destroy session; clear / revoke remember token(s).
check() / guest()Boolean auth state.
user() / id()Current user object or id.
can / cannot / authorizePolicy / gate checks (throws AuthorizationError on authorize).

Guards and file-router ordering

Remember-me tokens

If you pass a rememberTokens adapter, the manager stores a hashed token in the database and a signed cookie with the raw token. Call createRememberTokensTable(knex) or mirror its columns in a migration. quickAuth can wire the default Knex table when rememberMe: true.

Password reset & email verification (optional)

Enable authTokens: true on quickAuth (or pass an authTokens adapter to createAuth) and run createAuthTokensTable(knex). Pair with emailPlugin and authEmails: { enabled: true } to send MJML mails and optionally mount POST /api/auth/forgot-password, /reset-password, /verify-email, /resend-verification. Forgot-password always returns { ok: true } (no user enumeration).

const { quickAuth, createAuthTokensTable } = require('webspresso/core/auth');
const { emailPlugin, adminPanelPlugin } = require('webspresso/plugins');

const auth = quickAuth({ db, authTokens: true });
await createAuthTokensTable(db.knex);

createApp({
  pagesDir: './pages',
  db,
  auth,
  plugins: [
    emailPlugin({
      db,
      auth,
      smtp: { host: process.env.SMTP_HOST, port: 587, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } },
      defaults: { from: process.env.MAIL_FROM },
      authEmails: {
        enabled: true,
        baseUrl: process.env.BASE_URL,
        registerRoutes: true,
      },
    }),
    adminPanelPlugin({ db }),
  ],
});

Admin panel auth (separate)

The admin panel plugin uses its own session namespace (req.session.adminUser via hono-sessions) with routes under /_admin/api/auth/*. It does not replace createApp({ auth }) for your public site users — use both if you need CMS staff and end-user sessions.

Rich-text fields (customFields with type: 'rich-text'): HTML from the admin API is sanitized on the server with a narrow tag whitelist before save. Use richTextSanitize: false on adminPanelPlugin only if you accept the XSS trade-off. Rendering stored HTML on public pages still requires safe templating (| escape by default in Nunjucks; avoid raw HTML unless you trust the content).

Site user management inside the admin plugin

To manage end-user accounts (the same rows your site login uses) from the admin SPA, enable userManagement on adminPanelPlugin. The model option must match your ORM user model (e.g. User with quickAuth({ userModel: 'User', ... })). The UI lives under /_admin/users, /_admin/users/new, etc., and talks to /_admin/api/users*.

const authManager = quickAuth({ db, userModel: 'User', identifierField: 'email', passwordField: 'password' });

const { app } = createApp({
  pagesDir: './pages',
  db,
  auth: authManager,
  plugins: [
    adminPanelPlugin({
      db,
      auth: authManager,
      userManagement: { enabled: true, model: 'User' },
    }),
  ],
});

Admin UI screenshots

Screenshots are generated locally with npm run docs:admin-screenshots (Playwright). If images are missing in your checkout, run that script or refer to the admin-panel example.

Click any image to zoom. Captures use the docs fixture app with adminPanelPlugin, dataExchangePlugin, auditLogPlugin, siteAnalyticsPlugin, and ormCacheAdminPlugin. Regenerate with node scripts/capture-admin-doc-screenshots.js.

Core admin panel

Webspresso admin dashboard
Dashboard — widgets, model cards, sidebar navigation.
Webspresso admin model records list
Model list — table, pagination, Edit/Delete actions.
Webspresso admin bulk actions toolbar
Bulk actions — export JSON/CSV/Excel, import, set field, delete.
Webspresso admin advanced filters drawer
Advanced filters drawer on the list view.
Webspresso admin create record form
New record — schema-driven form (incl. rich-text fields).
Webspresso admin edit record form
Edit record — same form layout with existing values.
Webspresso admin site users list
Site user management (userManagement) — manage end-user accounts from the same admin SPA.

Admin plugins (register via adminApi.registerModule)

Webspresso audit log admin page
auditLogPlugin — CRUD audit trail for admin API actions.
Webspresso site analytics admin dashboard
siteAnalyticsPlugin — self-hosted page views, referrers, bots, client errors.
Webspresso ORM cache admin page
ormCacheAdminPlugin — cache hit metrics, purge, and per-model invalidate (createDatabase({ cache: true })).

File-based routing

SSR pages

File pathRoute
pages/index.njkGET /
pages/about/index.njkGET /about
pages/tools/[slug].njkGET /tools/:slug
pages/docs/[...rest].njkGET /docs/*

API routes

File pathRoute
pages/api/health.get.jsGET /api/health
pages/api/echo.post.jsPOST /api/echo
pages/api/users/[id].get.jsGET /api/users/:id

Route config (pages/.../*.js next to .njk)

module.exports = {
  middleware: ['auth'],
  async load(req, ctx) {
    const posts = await ctx.db.getRepository('Post').query().limit(10).list();
    return { posts };
  },
  meta(req, ctx) {
    return { title: 'Blog', description: 'Latest posts' };
  },
  hooks: { beforeLoad: async () => {}, afterRender: async () => {} },
};

API module shapes

Per request: req.db (if configured) → Zod schemamiddlewarehandler.

module.exports = {
  middleware: ['requireAuth'],
  schema: ({ z }) => ({ body: z.object({ q: z.string() }) }),
  handler: async (req, res) => {
    return res.json({ q: req.input.body.q, results: [] });
  },
};

Zod schemas (API)

KeyValidates
bodyPOST / PUT / PATCH JSON body
paramsPath params (:id, …)
queryQuery string
responseDocumentation only (not enforced at runtime)

Validated values are on req.input; failures return 400 JSON { error: 'Validation Error', issues }.

Plugin system

Plugins can register compat middleware ((req, res, next) or Hono handlers on app._hono), template helpers/filters, routes in onRoutesReady, and expose an api object for other plugins. Errors during registration generally log a warning instead of crashing the process.

Built-in (require path)Purpose
studioPlugin / createApp({ studio })SSR developer Studio at /_webspresso — routes, plugins, health, ORM, env (see Studio)
dashboardPluginDeprecated — wraps studioPlugin
sitemapPlugin/sitemap.xml, /robots.txt, optional DB-driven URLs
analyticsPluginGA4, GTM, Yandex, Bing UET, Facebook Pixel, verification meta tags
siteAnalyticsPluginSelf-hosted page views + admin charts
adminPanelPluginCRUD admin SPA (Mithril), auth hooks, custom modules
dataExchangePluginAdmin-only .xlsx export + CSV/XLSX import (/api/data-exchange/…); register after adminPanelPlugin
ormCacheAdminPluginAdmin UI for ORM cache stats / purge / per-model invalidate (requires adminPanelPlugin + createDatabase({ cache: … }))
seoCheckerPluginDev toolbar SEO audit (40+ checks)
schemaExplorerPluginJSON schema of models + ORM components OpenAPI export
swaggerPluginOpenAPI 3 for pages/api + Zod; Swagger UI (dev by default)
healthCheckPluginGET /health probe (optional DB checks, custom path)
redirectPluginConfigurable 301/302/303/307/308 redirects in register() — runs before file-based SSR routes; see Redirect plugin
uploadPluginPOST multipart uploads (parseBody / built-in multipart); default createLocalFileProvider; optional mimeAllowlist / maxBytes; pairs with admin settings.uploadUrl
rateLimitPluginIn-memory rate limiting on selected routes (optional hono-rate-limiter peer for advanced setups)
emailPluginMJML + Nodemailer email sending, template registry, optional DB logs + admin page; optional auth password-reset / email-verify bridge — see Email plugin
restResourcePluginOpt-in REST CRUD per model (rest.enabled or plugin models whitelist); ?include= uses ORM eager load (single-level relations only; no nested a.b)

Redirect plugin

redirectPlugin registers middleware in register(), which runs after static assets but before mountPages — so configured paths take precedence over SSR page files. Rules are evaluated in order; the first match wins.

const { redirectPlugin } = require('webspresso/plugins');

createApp({
  pagesDir: './pages',
  plugins: [
    redirectPlugin({
      rules: [
        { from: '/old-blog', to: '/blog', status: 301 },
        { from: /^\/wiki\/(.*)$/, to: '/docs' },
      ],
    }),
  ],
});

README: Redirect plugin.

File upload plugin

Use uploadPlugin({ path, local: { destDir, publicBasePath }, maxBytes, mimeAllowlist, extensionAllowlist, middleware, provider }) from the package root or webspresso/plugins. The handler accepts multipart field file (configurable) and returns JSON { url, publicUrl, key? }. In production, set an explicit MIME allowlist — trusting all types is risky. ORM: zdb.file() stores the URL/path as a string column. Register uploadPlugin before adminPanelPlugin so the admin SPA gets uploadUrl, or pass adminPanelPlugin({ uploadUrl: '/api/upload' }).

REST resources plugin

Mounts GET list, GET /:id, POST, PATCH /:id, DELETE /:id under a configurable base path (default /api/rest). Requires createApp({ db }). Model metadata: defineModel({ ..., rest: { enabled: true, path: 'segment', allowInclude: ['company'] } }). List params: page, perPage, sort, order, include, trashed (soft-delete), plus equality filters on known columns.

const { createApp, restResourcePlugin } = require('webspresso');

const { app } = createApp({
  pagesDir: './pages',
  db,
  plugins: [
    restResourcePlugin({
      path: '/api/rest',
      middleware: [],        // optional — e.g. auth (before attachDbMiddleware)
      models: null,          // optional whitelist of model names
      excludeModels: [],
    }),
  ],
});

Also exported from webspresso/plugins. See README section REST resources plugin for full options.

Data exchange plugin

Optional dataExchangePlugin adds admin-authenticated spreadsheet endpoints (same session as adminPanelPlugin). Only models with admin.enabled participate; hidden columns are omitted from export and ignored on import. Uses exceljs and csv-parse from the framework package.

const { adminPanelPlugin, dataExchangePlugin } = require('webspresso/plugins');

createApp({
  pagesDir: './pages',
  db,
  plugins: [
    adminPanelPlugin({ db, path: '/_admin' }),
    dataExchangePlugin({ db, adminPath: '/_admin' }),
  ],
});

Full detail: README — dataExchangePlugin. In the model list UI, spreadsheet import/export appears in the bulk toolbar (see Admin UI screenshots — bulk actions).

Audit log plugin

auditLogPlugin records create/update/delete actions performed through the admin model API. It registers middleware on the app and adds an Audit log page under /_admin/audit-log. Prune old rows with webspresso audit:prune --days 90.

const { adminPanelPlugin, auditLogPlugin } = require('webspresso/plugins');

createApp({
  pagesDir: './pages',
  db,
  plugins: [
    adminPanelPlugin({ db }),
    auditLogPlugin({ db, adminPath: '/_admin' }),
  ],
});
Audit log admin page
Audit log list — actor, action, model, path, timestamp.

Site analytics plugin

siteAnalyticsPlugin tracks page views in your database (no third-party analytics required) and adds an Analytics admin page at /_admin/analytics with charts for views over time, top pages, referrers, countries, bots, and optional client-side JS errors.

Site analytics admin dashboard
Analytics dashboard — stats cards and time-series charts.

Email plugin

emailPlugin sends email via Nodemailer with MJML templates. Templates can come from a directory (templatesDir), a config map, runtime registerTemplate, or inline on send({ mjml, data }). Variables use {{name}} / {{user.email}} interpolation.

const { createApp, adminPanelPlugin, emailPlugin } = require('webspresso');

createApp({
  pagesDir: './pages',
  db,
  plugins: [
    emailPlugin({
      db,
      templatesDir: './emails',
      smtp: { host: process.env.SMTP_HOST, port: 587, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } },
      defaults: { from: process.env.MAIL_FROM },
    }),
    adminPanelPlugin({ db }),
  ],
});

// Other plugins / route handlers:
// pluginManager.getPluginAPI('email').sendTemplate('welcome', { to, subject, data });

Migration helper: ctx.usePlugin('email').getMigrationTemplate() or README Email plugin.

ORM & database

The ORM layers Knex with Zod: column metadata lives on Zod schemas via zdb helpers (zdb.id(), zdb.uuid(), zdb.nanoid(), zdb.string(), foreign keys, relations, soft deletes, …). Repositories provide findById, query() with pagination, transactions, and migrations via the same Knex instance. Optional query caching is enabled with createDatabase({ cache: true }) or a cache: { defaultStrategy, memory, provider } object; see below.

Schema helpers (zdb)

Helpers wrap Zod with database column metadata (same surface as README — Schema helpers).

HelperDescriptionOptions
zdb.id()Primary key (bigint, auto-increment)
zdb.uuid()UUID primary key
zdb.nanoid(opts)Nanoid primary key (URL-safe string, VARCHAR)maxLength (default 21)
zdb.string(opts)VARCHAR columnmaxLength, unique, index, nullable
zdb.text(opts)TEXT columnnullable
zdb.integer(opts)INTEGER columnnullable, default
zdb.bigint(opts)BIGINT columnnullable
zdb.float(opts)FLOAT columnnullable
zdb.decimal(opts)DECIMAL columnprecision, scale, nullable
zdb.boolean(opts)BOOLEAN columndefault, nullable
zdb.date(opts)DATE columnnullable
zdb.datetime(opts)DATETIME columnnullable
zdb.timestamp(opts)TIMESTAMP columnauto: 'create'|'update', nullable
zdb.json(opts)JSON columnnullable
zdb.array(itemSchema, opts)ARRAY column (stored as JSON)nullable
zdb.enum(values, opts)ENUM columndefault, nullable
zdb.foreignKey(table, opts)Foreign key (bigint)referenceColumn, nullable
zdb.foreignUuid(table, opts)Foreign key (uuid)referenceColumn, nullable
zdb.foreignNanoid(table, opts)Foreign key (nanoid string)referenceColumn, nullable, maxLength (match referenced PK)

Nanoid columns: migration scaffolding uses table.string(column, maxLength). For a nanoid primary key, omitting the PK on repository.create() fills it with a cryptographically random ID (same default alphabet as the nanoid package; built into Webspresso, no extra npm dependency). Use generateNanoid from webspresso when you need the same generator manually. For API schema validation (params, query, body), use z.nanoid(), z.nanoid(n), or z.nanoid({ maxLength }) on the z from your route schema (or zodNanoid / extendZ outside compiled routes).

const { zdb, defineModel, createDatabase } = require('webspresso');

const User = defineModel({
  name: 'User',
  table: 'users',
  schema: zdb.schema({
    id: zdb.id(),
    email: zdb.string({ unique: true }),
    created_at: zdb.timestamp({ auto: 'create' }),
  }),
});

const db = createDatabase({
  client: 'pg',
  connection: process.env.DATABASE_URL,
  models: './models',
});

ORM query cache (optional)

Pass cache: true or cache: { enabled: true, defaultStrategy: 'auto' | 'smart', memory?: { maxEntries, defaultTtlMs }, provider? } to createDatabase. Then db.cache exposes purge, invalidateTags, invalidateModel(name), and metrics helpers; it is null when caching is off.

Per model, defineModel({ cache: true | 'auto' | 'smart' | { strategy } | false }) opts in or out and picks invalidation coarseness: auto clears all cached reads for that model on any mutation; smart uses finer tags (row PK + collection) where safe. Cached read paths include findById, findOne, findAll, and query builder first / list / count / paginate when the query is classifiable; Knex transaction clients always bypass the cache.

For an admin panel page (metrics, purge, invalidate), add ormCacheAdminPlugin({ db }) next to adminPanelPlugin. Full detail: README — ORM query cache.

const { createApp, createDatabase, defineModel, ormCacheAdminPlugin, adminPanelPlugin } = require('webspresso');

const db = createDatabase({
  client: 'pg',
  connection: process.env.DATABASE_URL,
  models: './models',
  cache: true,
});

// defineModel({ ..., cache: 'smart' }) // optional per-model override

createApp({
  pagesDir: './pages',
  plugins: [
    adminPanelPlugin({ db }),
    ormCacheAdminPlugin({ db }),
  ],
});
ORM cache admin page
ORM Cache admin — metrics, purge all, invalidate by model.
npm install pg mysql2 better-sqlite3   # pick one
webspresso db:migrate
webspresso db:make add_posts_table --model Post
webspresso seed

Migrations live under migrations/ as usual for Knex; webspresso.db.js (or knexfile.js) is loaded for all DB CLI commands.

i18n & template helpers

Locales merge: global JSON in pages/locales/, then route-specific folders override keys for that subtree.

{
  "nav": { "home": "Home", "about": "About" }
}
<h1>{{ t('nav.home') }}</h1>
{{ fsy.canonical() | safe }}
{{ fsy.url('/blog', { page: 2 }) }}

fsy groups include URL builders, request accessors, slugify/truncate/pretty bytes, dayjs-powered dates, dev flag, JSON-LD helper, and asset tags when assets is configured — see README for the exhaustive list.

Lifecycle hooks

Global hook module:

// pages/_hooks.js
module.exports = {
  onRequest(ctx) {},
  beforeLoad(ctx) {},
  afterLoad(ctx) {},
  beforeRender(ctx) {},
  afterRender(ctx) {},
  onError(ctx, err) {},
};

Execution order (SSR)

  1. Global onRequest → route onRequest
  2. beforeMiddleware → middleware chain → afterMiddleware
  3. beforeLoadloadafterLoad
  4. beforeRender → Nunjucks render → afterRender

Tooling & developer experience

Webspresso bundles the pieces you usually wire by hand: a CLI for scaffolding and DB tasks, a dev runner that restarts the process when routes or templates change, and optional first-party UI for routes and SEO. Below is how those tools fit together.

CLI & project automation

The webspresso binary (Commander) exposes subcommands for projects, APIs, Tailwind, Knex migrations, seeds, admin maintenance, audit pruning, environment checks (doctor), Cursor Agent Skills (skill, including a bundled Webspresso preset), and favicon/PWA asset generation. Interactive flows use Inquirer when you run commands without full arguments (e.g. webspresso new with no name).

Local development server

webspresso dev runs your project’s server.js under Node’s --watch and adds --watch-path for pages/, models/, and views/ when those directories exist, so route files and Nunjucks layouts reload without manual restarts. With Tailwind enabled, the same command can spawn watch:css; use --no-css to skip that subprocess.

CSS & front-end pipeline

Scaffolded apps use Tailwind CSS with PostCSS and Autoprefixer (webspresso add tailwind or the default new template). Compiled CSS is written under public/css/ and linked from the layout. For cache-busted assets in SSR, createApp({ assets }) supports a static version string or a Vite/Webpack-style manifest so fsy.asset() resolves hashed filenames.

In-browser dev tools

createApp({ studio: true }) mounts Webspresso Studio at /_webspresso (on by default in development). See Studio and docs/studio.md. The SEO checker plugin adds a separate dev toolbar. Production requires explicit config and authentication.

Images & PWA assets

webspresso favicon:generate uses sharp to resize a master PNG into Apple/Android/favicon sizes, writes a manifest.json, and can inject a Nunjucks partial into your layout — useful for consistent branding across devices.

Webspresso Studio

Studio is the developer visibility panel at /_webspresso (SSR, no extra frontend framework). It is separate from the production admin panel at /_admin.

createApp({
  pagesDir: './pages',
  viewsDir: './views',
  studio: {
    enabled: true,
    path: '/_webspresso',
    auth: 'dev-only',
    exposeEnv: false,
    requestTimeline: { enabled: true, maxEntries: 100 },
  },
});

Pages: overview, routes, plugins, ORM, cache, health, OpenAPI, sitemap, env, logs. Security: docs/studio-security.md.

Request flow (conceptual)

Incoming HTTP traffic passes through the Hono compat stack (secure headers, sessions, body parsers, timeout, static files) and middleware registered by plugins, then the file router decides whether the request targets an API module under pages/api or an SSR page. API handlers may run Zod first; SSR routes run load() then Nunjucks with globals (fsy, t, …).

  Client
    │
    ▼
┌─────────────────────────────────────────┐
│ Hono compat stack: session, secure hdrs │
│ body parsers, timeout, static files     │
│ Plugin register(ctx) — extend app       │
└─────────────────┬───────────────────────┘
                  ▼
         mountPages (file-router)
                  │
      ┌───────────┴───────────┐
      ▼                       ▼
 pages/api/*.js          pages/*.njk + route *.js
 Zod → handler           load() → Nunjucks → HTML
      │                       │
      └───────────┬───────────┘
                  ▼
            HTTP response

Architecture

Two layers matter: your application (a small server.js that calls createApp, plus pages/, views/, optional models/) and the webspresso package, which implements routing, rendering, plugins, and ORM primitives. The public API surface is re-exported from index.js with matching index.d.ts for TypeScript tooling.

index.js — public exports

Export areaExamples
AppcreateApp
Router utilitiesmountPages, filePathToRoute, scanDirectory, i18n helpers
Templates & assetscreateHelpers, AssetManager, configureAssets
PluginsPluginManager, createPluginManager
ORMdefineModel, createDatabase, zdb, generateNanoid, zodNanoid, …
Convenience pluginsschemaExplorerPlugin, swaggerPlugin, healthCheckPlugin, uploadPlugin, restResourcePlugin, adminPanelPlugin, dataExchangePlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin
TypeScriptindex.d.ts (package.json types)

src/server.jscreateApp pipeline

  1. Build the Hono compat app (src/http/compat-app.js) and apply secure headers via the helmet option (CSP enabled in production; relaxed in dev for hot editing).
  2. Attach sessions (hono-sessions when auth/admin needs them), JSON/form parsers, request timeout when configured, and static file serving.
  3. Instantiate PluginManager, run each plugin’s register(ctx) (templates, middleware, helpers).
  4. Configure Nunjucks with configureAssets / createHelpers for fsy.
  5. Call mountPages from file-router.js to register SSR and API routes from disk.
  6. Run onRoutesReady on plugins so they can add routes or read the route table.
  7. Invoke onReady when the server starts listening.

Source modules (published package)

PathResponsibility
src/server.jscreateApp, Hono compat wiring, error pages, plugin orchestration
src/http/Compat app, request/response context, sessions, multipart, secure-headers, Node listen
src/file-router.jsScan pages/, map files to routes, wire API handlers and SSR
src/helpers.jsNunjucks globals (fsy), asset URL resolution
src/plugin-manager.jsPlugin registry, dependency metadata, lifecycle hooks
core/orm/Models, repositories, Knex query builder, migrations, seeding
core/auth/Session-aware helpers for protected routes / admin
core/build/Production compiler — manifest, adapters (node, cloudflare), createWorkerApp
src/router-edge.jsEdge-safe i18n / middleware helpers (no fs)
core/compileSchema.js / applySchema.jsZod schema compilation for validation layers
plugins/Built-in dashboard, sitemap, analytics, admin, SEO checker, …
utils/Shared helpers (e.g. schema cache)
bin/CLI commands (Commander)

Package layout (package.json files)

index.js           # re-exports createApp, router utils, ORM, plugins
index.d.ts         # TypeScript declarations (public API)
bin/               # webspresso CLI
adapters/          # node, cloudflare, bun deploy adapters
core/              # ORM, auth, build compiler, Zod compile/apply
plugins/           # first-party plugins
src/               # server.js, file-router, http/ (Hono compat), helpers
utils/             # shared utilities

Subpath exports (package.json exports)

ImportUse
webspressocreateApp, router, ORM re-exports, plugins index
webspresso/buildrunBuild, build types
webspresso/build/runtime/create-app-from-manifestCloudflare Worker → createWorkerApp
webspresso/build/runtime/create-app-from-manifest-nodeNode production manifest → full server.js stack
webspresso/core/authSession auth (createAuth, guards)
webspresso/core/ormKnex ORM (defineModel, D1 client)
webspresso/plugins/*Individual built-in plugins

Generated Cloudflare entries must import the worker manifest path so Wrangler does not bundle bcrypt, admin plugins, or filesystem route scanning.

Deployment

Development uses webspresso dev and your project server.js (live file-router scan). Production can use the same stack on Node, or a compiled Cloudflare Worker that serves routes from a build manifest. The compiler lives in core/build/ (webspresso build CLI).

Adapters

AdapterOutputDeploy with
node.webspresso/server/node .webspresso/server/index.mjs, Docker, PM2
cloudflare.webspresso/worker/ + templates.mjsWrangler (wrangler dev / deploy)
bun.webspresso/server/Bun runtime (experimental)

Typical workflow

# 1) Scaffold provider files (once per project)
webspresso add deploy --provider cloudflare

# 2) Configure webspresso.build.js (adapter, pagesDir, viewsDir, publicDir)

# 3) Build production artifacts
npm run build:css                    # if using Tailwind
webspresso build --adapter cloudflare

# 4) Run locally or deploy
npx wrangler dev
npx wrangler deploy

Build output layout

.webspresso/
  worker/                    # cloudflare adapter
    manifest.json            # routes, i18n, template metadata, buildId
    handlers.mjs             # compiled API + SSR config exports
    templates.mjs            # precompiled Nunjucks (extends/includes from views/)
    index.mjs                # Worker entry (import createAppFromManifest)
    assets/public/           # copy of public/ for Wrangler [assets]
  server/                    # node adapter (same except no templates.mjs)
  meta/
    build-graph.json
    diagnostics.json

Build phases (summary)

  1. Discover — scan pages/ for SSR and API routes.
  2. Analyze — static import checks, Nunjucks extends / include, edge-incompatible require() on Cloudflare.
  3. Compile — bundle handlers; walk Nunjucks extends / include from views/; precompile to templates.mjs (Cloudflare); collect i18n JSON.
  4. Manifest — assign registration order, template ids, plugin metadata.
  5. Bundle — write artifacts; Node runs esbuild on entry; Cloudflare skips esbuild and emits templates.mjs for Wrangler.
  6. Validate — adapter rules (unsupported plugins, memory sessions, unresolved templates).

Node production entry uses createAppFromManifest from webspresso/build/runtime/create-app-from-manifest-node (full server.js). Cloudflare uses a separate worker-only module so Wrangler never bundles auth, bcrypt, or filesystem route scanning. Details: Cloudflare Workers.

Cloudflare Workers

Run SSR and API routes on Cloudflare’s edge using a manifest-driven worker. The worker runtime (createWorkerApp) mounts routes from manifest.json, renders templates from precompiled Nunjucks (no runtime eval), and receives Wrangler bindings (env.DB, env.ASSETS) on each fetch.

Prerequisites

webspresso.build.js

/** @type {import('webspresso/build').BuildConfig} */
module.exports = {
  adapter: 'cloudflare',
  pagesDir: 'pages',
  viewsDir: 'views',
  publicDir: 'public',
};

wrangler.toml (scaffold)

name = "webspresso-app"
main = ".webspresso/worker/index.mjs"
compatibility_date = "2024-11-01"
compatibility_flags = ["nodejs_compat"]

[assets]
directory = ".webspresso/worker/assets/public"
binding = "ASSETS"

[[d1_databases]]
binding = "DB"
database_name = "webspresso"
migrations_dir = "migrations"

[vars]
NODE_ENV = "production"

main points at the generated worker entry. Static files are served via the Assets binding (copied at build time). Remove the [[d1_databases]] block if you do not use D1.

Generated worker entry

After webspresso build --adapter cloudflare, .webspresso/worker/index.mjs imports the worker-only manifest helper and precompiled templates:

import { createAppFromManifest } from 'webspresso/build/runtime/create-app-from-manifest';
import manifest from './manifest.json' assert { type: 'json' };
import { handlers } from './handlers.mjs';
import precompiledTemplates from './templates.mjs';

let cached = null;
function getApp(env) {
  if (!cached) {
    cached = createAppFromManifest({
      manifest,
      handlers,
      pagesDir: 'pages',
      bindings: env,
      precompiledTemplates,
      clientRuntime: { alpine: false, swup: false },
      logging: false,
    });
  }
  return cached.app;
}

export default {
  fetch(request, env, ctx) {
    return getApp(env).fetch(request, env, ctx);
  },
};

Node vs worker runtime

CapabilityNode createAppWorker createWorkerApp
Route sourceFilesystem scan (file-router) or manifestManifest only
NunjucksLive templates + watchPrecompiled templates.mjs
Auth / bcryptcore/authNot included
PluginsAll built-insEdge-compatible only; build rejects admin/upload/data-exchange
Client runtime (Alpine/swup)OptionalOff in generated entry
Static filespublicDirWrangler Assets
DatabaseKnex driversD1 via webspresso.db.js (optional); env.DBreq.db / getDb() in API handlers

D1 (optional)

webspresso add deploy --provider cloudflare can create webspresso.db.js with better-sqlite3 for local dev and d1 / d1-remote for production migrations. Use WEBSPRESSO_D1_REMOTE=1 with CF_ACCOUNT_ID, CF_D1_DATABASE_ID, CF_API_TOKEN to migrate the remote database from CI.

The generated worker entry imports knex and knex-cloudflare-d1, passes dbRuntime into createAppFromManifest, and resolves bindings.DB via resolveWorkerDb — same req.db.getRepository('Model') pattern as Node.

// pages/api/notes.get.js
module.exports = async function handler(req, res) {
  if (!req.db) return res.status(503).json({ error: 'Database not configured' });
  const notes = await req.db.getRepository('Note').query().orderBy('created_at', 'desc').list();
  res.json(notes);
};

Build validation errors

CodeMeaning
WS_BUILD_PLUGIN_UNSUPPORTEDPlugin not marked edge-compatible (e.g. admin panel)
WS_BUILD_EDGE_INCOMPATIBLERoute file imports fs, bcrypt, etc.
WS_BUILD_SESSION_MEMORYIn-memory session store not allowed on Workers

Troubleshooting

SymptomWhat to check
Many esbuild errors (crypto, fs, knex dialects)Use --adapter cloudflare; framework skips its esbuild — Wrangler bundles the worker entry
Wrangler: bcrypt / node-pre-gyp / aws-sdkEntry must use worker create-app-from-manifest, not Node server path; rebuild after upgrading webspresso
EvalError: Code generation from strings disallowedMissing or stale templates.mjs — run webspresso build --adapter cloudflare
Template / layout not foundPut layouts in views/; rebuild so extends is precompiled
No CSS on workerRun npm run build:css before webspresso build
npm run build:css
webspresso build --adapter cloudflare
npx wrangler dev
npx wrangler deploy

Development & testing

The framework repository runs Vitest for fast unit and integration tests (CLI, ORM, routing, HTTP compat layer, plugins, etc.) and Playwright for end-to-end checks in a real browser: admin panel APIs and UI, auth flows, audit log, CLI project scaffold, the SEO checker plugin, and data exchange admin API routes (export/import). Integration tests call app.fetch via helpers in tests/helpers/http.js (no supertest).

Unit & integration (Vitest)

npm test                 # vitest run
npm run test:watch
npm run test:coverage

TypeScript declarations

In this repository, npm run check:types runs tsc --noEmit on tests/ts-smoke/ so index.d.ts stays aligned with exports. Consumers only need the published index.d.ts from npm.

End-to-end (Playwright)

Specs live under tests/e2e/; the runner starts a temporary app and exercises HTTP + DOM (Chromium by default). Use UI or headed mode when debugging flaky selectors.

npm run test:e2e           # playwright test
npm run test:e2e:ui      # Playwright UI
npm run test:e2e:debug
npm run test:e2e:headed

CI tip: run npm test and npm run test:e2e before release. For a quick local health pass on an app directory, use webspresso doctor (add --db to verify database connectivity).