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.

Stack: Express · Nunjucks · Knex · Zod · Node 18+

At a glance

Webspresso is an opinionated, batteries-included server-side toolkit: one CLI scaffolds projects, watches your pages/ tree to build Express routes, 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'. Optional @types/express improves typing for Express app / middleware.

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

New projects ship with Tailwind CSS, a starter layout, i18n (e.g. en/tr), and npm scripts. Pass --no-tailwind to skip Tailwind.

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
db:migrateRun pending migrations
db:rollbackRollback last migration batch
db:statusShow migration status
db:make <name>Create migration file (optional --model scaffold)
db:scaffoldGenerate create-table migrations from all models/*.js (--only, --dry-run, --force)
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) — interactive name/description, or --preset webspresso copies SKILL.md + REFERENCE-framework.md + REFERENCE-kernel.md into .agents/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
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 ~/.agents/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). Express is preconfigured with sensible security headers (Helmet), sessions when needed, static files, and the file router.

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 Helmet options object.
loggingHTTP logging (on by default in development).
timeoutString duration ('30s') or false — uses connect-timeout.
errorPagesCustom error page templates or handler functions: { notFound?: string | Function, serverError?: string | Function, timeout?: string | Function }. E.g. notFound: '404.njk' renders views/404.njk with { fsy, locale, isDev, url, method }.
assetsAsset manager configuration: { version?: string, manifestPath?: string, prefix?: string, publicDir?: string }. Controls query-string cache-busting (?v=1.2.3), Vite/Webpack manifest resolution (.vite/manifest.json), and CDN path prefixing for template helpers (fsy.asset(), fsy.css(), fsy.js(), fsy.img()).
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 Express routes after file routes / plugin onRoutesReady, before 404. ctx.authMiddleware when auth is set; 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: repository examples/alpine-swup-demo/ (npm install && npm run dev). Production Helmet CSP: allow script-src 'self' for /__webspresso/client-runtime/; if Alpine requires unsafe-eval for your version, adjust or use a CSP-friendly build.

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

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

Graceful Shutdown

Webspresso features native, framework-level Graceful Shutdown and Plugin Cleanup Lifecycle management. It coordinates SIGINT and SIGTERM signals, stops accepting new HTTP connections, drains in-flight requests, executes shutdown hooks, and disposes plugins in reverse dependency order.

Option / APIDescription
shutdown: { mode: 'graceful', timeout: 10_000 }Default production mode. Waits up to timeout ms for active requests to finish before force closing lingering connections.
shutdown: { mode: 'force', timeout: 1000 }Immediate connection termination. Recommended for development/watch environments to prevent hanging keep-alive sockets during reload.
app.onShutdown(async () => {})Register application-level cleanup callbacks (e.g. database disconnect, background queues).
await app.close()Idempotent programmatic shutdown method. Safely closes the server and executes all teardown hooks without calling process.exit().
Plugin Disposer ReturnPlugins returning a cleanup function from setup(app) or register(ctx) are automatically registered and executed in reverse registration order.

Example — Standard Configuration

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

const { app } = createApp({
  pagesDir: './pages',
  server: {
    shutdown: {
      mode: 'graceful',
      timeout: 10_000,
    },
  },
});

await app.listen(3000);

Development / Watch Mode (Force Close)

const { app } = createApp({
  pagesDir: './pages',
  shutdown: {
    mode: 'force',
    timeout: 1000,
  },
});

Note: mode: 'force' is generally not needed in production. It is designed for development and watch runners to prevent dangling keep-alive sockets from keeping the process alive.

Manual Application Teardown

app.onShutdown(async () => {
  await database.close();
});

Plugin Disposer Lifecycle

module.exports = {
  name: 'my-resource-plugin',
  async setup(app) {
    const resource = await createResource();

    // Returning a disposer automatically registers it in reverse teardown order:
    return async () => {
      await resource.close();
    };
  },
};

HTTP Compression

Webspresso features native, streaming-first HTTP Response Compression built directly on Node's native node:zlib module. It supports Brotli (br), Gzip (gzip), and Deflate (deflate) with full Accept-Encoding negotiation (including quality q values and wildcard *).

Option / FeatureDefaultDescription
compressionfalseOpt-in compression flag or configuration object. Zero overhead when disabled.
threshold1024Minimum response size in bytes before applying compression (default: 1 KB).
level6Compression level for Gzip/Deflate (0–9) and default quality for Brotli (0–11).
encodings['br', 'gzip', 'deflate']Preferred algorithm order. If runtime lacks Brotli support, automatically falls back to Gzip/Deflate.
res.compress(false)Route/response-level opt-out method for sensitive or pre-compressed responses.
filter(req, res)Custom filter predicate returning false to bypass compression.

Basic Usage

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

const { app } = createApp({
  pagesDir: './pages',
  server: {
    compression: true, // Enables default 1 KB threshold with Brotli + Gzip
  },
});

Detailed Configuration

const { app } = createApp({
  pagesDir: './pages',
  server: {
    compression: {
      threshold: 2048,           // 2 KB minimum
      encodings: ['br', 'gzip'], // Algorithm preference
      level: 6,
      filter: (req, res) => {
        // Exclude specific paths or headers
        if (req.path.startsWith('/downloads')) return false;
        return true;
      },
    },
  },
});

API Route Opt-out (pages/api/sensitive-export.get.js)

/**
 * GET /api/sensitive-export
 */
module.exports = async function handler(req, res) {
  // Disable compression for sensitive or attacker-influenced response data:
  res.compress(false);

  res.json({
    secret: 'confidential-token-value',
    generatedAt: new Date().toISOString(),
  });
};

Framework Exceptions & Error Handling

Webspresso features a centralized, lightweight, zero-dependency exception hierarchy inspired by Django's failure domain separation. Thrown synchronous and asynchronous exceptions are automatically caught by the global error boundary, normalized, and formatted as standardized JSON responses for API routes or styled HTML pages for web clients.

Exception Hierarchy & Imports

import {
  WebspressoError,
  HttpError,
  BadRequestError,           // 400
  UnauthorizedError,         // 401
  ForbiddenError,            // 403
  NotFoundError,             // 404
  MethodNotAllowedError,     // 405
  ConflictError,             // 409
  PayloadTooLargeError,      // 413
  UnsupportedMediaTypeError, // 415
  UnprocessableEntityError,  // 422
  TooManyRequestsError,      // 429
  ValidationError,           // 422 with { fields }
  ConfigurationError,        // Bootstrap & config validation
  PluginError,               // Plugin lifecycle & dependency issues
  SecurityError,             // 400 with masked internal details
  RequestAbortedError,       // Client socket disconnection
  RouteNotFoundError,        // 404 URL resolution error
  RouteGenerationError,      // Named route URL generation error
} from 'webspresso';

// Or via subpath import:
// import { NotFoundError, ValidationError } from 'webspresso/errors';

HTTP Exceptions in API Routes

/**
 * GET /api/users/[id]
 * File: pages/api/users/[id].get.js
 */
import { NotFoundError } from 'webspresso';

module.exports = async function handler(req, res) {
  const user = await req.db.getRepository('User').findById(req.params.id);

  if (!user) {
    throw new NotFoundError('User not found', {
      code: 'USER_NOT_FOUND',
    });
  }

  res.json({ user });
};

Validation Errors (422 Unprocessable Entity)

import { ValidationError } from 'webspresso';

module.exports = async function handler(req, res) {
  const { email, age } = req.body;

  if (!email || age < 18) {
    throw new ValidationError('Validation failed', {
      code: 'INVALID_INPUT',
      fields: {
        email: !email ? ['Email address is required'] : [],
        age: age < 18 ? ['Must be at least 18 years old'] : [],
      },
    });
  }
};

Custom Error Handler (app.setErrorHandler)

const { app } = createApp({ pagesDir: './pages' });

app.setErrorHandler(async (err, req, res, next) => {
  if (err instanceof ValidationError) {
    return res.status(422).json({
      success: false,
      errors: err.fields,
    });
  }

  // Rethrowing or returning nothing safely delegates to Webspresso default handler
});

Development vs Production Response Format

Development (NODE_ENV !== 'production')

{
  "status": 500,
  "error": "Internal Server Error",
  "message": "Cannot read properties of null",
  "code": "INTERNAL_SERVER_ERROR",
  "stack": "TypeError: Cannot read properties...",
  "cause": "..."
}

Production (NODE_ENV === 'production')

{
  "status": 500,
  "error": "Internal Server Error",
  "message": "Internal Server Error"
}

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 Express routing 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 cookie parser, express-session, and a per-request authenticate middleware that fills req.user and req.auth.

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 cookie, session, authenticate on 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).
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, authMiddleware } = createApp({
  pagesDir: './pages',
  viewsDir: './views',
  db,
  auth,
  setupRoutes(expressApp, ctx) {
    const am = ctx.authMiddleware;
    if (!am) return;
    expressApp.get('/login', am.requireGuest(), (req, res) => res.render('login.njk'));
    expressApp.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.

Admin panel auth (separate)

The admin panel plugin uses its own express-session stack and req.session.adminUser 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.

Unauthenticated visits to a protected admin route (e.g. /_admin/models/Post/edit/5) are redirected to /login; after a successful login the SPA returns to that saved route (sessionStorage). Session expiry during use triggers the same flow on API 401 responses.

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).

File upload fields — register uploadPlugin before adminPanelPlugin (or pass uploadUrl). In the ORM schema use zdb.file() for single files (stored as string URLs) or zdb.files() for multiple files (stored as a JSON array of URLs). The admin create/edit form renders a drag-and-drop uploader that POSTs to settings.uploadUrl and stores the returned URL(s). Image fields show a thumbnail preview (use ui.accept: 'image/*' or a URL with a common image extension). Optional column ui: { label, hint, accept, maxBytes }. For legacy single string columns use admin.customFields: { attachment: { type: 'file-upload' } }. Without uploadUrl single fields fall back to text inputs.

cover_image: zdb.file({
  maxLength: 2048,
  nullable: true,
  ui: { label: 'Cover', accept: 'image/*' },
}),
gallery: zdb.files({
  nullable: true,
  ui: { label: 'Gallery', accept: 'image/*' },
}),

Custom field renderers & relationships (registerFieldRenderer): Register custom components to format and edit fields in the browser. Registered renderers are automatically serialized and connected to the client side. The admin panel includes built-in renderers for relationships: belongsTo (dropdown selection) and hasMany (checkbox lists). For foreign key columns, use admin.customFields: { category_id: { type: 'belongsTo', relation: 'category', displayKey: 'name' } } to render a dropdown instead of a numeric ID field.

registry.registerFieldRenderer('rating', {
  display: (value, record) => m('span', '⭐'.repeat(value)),
  edit: (value, onChange, col) => m('input[type=number]', {
    value,
    oninput: (e) => onChange(Number(e.target.value))
  })
});

Custom HTML/URL Pages & EUIX Integration

The admin panel supports registering custom pages via Mithril components, external URLs, or direct HTML files (such as custom UI frameworks like EUIX Engine, Vue, or Alpine). HTML pages are rendered directly into the native DOM, automatically inheriting the Admin Panel's Tailwind CSS styling, dark mode theme, and scripts.

adminApi.registerModule({
  id: 'euix-module',
  scripts: ['https://unpkg.com/euixjs@latest/dist/EUIXEngine.umd.js'],
  styles: ['https://cdn.example.com/admin.css'],
  pages: [
    { id: 'euix-docs', title: 'EUIX Docs', path: '/euix', url: 'https://litepacks.github.io/euix/' },
    { id: 'euix-counter', title: 'EUIX Counter', path: '/euix-counter', htmlFile: './euix-counter.html' }
  ],
  menu: [
    { id: 'euix-docs', label: 'EUIX Docs', path: '/euix', icon: 'code' },
    { id: 'euix-counter', label: 'EUIX Counter', path: '/euix-counter', icon: 'zap' }
  ]
});

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' },
    }),
  ],
});

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 Express middleware, 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
webspresso/pluginsdashboardPluginDev-only route browser at /_webspresso
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
contentPluginSchema-driven CMS — content types & entries, public GET /api/content/:type/:slug, inline admin edit on SSR pages; see Content plugin
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 (multer); default createLocalFileProvider; optional mimeAllowlist / maxBytes; pairs with admin settings.uploadUrl
basicAuthPluginZero-dependency RFC 7617 HTTP Basic Authentication with timing-safe comparison (crypto.timingSafeEqual) & named route middleware
realtimePluginFramework-agnostic realtime layer (core/realtime) with WebSocket, SSE, and Socket.IO adapters, subscription registry & backoff reconnect
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 Express 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, multiple, maxFiles }) from the package root or webspresso/plugins. The handler accepts multipart field file (configurable), parses either a single file or multiple files depending on the multiple option (default: false), and returns JSON { url, publicUrl, key? } or an array of objects. In production, set an explicit MIME allowlist — trusting all types is risky.

Admin integration: ORM zdb.file() and zdb.files() columns appear in admin forms as single/multiple upload widgets when uploadUrl is set. Register order and model example:

const { createApp, uploadPlugin, adminPanelPlugin, defineModel, zdb } = require('webspresso');

const Post = defineModel({
  name: 'Post',
  table: 'posts',
  schema: zdb.schema({
    id: zdb.id(),
    title: zdb.string(),
    cover_image: zdb.file({ maxLength: 2048, nullable: true, ui: { accept: 'image/*' } }),
    gallery: zdb.files({ nullable: true, ui: { accept: 'image/*' } }),
  }),
  admin: { enabled: true },
});

const { app } = createApp({
  pagesDir: './pages',
  publicDir: './public',
  db,
  plugins: [
    uploadPlugin({ 
      path: '/api/upload', 
      local: { destDir: './public/uploads', publicBasePath: '/uploads' },
      multiple: false // true to support multiple files upload endpoint
    }),
    adminPanelPlugin({ db }),
  ],
});

See README File upload plugin for customFields: { type: 'file-upload' } on string columns and custom provider storage.

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.

Content plugin (schema-driven CMS)

Optional contentPlugin adds a lightweight, SQLite-first CMS: admins define content types (JSON field schemas) and entries from /_admin/content/types. Templates fetch entries via ctx.content in load() or the public JSON API. When an admin is logged in, editable regions show an Edit toolbar and a responsive popover editor anchored to the trigger (bottom sheet on small screens; vanilla JS — no Alpine required).

Field types: text, textarea, rich-text, number, boolean, image, url, date, select, repeater. Rich text is sanitized on save (same whitelist as admin Quill fields).

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

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

Template usage — in route load():

async load(req, ctx) {
  const hero = await ctx.content.getEntry('hero', 'homepage');
  return { hero: hero?.data, heroMeta: hero?.meta };
}

In Nunjucks, wrap fields for inline edit (admin-only overlay):

{% raw %}

{{ fsy.content.editable(hero.headline, { entryId: heroMeta.id, typeSlug: 'hero', field: 'headline' }) | safe }}

{{ fsy.content.editable(hero.body, { entryId: heroMeta.id, typeSlug: 'hero', field: 'body' }) | safe }}

{% endraw %}

The block wrapper places the Edit toolbar above the content; the popover opens anchored to that button (bottom sheet on viewports under 640px).

Core module (framework-agnostic): require('webspresso').content exports createContentService, schema helpers, and render utilities. Contract tests: tests/integration/content-contract.test.js.

Services Layer

The Services Layer provides a lightweight, framework-agnostic abstraction to organize business logic, multi-step mutations, and queries under services/. Services are automatically discovered from the filesystem and can be invoked from SSR data loaders (ctx.service()), API handlers (req.service()), CLI tasks, background workers, or nested services.

File-based Auto-Discovery

Files inside services/ map directly to dot-separated service names and kebab/snake to camelCase aliases:

File pathService namecamelCase alias
services/user/get.js'user.get'
services/user/reset-password.js'user.reset-password''user.resetPassword'
services/order-items/get-by-id.js'order-items.get-by-id''orderItems.getById'

Service Definition

// services/user/create.js
const { z } = require('zod');

module.exports = {
  // 1. Declarative Zod validation
  schema: z.object({
    email: z.string().email(),
    name: z.string().min(2),
    role: z.enum(['user', 'admin']).default('user'),
  }),

  // 2. Declarative Auth & RBAC guard
  auth: 'admin', // true | 'admin' | ['admin', 'manager'] | (user, ctx) => boolean

  // 3. Execution timeout in ms
  timeout: 5000,

  // 4. Automatic ACID transaction propagation across nested calls
  transaction: true,

  // 5. In-memory response memoization
  cache: {
    ttl: 60000,
  },

  async handler(input, ctx) {
    const { email, name, role } = input;
    const { db, trx } = ctx;

    const user = await db.getRepository('User').create(
      { email, name, role },
      { trx }
    );

    return user;
  },
};

Unified Invocation & Composition

// From SSR Page Loader (pages/users/[id].js)
module.exports = {
  async load({ req, ctx }) {
    const user = await ctx.service('user.get', { id: req.params.id });
    return { user };
  },
};

// From Express API Route (pages/api/users.js)
module.exports = async function (req, res) {
  const newUser = await req.service('user.create', req.body);
  res.status(201).json({ data: newUser });
};

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 }),
  ],
});
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: { version, manifestPath, prefix } }) supports static version strings (?v=1.2.3), CDN prefixes, and Vite/Webpack-style build manifests so fsy.asset(), fsy.css(), and fsy.js() seamlessly output cache-busted URLs and HTML tags.

In-browser dev tools

Enable dashboardPlugin() to get a route inventory at /_webspresso (development only by default). The SEO checker plugin adds a dev-only panel with many automated HTML/metadata checks. These tools stay out of production unless you explicitly force them on.

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.

Request flow (conceptual)

Incoming HTTP traffic passes through Express middleware registered by the framework and 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
    │
    ▼
┌─────────────────────────────────────────┐
│ Express stack: cookie/session, helmet,  │
│ 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.

For a C4-style, feature-set map (containers, plugins, extension points, maintenance protocol), see docs/ARCHITECTURE.md in the repository.

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, contentPlugin, dataExchangePlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin
Content CMS corecontentcreateContentService, schema validation, render helpers (framework-agnostic)
TypeScriptindex.d.ts (package.json types)

src/server.jscreateApp pipeline

  1. Build the Express app and apply Helmet (CSP enabled in production; relaxed in dev for hot editing).
  2. Attach sessions, parsers, connect-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, Express stack, error pages, plugin orchestration
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/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
core/              # ORM, auth, Zod compile/apply
plugins/           # first-party plugins
src/               # server.js, file-router, helpers, plugin-manager
utils/             # shared utilities

Development & testing

The framework repository runs Vitest for fast unit and integration tests (CLI, ORM, routing, 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, data exchange admin API routes, and content CMS API contracts (tests/integration/content-contract.test.js).

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). Content plugin UI flows and step screenshots: tests/e2e/content.spec.js (writes PNGs to tests/e2e/screenshots/content/). 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).