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+
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.
.njk files under pages/ to GET routes; nested folders become path segments.[slug] for params, [...rest] for catch-all routes.pages/api/ with optional method suffixes (.get.js, .post.js, …).schema factory using Zod; validated payloads appear on req.input.pages/locales/ plus per-route overrides; t('key') in templates._hooks.js and per-route hooks for load/render pipeline control.fsy object (URLs, dates via dayjs, SEO helpers, assets, …).createApp({ clientRuntime: { alpine, swup } }) serves Alpine.js and swup v4 (Head + Scripts plugins) under /__webspresso/client-runtime/; SSR context exposes clientRuntime for layouts. See Client runtime (Alpine + swup).dependencies, register / onRoutesReady / onReady.createDatabase({ cache: true }), per-model defineModel({ cache: 'auto' | 'smart' | false, … }), db.cache (purge, invalidateModel, metrics); reads on Knex transactions bypass the cache.createAuth / quickAuth (webspresso/core/auth), createApp({ auth }), remember-me tokens, policies/gates, route middleware auth / guest. Details: Authentication.ormCacheAdminPlugin), optional spreadsheet import/export for admin (dataExchangePlugin — Excel export, CSV/XLSX import; see Data exchange), configurable HTTP redirects before file routes (redirectPlugin; see Redirect), SEO checker (dev), schema explorer (ORM metadata), Swagger UI + OpenAPI for HTTP APIs (dev by default), HTTP /health probe, multipart file upload (uploadPlugin + createLocalFileProvider), optional REST CRUD routes from ORM models (restResourcePlugin) with batched ?include= relations.index.d.ts (package.json types); import createApp, ORM, plugins from TypeScript. Add @types/express in your app for richer Express types.require('webspresso').kernel: in-process event bus (dispatch / publish), plugin shell, flow registry, minimal view resolver, simulated BaseRepository; not the SSR createApp. See Application kernel.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.
Database drivers, Faker, and dotenv are optional peers — install only what you use so production images stay lean.
| Package | Typical use |
|---|---|
pg | PostgreSQL via Knex |
mysql2 | MySQL / MariaDB |
better-sqlite3 | SQLite (local dev, embedded) |
dotenv | Load .env in development |
zod | webspresso new scaffold validates env with Zod (config/env.schema.js) |
@faker-js/faker | Seed scripts and factories |
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--install (-i) — runs npm install and npm run build:css, then optionally starts the dev server.package.json, creates webspresso.db.js, migrations/, models/, and DATABASE_URL in .env.example.config/load-env.js (dotenv chain: .env → .env.local → mode-specific files), config/env.schema.js (Zod), config/app.js (createApp options + optional db when webspresso.db.js exists). Dependencies: dotenv, zod.@faker-js/faker, seeds/index.js, and npm run seed.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
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()
| Variable | Default | Description |
|---|---|---|
NODE_ENV | development | Controls logging, plugin defaults (e.g. dashboard) |
DEFAULT_LOCALE | en | Fallback locale |
SUPPORTED_LOCALES | en | Comma-separated list (e.g. en,de) |
BASE_URL | http://localhost:3000 | Canonical URLs, sitemap, metadata |
DATABASE_URL | — | Connection 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.
Entry: webspresso (bin/webspresso.js). Use --help on any subcommand for flags.
| Command | Description |
|---|---|
new [project-name] | Scaffold a project (Tailwind, i18n, scripts; optional DB + seeds) |
page | Interactive: add SSR page + optional route config + locales |
api | Interactive: add API route + HTTP method |
dev | Development server (Node watch, optional CSS watch) |
start | Production server via server.js |
add tailwind | Add Tailwind, PostCSS, build + watch scripts |
db:migrate | Run pending migrations |
db:rollback | Rollback last migration batch |
db:status | Show migration status |
db:make <name> | Create migration file (optional --model scaffold) |
db:scaffold | Generate create-table migrations from all models/*.js (--only, --dry-run, --force) |
seed | Run seeds/index.js (Faker-based fake data) |
admin:setup | Emit admin_users migration for admin plugin |
admin:list | List admin users (needs migrated admin_users) |
admin:password | Reset admin password (interactive or -e / -p) |
audit:prune | Delete audit log rows older than --days (optional --table) |
doctor | Sanity 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 |
| Command | Flags / 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) |
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.
| Option | Role |
|---|---|
pagesDir | Required. Root of routes and templates resolution. |
viewsDir | Nunjucks views / layouts. |
publicDir | Static files (default public). |
db | ORM instance → ctx.db (SSR), req.db on pages/api (before middleware); getDb() / attachDbMiddleware elsewhere. |
plugins | Array of plugin factories or objects. |
middlewares | Named map: plain (req,res,next) or factories (opts) => (req,res,next). Routes use middleware: ['auth'] or tuples [['auth', { api: true }]]. |
helmet | true / false / custom Helmet options object. |
logging | HTTP logging (on by default in development). |
timeout | String duration ('30s') or false — uses connect-timeout. |
errorPages | Custom 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 }. |
assets | Asset 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()). |
clientRuntime | Optional { 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. |
auth | Optional 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',
},
});
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 },
});
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 / API | Description |
|---|---|
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 Return | Plugins returning a cleanup function from setup(app) or register(ctx) are automatically registered and executed in reverse registration order. |
const { createApp } = require('webspresso');
const { app } = createApp({
pagesDir: './pages',
server: {
shutdown: {
mode: 'graceful',
timeout: 10_000,
},
},
});
await app.listen(3000);
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.
app.onShutdown(async () => {
await database.close();
});
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();
};
},
};
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 / Feature | Default | Description |
|---|---|---|
compression | false | Opt-in compression flag or configuration object. Zero overhead when disabled. |
threshold | 1024 | Minimum response size in bytes before applying compression (default: 1 KB). |
level | 6 | Compression 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. |
const { createApp } = require('webspresso');
const { app } = createApp({
pagesDir: './pages',
server: {
compression: true, // Enables default 1 KB threshold with Brotli + Gzip
},
});
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;
},
},
},
});
/**
* 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(),
});
};
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.
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';
/**
* 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 });
};
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'] : [],
},
});
}
};
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 (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"
}
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 / path | Role |
|---|---|
kernel.createApp() | Registers event bus + view engine shell; distinct from SSR createApp(options) above. |
kernel.definePlugin / defineFlow | Small plugin descriptors and trigger → condition → sequential actions. |
kernel.BaseRepository | Simulated repository with beforeCreate / afterCreate / … events. |
core/kernel/*.js | Source 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.
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.
webspresso/core/auth)| Export | Role |
|---|---|
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 / verify | Bcrypt password helpers used by credentials adapters. |
createRememberTokensTable(knex) | Migration-style helper for the default remember_tokens shape (user_id, hashed token, expires_at). |
PolicyManager | Define definePolicy / defineGate; AuthManager exposes the same via definePolicy, defineGate, beforePolicy. |
createAppsession.secret on the auth config (or ensure env vars your app reads into that field). Without a secret, getSessionConfig() throws.auth is passed to createApp, the framework registers middlewares.auth and middlewares.guest to the session guards. Avoid defining your own middleware under those names if you use built-in auth.createApp returns authMiddleware (or null). Use it inside setupRoutes(app, { authMiddleware }) for custom login/logout routes or requireAuth({ api: true }) on JSON APIs.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');
});
},
});
req.auth)Bound per request after authenticate runs:
| Method | Role |
|---|---|
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 / authorize | Policy / gate checks (throws AuthorizationError on authorize). |
middleware: ['auth'] or ['guest'] in the sibling .js route config.pages/login.njk exists it may be registered before your setupRoutes handler and skip requireGuest. Prefer registering login in setupRoutes and keep the template only under views/, or omit pages/login.njk — see tests/e2e/auth.spec.js.requireAuth({ api: true }) returns 401 JSON instead of redirecting — use on /api/* handlers you mount manually.requireVerified({ field: 'email_verified_at' }) enforces an optional “verified” column pattern.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.
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))
})
});
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.
html / htmlFile: Inline HTML string or path to an HTML file rendered natively in the page DOM (full Tailwind CSS & script support, no iframe borders or scroll issues).url / iframeUrl: URL of an external page or web app embedded inside a clean <iframe> container.iframe: true: Force iframe container isolation for HTML files if desired.layout: false: Removes default layout and breadcrumbs for full-screen / custom canvas pages.scripts / styles: Array of external script URLs, script objects, or CSS links injected directly into the Admin Panel SSR template via registerScript / registerStyle.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' }
]
});
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*.
auth on the plugin should be the same AuthManager you pass to createApp({ auth }) if you want Active Sessions and revoke-token APIs (requires rememberTokens / remember-me). Omit auth if you only need CRUD on users via the repository — session admin endpoints then stay empty or return a “not enabled” message.admin_users, webspresso admin:setup) are still separate from site users; staff sign in at /_admin, visitors use your normal site routes.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 path | Route |
|---|---|
pages/index.njk | GET / |
pages/about/index.njk | GET /about |
pages/tools/[slug].njk | GET /tools/:slug |
pages/docs/[...rest].njk | GET /docs/* |
| File path | Route |
|---|---|
pages/api/health.get.js | GET /api/health |
pages/api/echo.post.js | POST /api/echo |
pages/api/users/[id].get.js | GET /api/users/:id |
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 () => {} },
};
module.exports = async (req, res) => { }handler, optional middleware (names from createApp({ middlewares })), optional schemaPer request: req.db (if configured) → Zod schema → middleware → handler.
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: [] });
},
};
| Key | Validates |
|---|---|
body | POST / PUT / PATCH JSON body |
params | Path params (:id, …) |
query | Query string |
response | Documentation only (not enforced at runtime) |
Validated values are on req.input; failures return 400 JSON { error: 'Validation Error', issues }.
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/plugins → dashboardPlugin | Dev-only route browser at /_webspresso |
sitemapPlugin | /sitemap.xml, /robots.txt, optional DB-driven URLs |
analyticsPlugin | GA4, GTM, Yandex, Bing UET, Facebook Pixel, verification meta tags |
siteAnalyticsPlugin | Self-hosted page views + admin charts |
adminPanelPlugin | CRUD admin SPA (Mithril), auth hooks, custom modules |
contentPlugin | Schema-driven CMS — content types & entries, public GET /api/content/:type/:slug, inline admin edit on SSR pages; see Content plugin |
dataExchangePlugin | Admin-only .xlsx export + CSV/XLSX import (/api/data-exchange/…); register after adminPanelPlugin |
ormCacheAdminPlugin | Admin UI for ORM cache stats / purge / per-model invalidate (requires adminPanelPlugin + createDatabase({ cache: … })) |
seoCheckerPlugin | Dev toolbar SEO audit (40+ checks) |
schemaExplorerPlugin | JSON schema of models + ORM components OpenAPI export |
swaggerPlugin | OpenAPI 3 for pages/api + Zod; Swagger UI (dev by default) |
healthCheckPlugin | GET /health probe (optional DB checks, custom path) |
redirectPlugin | Configurable 301/302/303/307/308 redirects in register() — runs before file-based SSR routes; see Redirect plugin |
uploadPlugin | POST multipart uploads (multer); default createLocalFileProvider; optional mimeAllowlist / maxBytes; pairs with admin settings.uploadUrl |
basicAuthPlugin | Zero-dependency RFC 7617 HTTP Basic Authentication with timing-safe comparison (crypto.timingSafeEqual) & named route middleware |
realtimePlugin | Framework-agnostic realtime layer (core/realtime) with WebSocket, SSE, and Socket.IO adapters, subscription registry & backoff reconnect |
restResourcePlugin | Opt-in REST CRUD per model (rest.enabled or plugin models whitelist); ?include= uses ORM eager load (single-level relations only; no nested a.b) |
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.
rules: from (string path or RegExp on req.path), to (path or URL), optional status, optional methods ('*' or list; plugin defaults to GET + HEAD only).preserveQuery (default true): append the request query when to has no ?.allowExternal (default false): allow http(s): and protocol-relative // targets.trailingSlash: 'strip' | 'add' | false — normalize path before matching; string rules also allow a loose /a vs /a/ match when this is false.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.
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.
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.
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.
GET / POST ${adminPath}/api/data-exchange/export/:model — body/query same semantics as built-in export (ids, selectAll, filters); response is an .xlsx file.POST ${adminPath}/api/data-exchange/import/:model — multipart field file; mode=insert|upsert, upsertKey (e.g. id or a unique column). Returns JSON summary with per-row errors.db, adminPath (default /_admin), maxRows, maxFileBytes.const { adminPanelPlugin, dataExchangePlugin } = require('webspresso/plugins');
createApp({
pagesDir: './pages',
db,
plugins: [
adminPanelPlugin({ db, path: '/_admin' }),
dataExchangePlugin({ db, adminPath: '/_admin' }),
],
});
Full detail: README — dataExchangePlugin.
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).
GET /api/content/:typeSlug/:entrySlug — only status=published; response { type, slug, data, meta }./_admin/api/content/types, …/types/:typeSlug/entries, …/entries/:id, …/types/:typeSlug/schema (inline edit form).content_types, content_entries — run webspresso db:migrate after enabling the plugin.db, adminPath, publicApiPath (default /api/content), inlineEdit (default true), cacheTtlMs (optional TTL; default in-memory until admin writes invalidate).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.
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.
Files inside services/ map directly to dot-separated service names and kebab/snake to camelCase aliases:
| File path | Service name | camelCase 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' |
// 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;
},
};
// 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 });
};
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.
zdb)Helpers wrap Zod with database column metadata (same surface as README — Schema helpers).
| Helper | Description | Options |
|---|---|---|
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 column | maxLength, unique, index, nullable |
zdb.text(opts) | TEXT column | nullable |
zdb.integer(opts) | INTEGER column | nullable, default |
zdb.bigint(opts) | BIGINT column | nullable |
zdb.float(opts) | FLOAT column | nullable |
zdb.decimal(opts) | DECIMAL column | precision, scale, nullable |
zdb.boolean(opts) | BOOLEAN column | default, nullable |
zdb.date(opts) | DATE column | nullable |
zdb.datetime(opts) | DATETIME column | nullable |
zdb.timestamp(opts) | TIMESTAMP column | auto: 'create'|'update', nullable |
zdb.json(opts) | JSON column | nullable |
zdb.array(itemSchema, opts) | ARRAY column (stored as JSON) | nullable |
zdb.enum(values, opts) | ENUM column | default, 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',
});
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.
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.
Global hook module:
// pages/_hooks.js
module.exports = {
onRequest(ctx) {},
beforeLoad(ctx) {},
afterLoad(ctx) {},
beforeRender(ctx) {},
afterRender(ctx) {},
onError(ctx, err) {},
};
Execution order (SSR)
onRequest → route onRequestbeforeMiddleware → middleware chain → afterMiddlewarebeforeLoad → load → afterLoadbeforeRender → Nunjucks render → afterRenderWebspresso 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.
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).
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.
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.
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.
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.
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
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 area | Examples |
|---|---|
| App | createApp |
| Router utilities | mountPages, filePathToRoute, scanDirectory, i18n helpers |
| Templates & assets | createHelpers, AssetManager, configureAssets |
| Plugins | PluginManager, createPluginManager |
| ORM | defineModel, createDatabase, zdb, generateNanoid, zodNanoid, … |
| Convenience plugins | schemaExplorerPlugin, swaggerPlugin, healthCheckPlugin, uploadPlugin, restResourcePlugin, adminPanelPlugin, contentPlugin, dataExchangePlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin |
| Content CMS core | content — createContentService, schema validation, render helpers (framework-agnostic) |
| TypeScript | index.d.ts (package.json types) |
src/server.js — createApp pipelineconnect-timeout when configured, and static file serving.register(ctx) (templates, middleware, helpers).configureAssets / createHelpers for fsy.mountPages from file-router.js to register SSR and API routes from disk.onRoutesReady on plugins so they can add routes or read the route table.onReady when the server starts listening.| Path | Responsibility |
|---|---|
src/server.js | createApp, Express stack, error pages, plugin orchestration |
src/file-router.js | Scan pages/, map files to routes, wire API handlers and SSR |
src/helpers.js | Nunjucks globals (fsy), asset URL resolution |
src/plugin-manager.js | Plugin 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.js | Zod 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.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
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).
npm test # vitest run
npm run test:watch
npm run test:coverage
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.
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).