Why ZenJS?

ZenJS is an open source Deno-based framework which dramatically simplifies the process of building multipage web applications with SPA-like interactivity.

Rather than requiring a separate server-side API app, ZenJS is a single server-rendered app with built-in tools for handling interactivity with the ZenJS "action loop".

Server-rendered templates

By working with the web, keeping application state on the server, and sending pure HTML down the wire, our application becomes much easier to work with. The ZenJS action loop makes the user experience very SPA-like with no clunky full-page reloads.


  • Starter website with mobile menu
  • Starter CSS with fluid typography/spaces, form styles, and more
  • File-based routing with "merge" mode for smooth navigation
  • Robust templating with Nunjucks
  • Action loop for client/server interactivity
  • MongoDB driver with custom validation
  • Server-side sessions
  • Admin system with users and authentication
  • Server-side Twind for full Tailwind support
  • Dev mode instant live reloads with no build step

Code example

Let's look at some actual code that might be used to add an item to the shopping cart.


<button z-click="cart.addToCart" z-payload="123">Add To Cart</button>

<aside id="cart">
  {% for item in cartItems %}
    <div>Product: {{ item.name }} {{ item.price }}</div>
  {% endfor %}
  • When the button is clicked, a request will be sent to the addToCart action on the server.
  • The addToCart action will do some processing, then render the #cart element as its response.
  • The cartItems array will be looped through and rendered using Nunjucks.
  • The client-side JavaScript will receive the response and morph the updated #cart element into the current page.

That's the ZenJS action loop in a nutshell. It's a simple, elegant way to add interactivity to your web pages.

Quick start


1. Install Deno

Mac: brew install deno

Linux: curl -fsSL https://deno.land/x/install/install.sh | sh

Then check that it works with deno --version.

2. Get the ZenJS Starter Kit

The ZenJS Starter Kit contains a boilerplate ZenJS web app with responsive web templates and an admin system.


Hello world!

Unzip the starter kit and cd into the directory. Then run: deno task dev.

  • The boilerplate app should now be running at localhost:8080.
  • Edit the templates in the pages/ directory to say "Hello world!" or whatever you like.
  • While in dev mode, your changes will be instantly morphed into the browser with no refresh and without even affecting current scroll position.
  • Tailwind classes can also be edited and morphed in real-time thanks to Twind.

Install MongoDB locally or use a service like Atlas

ZenJS can be used with or without MongoDB. For projects not requiring a database, ZenJS is still a great way to quickly get a Deno-based site up and running with a basic responsive boilerplate, Nunjucks server side templates, the actions loop, server-side sessions, and the many other features included with the ZenJS Starter kit.

If using a database, the easiest way is to sign up for a free MongoDB Atlas account, then save the MongoDB URL to the .env file in the root of your app.

Alternatively, you can install a local instance of MongoDB and use localhost:27017 as the MongoDB URL in .env.


How it works

Starting with a simple Deno application running an Oak web server (similar to Koa or Express), the ZenJS library is imported to handle requests for "pages" and "actions".

This is a key concept. Most web applications tend to have "pages" with an initial state, then "actions" the user can take to interact with that page. ZenJS is built from the ground up to support this behavior.


Pages are server-rendered templates made available with file-based routing. So a GET request for /about-us would be routed to /pages/about-us.njk. The .njk extension indicates that our templates are written in the Nunjucks templating language. If you've used Jinja2, or Twig, or even Vue or Svelte templating, it should feel pretty familiar. Remember, these templates are rendered on the server and sent down the wire as plain vanilla HTML.


It's very common in web applications for users to take some "action" on the client-side which triggers a server-side action and results in part of the DOM being updated on the client side. This "action loop" is elegantly handled with built-in tools provided by ZenJS. For example, by simply adding a "z-tag" attribute like z-click="cart.addToCart" to a button element, the zen.js client-side JavaScript included in the starter will add an event listener to that button which sends an AJAX fetch request to the server when clicked. An addToCart action method defined in the carts.js module can be defined to do server-side processing and respond with HTML elements to be morphed into the current page. This is the heart and soul of ZenJS. See "Action loop" below.


Z-Tags are used to declaratively add interactivity to your HTML. You can trigger a server-side action by adding an attribute like z-<event> and assigning a value of "<moduleName>.<methodName>". Most standard DOM events are available including z-click, z-mouseover, z-submit, and others. There's also a z-merge tag for using "merge" behavior so that only the body element is swapped out for smoother navigation. There's even z-active which accepts a Boolean argument for whether to add .active the element's classlist. See Z-Tags below to learn more.

Action loop

ZenJS strives to make client/server interactivity as simple as possible. For example, when a user clicks an "Add To Cart" button in a typical SPA app, you might normally set up the fetch request, then create an endpoint on the server to process the action and generate a response. Then back on the client side, your response handler would receive the result and parse it into the DOM. This is a common enough pattern that it makes sense to automate this loop. ZenJS accomplishes this by bundling a small JavaScript file in the starter kit which scans your DOM for z-<event> attributes and attaches event listeners so that when the specified event is triggered, the loop is processed like this:

  1. A fetch request is sent to: POST /@/<moduleName>?<methodName>.
  2. If a matching action module and actions method is found on the server, it will be executed.
  3. The actions method will typically do some server-side processing, then render at least one DOM element. For example: ctx.render("#sidebar");.
  4. The client side JavaScript that made the AJAX request will receive the response and update the element in place using the amazing Morphdom library.

This makes it very simple to add interactivity to your web pages without having to write a bunch of boilerplate code and without having to worry about implementation details of how the action loop works. Only specified elements are updated in place with no page refresh resulting in a smooth, SPA-like user experience.

Deno primer

If you're new to Deno, it may be helpful to briefly cover some of the key differences between Deno and its predecessor, Node. Ryan Dahl, the creator of Node, went on to create Deno as a modern successor to Node. So Deno is very simlar in most regards. They are both JavaScript runtimes built on top of the V8 engine – but there are a few differences worth knowing about.

Coming from Node?

If you've only used Node before, don't worry! Coming from Node to Deno is a breeze. Almost everything you've learned in Node will apply – and you'll soon learn to love Deno's smarter approach to package management and everything else that makes Deno a next-gen JavaScript runtime.

Package management

You may have noticed that in the above steps to "Hello world", there's no npm install or similar command to install dependencies. This is because with Deno, dependencies are loaded directly via a URL or local filepath.

The convention (which is also used in the starter kit) is to create a file called deps.js which imports dependencies and then exports them so that a single version is available throughout the app. To upgrade a module to a newer version, you only need to change the version number for that module in deps.js and restart your app. Have a look at deps.js to see how simple this is.

Local cache

Deno caches dependency modules locally so that after the first time they're pulled down from the net, they'll load much faster from that point forward. Rather than having a node_modules directory in each app, the system user has just one Deno cache for the whole system (similar to pnpm for Node). To see where Deno cache is located on your system, you can use the command deno info.

NPM modules

Deno recently introduced stable support for NPM. This means that with rare exceptions, any module available for Node can also be used with Deno. Learn more here.


Deno provides support for tasks to be defined in deno.json. This is very similar to Node support for scripts defined in package.json. Have a look at the deno.json file included with the starter to see the tasks included with ZenJS.

Deno API

Besides just parsing standard JavaScript, both Node and Deno include additional APIs for things like filesystem interaction. Deno tries wherever possible to implement APIs that are identical to the browser version. For example the fetch API is identical.

For APIs which have no browser equivilent, these are namespaced under the Deno object. So for example to read the current working directory in Node, you might use __dirname or process.cwd(). In Deno, this would be Deno.cwd().


Deno is a JavaScript and TypeScript runtime. There is no build step required for either. To use TypeScript in Deno, you simply use the .ts extension instead of .js. That's it. By default, ZenJS uses .js files for server-side code such as action modules and schemas. If you'd rather use TypeScript just use a .ts extension and you're good to go. ZenJS with Deno under the hood is happy to parse plain JavaScript or TypeScript.

Next steps

If want to take a deeper dive and learn more about Deno, the official manual is a great place to start.

Using ZenJS

The ZenJS server-side module is included in the starter kit in the top level zen/ directory. This module works in tandem with the client-side file /static/script/zen.js to provide all the features and functionality of ZenJS including file-based routing, Nunjucks templating, the action loop, and so on.

Because the ZenJS module is included in the starter kit, nothing is hidden away in a black box. You can see exactly how it works and even modify it to suit your needs. Just keep in mind that if you do modify the ZenJS module, you won't be able to use deno task update to update to the latest version of ZenJS.


All GET requests are routed to Nunjucks templates found in the pages/ directory. The routing system will first try to match directly, for example to /pages/books.njk, and then to an index file like /pages/books/index.njk. Requests may also include route parameters, querystrings, and hashes. These values will be available in actions modules as properties of the ctx object discussed later in this document.

Why just GET requests?

ZenJS implements a pattern that distinguishes between pages and actions. Pages are templates which are rendered and sent to the browser. Actions are used to execute functions on the server and update the DOM without a full page refresh. This is done by sending a POST request to an action module which then returns one or more DOM elements to the client. Since interactions with the server are handled via actions, there's no need to route requests for other HTTP methods to pages.

Hiding pages from the router

For files and directories in pages/ such as layouts or partials templates which should not generate a route, prefix the file or directory name with an underscore as seen in _layout.njk. When the routing system generates routes, it will ignore any files or directories starting with an underscore.

Dynamic routes

Dynamic routes are supported by using the + prefix in the directory name. For example /pages/books/+id/index.njk will match /books/123 and /books/abc but not /books/123/456. The value of the dynamic route parameter will be available in the ctx object as ctx.params.id.

404 and 500 error templates

When an error is encountered, the routing system will look for the nearest sibling or parent error file. For example, a request for /books/no-such-book where no template exists is a 404. So ZenJS looks for the error template at /books/no-such-book/_404.njk. If none is found there, ZenJS will walk up the directory tree until it finds a _404.njk or reaches the top of the project and uses a default 404 template. The same is true for 500 errors and also when errors are triggered programatically – for example with ctx._404().

Static files

Static files are served from the static/ directory. The static/ directory is the root of the static file server. So for example, to serve the file /static/images/logo.png, the URL would be /images/logo.png.


Pages are parsed with Nunjucks for robust templating with features like extending or including other templates, including dynamic data in templates, loops and conditionals, and much more. The syntax is very similar to Jinja2, Twig, or Handlebars using the same double curly braces for variables and tags. Templates are cached in memory for optimal performance.

You can read the full Nunjucks docs – but here are some of the most common features likely to be used in ZenJS:

Extend a layout

In /pages/index.njk found in the starter, you can see that block and extends are used to wrap this page in a layout. Here's the pattern to do that:


    {% block content %}
      <!-- child content appears here -->
    {% endblock %}


{% extends "_layout.html" %}

{% block content %}
  <h1>Hello world!</h1>
{% endblock %}

Include a partial

To include a partial template, use the Nunjucks include tag. For example, to include the partial template /pages/_sidebar.njk in the layout template above, you would use the following:

{% include "_sidebar.njk" %}

Remember that the path to partials is relative to the pages/ directory and not the template that includes it.

Notice that in both exampes above, the filename starts with an underscore to prevent a route being created for direct access to these files.

Loop through an array

To loop through an array, use the Nunjucks for tag. For example, to loop through an array of books and display their titles, you could use the following:

{% for book in books %}
  <h2>{{ book.title }}</h2>
{% endfor %}


To conditionally display data in a template, use the Nunjucks if tag. For example, to display a book's description only if it exists, you could use the following:

{% if book.description %}
  <p>{{ book.description }}</p>
{% endif %}


All of Nunjucks templating features including built-in filters are available. See the Nunjucks docs for more information. You can also add your own custom filters and tags by adding them to the config/nunjucks.config.js.


Markdown is supported in Nunjucks templates using a custom markdown tag which is included in the starter kit and can be used like this:

{% markdown "privacy-policy.md" %}


Beyond the functional aspects of building an app, ZenJS also includes built-in patterns and boilerplate code for managing your application style.

  • Global zen.css for global styles including site layout, typography, and common elements
  • CSS Variables for colors, font-sizes, and spacing
  • Fully responsive typography and spacing using Utopia
  • Twind library included for styling with Tailwind utility classes

Recommended approach

It is strongly recommended to use the global zen.css only for site-wide styles such as typography and form elements and to use Tailwind classes for "one-off" styles such as the padding around an image in a hero section.

In /static/css/zen.css, global variables are set for colors, font-sizes, and spacing. Classless styles are created for common elements like table, ul, and form elements like fieldset and input. Only truly global styles should be added to this file.

For styles which are specific to a single element, use Tailwind utility classes. For styles which are specific to a single template but are used multiple times within it, use a <style> tag in that template.

Global styles

The global zen.css file defines CSS variables and styles for elements globally. It is divided into named sections:

  • Variables
  • Reset
  • Layout
  • Typography
  • Lists
  • Tables
  • Forms
  • Buttons
  • Other

Fluid typography and spacing

The Utopia Fluid Responsive Design System is included for fluid typography and spacing. This allows for font sizes and space values for things like margins and padding to scale up and down according to the viewport size. This is extremely simple to implement and adjust and ensures that font-sizes and spacing are always in a harmonious ratio across all screen sizes.

See the "Variables" section of zen.css and fluid.css for more information.


The zen.css file includes rules for common layout related elements such as html, body, main, section, .wrapper, and fluid-wrap. The actual style of the layout, things like header, nav, and footer styles are defined in _layout.njk.

html and body set basic viewport parameters and define the body as a flexbox container.

main is typically used to wrap page content files (see /pages/index.njk).

section is used to wrap blocks of content on a page is constrained to .wrapper width by default. In case you want a page section to span full width, just add a .w-full class.

.wrapper class can be added to any element to make it behave like a section tag, constraining its width, applying fluid padding, and centering horizontally.

fluid-wrap is used to wrap blocks of content on a page that contain flex children with a min-width. Child elements will grow and shrink according to flex properties but will never be smaller than their min-width, wrapping when necessary. This is useful in responsive forms. Just wrap two or more elements in a fluid-wrap and they will wrap when necessary.

Form elements

Forms are the heart of almost every web app. The zen.css file includes styles for form, fieldset, legend, label, input, textarea, and select. If using the basic form elements provided by ZenJS, you should wrap each element and its corresponding label in a fieldset along with any .form-note or other elements related to this field.

By following this basic pattern, forms will be styled beautifully and consistently throughout the app with no additional work. However, you can always customize zen.css as needed.

Here is a typical ZenJS form used for signing in:

<form z-submit="users.signIn">
  <fieldset z-invalid="{{ invalid.username }}">
    <label for="username">Username</label>
    <input type="text" id="username" name="username" />
  <fieldset z-invalid="{{ invalid.password }}">
    <label for="password">Password</label>
    <input type="password" id="password" name="password" />
  <button type="submit">Submit</button>

Notice that every form element is wrapped in a fieldset. This is important for styling and consistent form layout.

Radios and checkboxes

Use a single fieldset for a group of radios or checkboxes. The fieldset should have a class of radios or checkboxes and each input should be wrapped in its own label tag.

<fieldset class="radios">
    <input type="radio" name="style" value="modern" />
    <input type="radio" name="style" value="traditional" />

Tailwind utility classes

ZenJS includes the Twind library for styling with Tailwind utility classes. Twind scans your template for Tailwind classes and generates CSS for them. In the head section of _layout.html, you can see that Twind is imported and the generated styles are injected into the page. You can use any Tailwind classes including responsive classes like md:hidden and pseudo classes like hover:bg-blue-500 as well as classes with arbitrary values like m-[2rem]. The Twind config is also extended to include colors and fluid typography and spacing defined in zen.css. See twind.config.js for more information.

Helper classes

The zen.css file includes a few helper classes for common tasks:

  • .wrapper - constrain an element to same limits as section
  • .fade-in - fade in an element with a transition
  • .checkboxes - style a fieldset as a group of checkboxes
  • .radios - style a fieldset as a group of radios
  • .row - added to checkboxes or radios fieldset to display them in a row
  • .invalid - Added to a form element, fieldset, or form to display invalid styles
  • .form-note - Add to a fieldset to display a note within a fieldset
  • .center - Center children horizontally and vertically
  • .debug - Add a checked border to an element for debugging layout
  • .revert - Revert an element and its children to its default style

Style guide

The boilerplate starter includes a style guide page at the route /style. This allows you to see your global variables and styles in action. You can edit zen.css and see your changes here in real time. This is a great way to experiment with different colors, fonts, and spacing and see the results immediately. Here is the style guide for this site.


Z-Tags are custom HTML attributes like z-click or z-merge which add functionality to pages. Most Z-Tags are processed by the client-side zen.js script to add interactivity to templates and to update the DOM without a full page refresh (the ZenJS action loop). A few Z-Tags like z-active are processed on the server-side after Nunjucks parses the template.


The z-init attribute is used to initialize a page. For example, to initialize a page with a list of books, you could add the z-init attribute to the main element:

<main z-init="books.list">
  <!-- page content -->

This will find the action method named list as a property of the init object in /actions/books.js and execute it. The init method response will normally be a full HTML page which will be sent to the browser and rendered.

In many cases, you only need one init function. For those cases, just define a single init() function in the actions file and supply just the module name to z-init like this:

<main z-init="books">
  <!-- page content -->

Stacking multiple z-init tags

It is sometimes useful to stack multiple z-init tags in a template. For example, you might want to add a z-init="checkStatus" to a layout for a section of your app. After all Nunjucks extends and includes are parsed and the template is built, z-init tags will be processed in the order they appear in the document. So in this example, the checkStatus method will be called first. In order to advance to the next z-init in the stack, use ctx.next() as explained in the ctx section below.


The z-merge attribute can be used so that navigation between pages is handled by the client-side zen.js script using an AJAX request to swap out the body of the page and morph the head of the page. The result is a seamless navigation experience without a full page refresh. The starter includes a z-merge attribute on the html element in the layout template:

<html lang="en" z-merge>

Typically, z-merge is added to a top level element such as html or body and will be applied to child anchor tags with href values which are relative links and not downloads.

z-merge can be selectively disabled by adding the z-merge="false" attribute to a child element. Then all children of this element will behave normally and will not be merged.

IMPORTANT: Because z-merge is really just an AJAX call to replace a chunk of the DOM (the body element specifically), we need to take care when using <script> tags within a page. The normal, expected JavaScript behavior when updating the DOM with content containing a <script> tag is for the tag to be added as expected but for the actual script within the tag to not execute. The client side zen.js handles this by explicitly removing and re-adding all <script> tags within the body element causing them to execute again. However, be cautious not to do things like document.addEventListener(<event>, <function>) because this will execute every time you navigate to the page causing multiple event listeners.

Also declaring global scope variables like const foo="bar" can cause JS errors on subsequest page visits because reexecuting this line of code effectively attempts to reset the value of a const. The simple workaround for this is to wrap your code in a self-executing function (IIFE) so that scope is always local to the function which is destroyed and recreated each time the page is merged. Using the Cash (tiny JQuery clone) library included with the default ZenJS Starter Kit, we can do that like this:

  $(function() {
    const foo = "bar";


The z-target attribute can be used to specify a target element for a merge. This allows you to navigate to a new page with a new URL but specify only a single element or a list of elements to be merged into the current page. For example, while on the /books list page, you might want to navigate to the /books/1 detail page and merge only the #book-detail element into the current page:

<div id="books-list">
  <a href="/books/123" z-target="#book-detail">Book 123</a>
  <a href="/books/456" z-target="#book-detail">Book 456</a>

<div id="book-detail">
  <!-- book detail content -->

By default, z-merge will morph the head element of the page and replace the body. Using z-target will morph the head and replace only the specified elements. Note that z-target can be added to anchor tags or any other element with a z-&lt;action&gt; attribute.


The z- prefix followed by the name of a standard DOM event such as click or submit is used to trigger a ZenJS action loop. For example, to trigger an action when a button is clicked, you would add the z-click attribute to the button element:

<button z-click="users.signOut">Sign Out</button>

When the button is clicked, the action method named signOut in /actions/users.js will be executed. Typically, the action method response will include one or more HTML elements which will be morphed into the DOM by the client-side zen.js script.


The z-ready attribute is used to trigger an action when the element is fetched either by direct page request or z-merge request. This allows you to load the page as quickly as possible and then load additional elements which may take longer due to additional database queries or other processing. For example, you could add a z-ready attribute to #books-list to trigger an action on page load to fetch the list of books and re-render the element and replace the spinner.

<div id="books-list" z-ready="books.list">
  {% if books %}
    {% for book in books %}
      <a href="/books/{{ book._id }}">{{ book.title }}</a>
    {% endfor %}
  {% else %}
    {% include "_/spinner.njk" %}
  {% endif %}

Note that a z-ready action cannot trigger itself causing an infinite loop. When a z-ready action is triggered, its attribute is prepended with "✓". For example z-ready="books.list" will become z-ready="✓books.list" and then this action will not be triggered again.

An action may also be prefixed with @ to indicate that it should be triggered only one time. For example, z-ready="@books.list" will trigger the action once, then the action name will be changed to z-ready="✓books.list" and the action will not be triggered again.


The z-payload attribute is used to pass data to an action. For example, to pass the value of a book's id to an action, you would add the z-payload attribute to the form field:

<button z-click="books.delete" z-payload="{{ book._id }}">

When the form is submitted, the value of book._id will be passed to the action method as a property of the ctx object which is discussed in more detail later in this document.


The z-submit attribute is used to trigger an action when a form is submitted. For example, to trigger an action when a form is submitted, you would add the z-submit attribute to the form element:

<form z-submit="books.create">
  <input type="text" name="title" placeholder="Title">
  <input type="text" name="author" placeholder="Author">
  <button type="submit">Create</button>

When the form is submitted, the action method named create in /actions/books.js will be executed.

While this is also a z-<event> attribute, it is discussed separately because its behavior is slightly different:

  • It can only be applied to a form element
  • Its payload is the complete form data


The z-active atttribute receives a boolean value and adds the active class to the element if the value is true. Otherwise, the active class is removed. This is useful for adding a class to a navigation link to indicate which page is currently active.


<a href="/books" z-active="{{ $meta.path.startsWith('/books') }}">Books</a>

As a shortcut for the above, you can use the z-active attribute with the value @. This will add the active class if the current window.location.href starts with the value of the href attribute of this element.


<a href="/books" z-active="@">Books</a>

This processing is done on the server-side after Nunjucks parses the template.


The z-invalid attribute receives a string which should be empty if the field is valid and should contain an error message if the field is invalid.

The client-side zen.js script will then process elements with z-invalid attributes. When a field's z-invalid attribute is a non-empty string, the .invalid class is added to that element and its parent <fieldset> element. The content of the z-invalid attribute (the error message) is used to populate <z-invalid> custom HTML tag if present within the same parent <fieldset>.

When a form contains one or more z-invalid elements with a non-empty string value, the form itself will also receive the .invalid class and the form's submit button will be disabled.

Additionally, an elment's z-invalid value will be cleared on focus and form re-parsed adding/removing .invalid classes and enabling or disabling the submit button accordingly. This allows the user to clear the error while editing the field. See validation for implementation details.


This attribute can be used a shortcut rather than adding a z-blur attribute on every field within a form to trigger a field validation. Instead add z-validate="<module.method>" to the parent form element to activate automatic validation of any fields within the form containing a z-invalid attribute when they are blurred.

For example:

<form z-submit="books.create" z-validate="books.validate">

In this case, all child fields with a z-invalid attribute will trigger the books.validate action when blurred.


The z-disabled attribute receives a boolean value and adds the disabled attribute to the element if the value is true. Otherwise, the disabled attribute is removed. This is useful for disabling a form input based on server state.


The z-checked attribute receives a boolean value and adds the checked attribute to the element if the value is true. Otherwise, the checked attribute is removed. This is useful for checking a checkbox based on server state.


The z-selected attribute receives a boolean value and adds the selected attribute to the element if the value is true. Otherwise, the selected attribute is removed. This is useful for selecting an option in a select element based on server state.


This is a special custom HTML tag (unlike the other "z-tags" which are actually attributes applied to an HTML tag).

It is very common to have a date value returned from a database used to populate a template value like:

Order Date: {{ order.date }}

A custom Nunjucks filter could be used for this purpose; but because Nunjucks is processed on the server, that means all dates would be formatted according to UTC time or the server's local time rather than the user's local time. By wrapping a time in <z-date> tags, the date will be formatted on the client side and offset to the user's local time.

The <z-date> tag works with stringified JS Date objects like "2023-08-24T00:28:52.034+00:00" or with UTC timestamp integers like "1692836840197". When saving timestamps to the database, it is recommended to use a new Date() constructor which saves the date as a BSON Date object in MongoDB.

Additionally, a format attribute can be passed to specify one of the following preset formatting types:

  • date (default) ex: "Thursday, June 15, 2023"
  • time ex: "3:02 PM"
  • datetime ex: "Thursday, June 15, 2023 at 3:02 PM"
  • relative ex: Today at 3:00 PM"

The relative option is similar to datetime but will replace the date with strings: "Today", and "Yesterday" when appropriate.


The ZenJS action loop starts with a z-<action> attribute which triggers an AJAX call to a server-side action method. The action method is responsible for processing the request and returning a response which is used to update the DOM.

For example, to delete a book, we might have a button with the z-click attribute:

<button z-click="books.delete" z-payload="{{ book._id }}">

When the button is clicked, the action method named delete in /actions/books.js will be executed. The action method receives a ctx object with several methods and properties which can be used to process the request and return a response.

The /actions directory is where you will create your action modules. Each module may export an init function or object, an actions object, and/or an auth function. For example, the /actions/books.js module might look like this:

import db from "/deps.js";

export const init = async (ctx, $) => {
  $.books = await db.collection("books").find().toArray();

export const actions = {
  delete: (ctx, $) => {
    await db("collection").delete(_id: ctx.payloadId);
    $.books = await db("collection").find().toArray();

In this example, we import the db object and export an init function and an actions object containing one action method delete.

  • The init function is executed when the page loads assuming that somewhere on the page, there's an attribute like z-init="books".
  • The delete action method is executed when the button in the example above is clicked because our sample HTML has button element with z-click="books.delete".

Every action method receives two arguments: ctx and $. The ctx argument is a Context object used to process the request and return a response.

The $ argument is a reference to the ctx.$ object which holds server-side state. Setting any property on the $ object will become available to Nunjucks templates. For example, setting $.name = "Bob" will make the name variable available to Nunjucks templates.

Hello, {{ name }}.

In addition to init and actions, we can also optionally export an auth function which runs before every init and actions action method and should be written to return either true or false. If it returns false, then the action will not execute. If no auth function is defined or if an auth function is defined and returns true, then action will execute.

A typical auth function might check to see if the current logged in user is an admin. If not, redirect to the log in page and return false like this:

export const auth = (ctx) => {
  const user = ctx.session.get("user");
  if (!user?.roles?.includes("admin")) {
    return false;
  return true;


The ctx object contains several methods and properties which can be used to process the request and return a response.

ctx properties


When an action is triggered by a z-<event> attribute, this is an object containing the values of id, name, and value attributes of the element that triggered the action.


The payload is the value of z-payload on the element that triggered the action or the entire form data if the action was triggered by z-submit on a form element.


The session object holds data that persists between requests. See "Sessions" section for more info.


The hash of the current URL.


The request headers sent when current page was loaded.


The host of the current URL.


The hostname of the current URL.


The full URL of the current page.


The IP address of the client.


The HTTP method of the request.


The params of the current URL.


The pathname of the current URL.


The port of the current URL.


The protocol of the current URL.


The query string of the current URL.


The search string of the current URL.


A boolean indicating whether the current URL is secure.


An object containing the current session keys and values. See "Sessions".

ctx methods


This is the workhorse of ZenJS actions. It is used to render the full page in an init method or to render one or more elements in an action method.

In an init method, you can call ctx.render() with no arguments to render the entire page.

In an action method, call it with an element ID or array of element IDs to render only those elements.

Example: ctx.render(['#books', '#authors'])

The current state of $ and $meta will be parsed into the compiled template and sent as a regular HTTP response.


Redirects to a relative or absolute URL. For direct HTTP requests in an init method which are not using z-merge, a standard redirect header is sent. For z-merge requests and all redirects from within <custom-action> handlers, a header is set to invoke the redirect.

Example: ctx.redirect("/login")


Renders the nearest 404 page template for this path. Example: ctx._404()


Renders the nearest 500 page template for this path with optional error message. Example: ctx._500("Failed")


Set a header to be sent with the response.

Example: ctx.setHeader("X-Test", "foobar")


Set a header containing a new URL for the client JS to replace the browser location. This is useful for cases where an action might for example load a specific record causing the viewport to no longer be relevant to the current URL. Use this method to correct that.

Example: ctx.setLocation("/books/123")

Note that this results in a similar outcome to using z-target on an <a> element with the href set to the desired new location. Depending on the specific use case, either approach may be used.


Set a flash message to the session to read once and then destroyed.

Example: ctx.flash("You have been logged out.")


Call the next z-init tag for further processing. This is used when stacking multiple z-init tags on a single page. If no more z-init tags are found, an error will be thrown.


If the payload is an object or a JSON string, the value of the _id property will be cast as an ObjectId and returned by this method. If the payload is a string, the value will be cast to an ObjectId and returned. Otherwise, an empty string will be returned. This is a handy shortcut for MongoDB queries in actions methods.


If the payload is an object or a JSON string with an _id property, this method will return the full payload object with the _id property removed. This is useful for updating a document in the database without attempting to overwrite the _id property which is disallowed.


If the payload is a JSON string, the JSON will be parsed and returned as an object.


Ignore the request and do nothing.


ZenJS uses the Oak Sessions middleware to manage sessions. The session object is available in the ctx.session property of the ctx object.

Sessions are used to persist data between requests. For example, you might use sessions to store a user's login status or shopping cart items. It is also used for flash messages and for maintaining application state $ between actions on a page.

The docs for Oak Session say to use ctx.state.session but this refers to Oak's ctx object, not ZenJS's ctx object. In ZenJS, use ctx.session instead of ctx.state.session.

Basic usage

The ctx.session object has five methods:


Get a value from the session.

Example: ctx.session.get("user")

ctx.session.set(<key>, <value>)

Set a value in the session.

Example: ctx.session.set("user", { id: 1, name: "Bob" })


Check if a value exists in the session.

ctx.session.flash(<key>, <value>)

Set a value in the session to be read once and then removed the first time it's accessed with ctx.get. Useful for things like alert messages.


Destroy the session entirely.

Session Store and Hours

The storage strategy and session expiration time are set in .env as SESSION_STORE and SESSION_HOURS. In dev mode, the recommended (default) value for SESSION_STORE is memory. In production mode, the recommended SESSION_STORE is redis because Redis is an in-memory database which is extremely fast and is also very simple to install with brew install redis on Mac or sudo apt install redis on Linux.

In some cases, you may want to use MongoDB as the session store for example if deploying to a platform like Deno Deploy or Cloudflare Workers which do not allow local installation of Redis. In this case, you can set SESSION_STORE to mongo.

The default SESSION_HOURS value is 2 hours. Rolling sessions are supported meaning that the expiration time is reset every time the session is accessed.


For database operations, ZenJS uses the Deno MongoDB driver which is very well tested and well documented. See docs with example CRUD operations here. The driver is available in the db object which can be imported from zen/deps.js. Typically in an action module, you will import the db object like this:

import { db } from "/deps.js";

Then you can use the db object to perform database operations. For example, to find a user by username, you would use the following:

$.user = await db.collection("users").findOne({ username: "bob" });

Remember that when using the MongoDB driver, you must use await when calling database methods. Also remember to call toArray() to get an array from a returned cursor for methods like find().

$.users = await db.collection("users").find().toArray();

Note that validation is handled as a separate step. See the "Validation" section below.


In ZenJS, all validation is done on the server side including both form level validation and field level validation. When a form is submitted and fails validation, we should assign the validation errors to a local state variable like $.invalid, then call ctx.render("#book-form") to re-render the form with the updated value of $.invalid. The fields in this form will use this value to populate the value of a form's z-invalid attributes like this:

<input id="title" name="title" z-invalid="{{ invalid.title }}"/>

The value of invalid.title in this example might be rendered with an error string like this:

<input name="title" name="title" z-invalid="Title is required"/>

If a z-invalid attribute is populated with a non-empty string, the client side zen.js script will then take these actions:

  1. Class .invalid will be added to this field, its parent <fieldset> element, and its parent <form> element (remember that the convention in ZenJS is to wrap each form field in a fieldset).
  2. If a custom element <z-invalid><z-invalid> is present as a child of the same fieldset, it will be populated with the value of z-invalid, e.g., "Title is required".
  3. When the field receives a focus event, the value of z-invalid is set to an empty string, causing failed validation to be cleared for this form while the user is interacting with it (until it's blurred again or the form is submitted).
  4. If the form contains any kind of submit button, the disabled attribute will be added to it.
  5. If the parent form contains a z-validate attibute with an action module and method as its value, then when the field receives a blur event, that action will fire on the server-side, typically to re-render just this field with an updated z-invalid value.

Breaking it down

That may sound like a lot, but if you break it down, it's actually pretty sensible. How do we know if any fields in a form have failed validation? Because fields which have failed server-side validation will have a non-empty value for z-invalid. And if any fields are invalid, we want to add the class .invalid to the field, its parent fieldset, and its parent form (this allows you to easily use CSS selectors to style failed validations however you like). And of course, if a form has failed validation, it makes sense to also disable its submit button.

If the form has an action defined for z-validate then blur listeners are added for field-level validation alongside the focus listeners which clear validation for the field. For example, if a user enters a the value "bob" in the "pages" field whose schema requires a valid number, then when this field is blurred, and books.validate is called (as specified in the form's z-validate attribute), we'll re-render this field with z-invalid set to the current validation error message or an empty string if it passed. But then when the user clicks back into that field to correct it, it makes sense to temporarily clear the error while they're typing.

Excluding fields from validation

If you want to use z-validate to automate validation for most of the form, but do not want the client-side magic to consider specific fields, simply don't include a z-invalid attribute for that field. Only fields with a z-invalid attribute are parsed.

Server side

So now we know how the client side handles the presence or absence of z-invalid values, but how do we populate those values in the first place? The simplified answer is to say that we use a schema to validate an object or field, then use that result to populate the value of $.invalid or whatever you've chosen to call your state object containing validation failure with strings keyed by their field names. It helps to consider the two separate tasks one at a time: Form level validation before database operations like insert or update, and field level validations for example when a field is blurred. But in both cases, we start by creating a schema.


The Zen library includes a custom schema parsing and validation module which covers most validation and transformation requirements and also supports custom parsing for special cases. Schema objects created with schema() have a schema property as well as parse, parsePartial, and parseProperty methods. Each of these methods will always return either an object containing data or invalid. Create additional schema files in the schema/ directory. /schema/User.js can be referenced as a guide.

import { schema } from "/zen/deps.js";

export const Book = schema({
  title: "string|min:3",
  pages: "number|optional",

Now this schema can be imported into an action module and used for data transformation and validation:

import { Book } from "/schema/Book.js";

const { data, invalid } = Book.parse(ctx.payload);

In this example, the full payload of the request is passed which often may include the _id property. Since _id isn't defined in our schema, it be silently stripped from the data object.

Handling the returned data or invalid object

Zen Schema will always return an object containing either a data or invalid object.

If validation passes, a data object will be returned containing the parsed data which includes only properties defined in the schema and transformed according to its data type. For example, pages is submitted as an input field in a web form, this arrives at the server as a string type (because all values submitted through web forms are strings). But because the specified type for pages is "number", the returned value in data.pages will be a number type ready for insertion to the database.

If validation fails, the invalid object will contain the validation errors keyed by the field name. For example if "abc" was submitted through a web form for pages, the return value for invalid.pages would be "Pages must be a number". This makes it easy to populate the $.invalid state object (or any other state property name).

A typical pattern for handling the schema object parse result in a ZenJS action method is to check for the presence of invalid and if present, populate the invalid state object, re-render the form, and return out of the action, short-circuiting the rest of the function like this:

create: (async (ctx, $) => {
  const { data, invalid } = Book.parse(ctx.payload);
  if (invalid) {
    $.invalid = invalid;
    $.book = ctx.payload;
  // No errors, save parsed data to db.

In line 2, we start by parsing the submitted data. If validation failed, we assign that to a state variable in line 4 which by convention is named $.invalid but can be anything you like.

In line 5, we assign the submitted values to $.book so that the form is rendered with submitted values populated, then render the form and return out of the function.

This will result in the form being re-rendered in place on the client with the form fields' z-invalid values set according to the results of schema validation. Then the client-side processing will set .invalid classes, populate <z-invalid> elements, and enable/disable the submit button.

Validating a partial object

In some cases, you may want to validate only a partial object. For example, when updating a record, you may want to validate only the fields that were submitted. In this case, use the parsePartial() method instead of parse() like this:

const { data, invalid } = Book.parsePartial(ctx.payload);

Note that only submitted fields will be validated and returned in the data object. Any fields not submitted will be ignored regardless whether specified as optional or required (default).

Validating a single form field

It is common to validate individual form fields and provide user feedback while the form is being edited and before being submitted. This is traditionally done on the client side requiring two entirely separate schemes for validation on the client side and then again on the server side. ZenJS simplifies this by handling all validations including field-level validation on the server.

Validating a single field is similar to full validation. But here we'll use ctx.trigger values rather than ctx.payload to get the id, name, and value of the single field that triggered this action.

validate: (ctx, $) => {
  const { id, name, value } = ctx.trigger;
  const { invalid, data } = Book.parseProperty(name, value);
    ? $.invalid = { ...$.invalid, ...invalid }
    : delete $.invalid?.[name];
  $.book[name] = value;

Notice in line 3, we call the schema's parseProperty() method. This checks only the one specified field against the schema. For nested properties, use dot notation like parseProperty("address.city", "New York").

In lines 5 and 6, we set the value of $.invalid to the current value overwritten by the new result of validation for this one field – or the property for that field is deleted if it passed validation.

Finally, we make sure the field is populated with the value submitted and re-render it.

Note that this method is named validate by convention but you're free to name it whatever you like.

Schema types and options

A schema item must start with a data type followed by zero or more options. All types support optional, default, label, and custom functions. optional makes the property optional.default sets the default value (effectively making it also optional). label allows you to specify a custom label for the field name used in invalid message values and uses the key set to titlecase by default. For example, if the field name is title, the default label will be "Title". The label option allows you to override this. For example, label:Book Title will result in "Book Title" being used in invalid messages instead of "Title".

Available types and their respective options include:


Returns the parsed data property as a string or returns an invalid object.

  • min:<number>: Minimum length
  • max:<number>: Maximum length
  • startsWith:<string>: Must start with specified string
  • endsWith:<string>: Must end with specified string
  • includes:<string>: Must include specified string
  • email: Must match valid email format
  • url: Must match valid url format
  • id: Must match valid MongoDB ObjectId format
  • optional: Field is optional
  • default: Set a default string value
  • label: A custom label for invalid messages
  • <string>: A custom function


Returns the parsed data property as a number or returns an invalid object.

  • min:<number>: Minimum value
  • max:<number>: Maximum value
  • integer: Must be an integer
  • optional: Field is optional
  • default: Set a default number value
  • label: A custom label for invalid messages
  • <string>: A custom function


Returns the parsed data property as a number or returns an invalid object. The following values are coerced to true: true, "true", "1", 1. The following values are coerced to false: false, "false", "0", 0.

  • optional: Field is optional
  • default: Set a default boolean value
  • label: A custom label for invalid messages
  • <string>: A custom function


Returns the object as is or returns an invalid object. Note that this type is useful for validating objects of unknown shape. If you know the shape of the object, it's better to use a nested object schema.

  • optional: Field is optional
  • default is not supported for this type
  • label: A custom label for invalid messages
  • <string>: A custom function


Returns the object as a date or returns an invalid object.

  • optional: Field is optional
  • default is not supported for this type
  • label: A custom label for invalid messages
  • <string>: A custom function


Returns the value as is. Any value is accepted.

  • optional: Field is optional
  • default is not supported for this type
  • label: A custom label for invalid messages
  • <string>: A custom function

[string], [number], [boolean], [object], [date], [any]

These are the array equivalents of the above types. For example, [string] will return an array of strings or an invalid object.

Custom functions

Any custom function can be used as a schema option in conjunction with a standard type. To do this, create a function which receives the value and returns an object containing either data or invalid, then pass the function as a second argument to the schema() function. Then reference the function name as a schema option like this:

const { data, invalid } = schema({
  pages: "number|isEven",
}, isEven);

function isEven(value) {
  return value % 2 === 0 
    ? { data: value } 
    : { invalid: "must be an even number" };

In line 2, we use the isEven custom function as a schema option. Then in line 3, pass in the isEven function defined in line 5 which receives the value and returns either data or invalid depending on whether the value is divisible by 2. Note that the string assigned to invalid will be prepended with the label value, e.g., "Pages must be an even number".


The starter kit includes an admin system which initially includes CRUD for users. You can add additional schemas and actions as needed. The users CRUD is a good example of how to add additional schema and actions.

To create the first user, a script is included in the devops directory. Run the script with the following command:

deno task admin


The devops directory contains scripts for common tasks such as creating a new admin user, deploying to remote server, and PM2 scripts which are run on the live server to start/stop PM2.

(more docs coming soon...)