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, …).dependencies, register / onRoutesReady / onReady./health probe.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.
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 |
@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.@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/
├── 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 # createApp() entry
| 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 |
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) |
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) for Cursor and similar tools — interactive name/description, or use --preset webspresso to install the bundled Webspresso reference skill under .cursor/skills/ |
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 ~/.cursor/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 referenced from route configs (middleware: ['auth']). |
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 | notFound, serverError, timeout — handler fn or template path. |
assets | Version string or manifest path for cache-busted fsy.asset() URLs. |
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',
},
});
| 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 |
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) |
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.
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',
});
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 }) supports a static version string or a Vite/Webpack-style manifest so fsy.asset() resolves hashed filenames.
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 so consumers rarely import deep paths.
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, adminPanelPlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin |
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 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, and the SEO checker plugin.
npm test # vitest run
npm run test:watch
npm run test:coverage
Specs live under tests/e2e/; the runner starts a temporary app and exercises HTTP + DOM (Chromium by default). Use UI or headed mode when debugging flaky selectors.
npm run test:e2e # playwright test
npm run test:e2e:ui # Playwright UI
npm run test:e2e:debug
npm run test:e2e:headed
CI tip: run npm test and npm run test:e2e before release. For a quick local health pass on an app directory, use webspresso doctor (add --db to verify database connectivity).