NICK
A NEARLY HEADLESS CMS
Plone Conference 2025, Jyväskylä
-
WHEN BACKEND MEETS FRONTEND
THE STATE OF NICK
Rob Gietema @robgietema
ABOUT ME
WHAT IS NICK?
(Nearly) Headless CMS
Build with Node.js
RESTfull API compatible with plone.restapi (Volto)
WHY "NICK"?
Nearly Headless CMS
WHY?
Fun to build!
Plone has a great architecture, great way to learn
the internals
Plone has a great Rest API
Started as a proof of concept on Ploneconf 2018 in
Tokyo
Frontend and backend using the same language
WEBSITE
https://nickcms.org
ONLINE DEMO
https://demo.nickcms.org
DOCUMENTATION
https://docs.nickcms.org
CONTRIBUTE
https://github.com/robgietema/nick
WHAT WILL WE COVER?
What does the architecture look like?
How does Nick perform?
How to build a site using Nick
How to integrate with a backend
WHAT DOES THE ARCHITECTURE
LOOK LIKE?
ISSUES WITH PLONE
Disclaimer: my opinion
Lots of legacy code
Lot of code to maintain ourself
Deployment
COMPLEX STACK
Python
Zope
Generic Setup (xml)
ZCML
Page templates
REST
Yaml
JSON
cfg
ini
Markdown
Javascript
Webpack
CSS / LESS / SASS
XSLT
Buildout
KSS
Portal Skins
Restricted Python
DTML
LANGUAGES USED IN NICK
Javascript / Typescript
JSON
Markdown
STORAGE
Postgres (Transactional, JSON integration, (text)
indexing)
Knex.js ( )
Objection.js ( )
knexjs.org
vincit.github.io/objection.js/
BLOBS
myproject
└─ var
└─ blobstorage
└─ 1d2362de-8090-472b-a06a-0e4d23705f3c
└─ 2bd8d8f2-6d01-4f39-a799-a521acd17dbf
└─ 5e178390-2cf6-498b-9bb3-424c1aa4dea3
└─ ...
Application Plone Nick
PLONE VS NICK
Language Python Node
Storage ZODB Postgres
Lines of code ~1.250.000 ~8.800
HOW DOES NICK PERFORM?
TEST SETUP
MacBook M1 Max
Plone 6.0.13
Nick 2.9.0
Postman
API test only
* Disclaimer
READS
Fetch the siteroot, 100 concurrent users
WRITES: PLONE
Create a Page, 20 concurrent users
WRITES: NICK
Create a Page, 20 concurrent users
DELETE
Delete a Page with 1000 children
Nick: 136 ms Plone: 2187 ms
DELETE
Delete a Page with 10000 children
Nick: 400 ms Plone: 29691 ms
RENAME
Rename a Page with 10000 children
Nick: 4429 ms Plone: 83941 ms
HOW TO BUILD A SITE USING NICK
Bootstrap a project
Configuration file
Profiles
Contenttypes
Behaviors
Initial Content
Permissions, Users,
Groups & Workflows
Vocabularies
Catalog & Search
Events
Controlpanels
i18n
Logging
Tests
Docs
WHAT WILL WE COVER?
CREATE THE DATABASE
CREATE DATABASE "myproject";
CREATE USER 'myproject' WITH ENCRYPTED PASSWORD 'myproject';
GRANT ALL PRIVILEGES ON DATABASE "myproject" TO "myproject";
ALTER DATABASE "myproject" OWNER TO "myproject";
YEOMAN GENERATOR
$ npm install -g yo
$ npm install -g @robgietema/generator-nick
$ yo @robgietema/nick myproject
BOOTSTRAP
$ cd myproject
$ pnpm bootstrap
CONFIG
myproject/src/config.js
export const config = {
connection: {
port: 5432,
host: 'localhost',
database: 'myproject',
user: 'myproject',
password: 'myproject',
},
blobsDir: `${__dirname}/var/blobstorage`,
port: 8000,
secret: 'secret',
clientMaxSize: '64mb',
systemUsers: ['admin', 'anonymous'],
systemGroups: ['Owner'],
cors: {
VOLTO CONFIG
import '@plone/volto/config';
import applyAddons from './config-addons.js';
export default function applyConfig(config) {
config.settings.devProxyToApiPath = 'http://localhost:8000';
return applyAddons(config);
}
START
$ pnpm start
DEMO
http://localhost:3000
SEEDS / PROFILES
myproject
└─ src
└─ profiles
└─ default
└─ metadata.json
└─ types
└─ schedule.json
└─ talk.json
└─ groups.json
└─ permissions.json
└─ ...
$ pnpm seed
$ pnpm reset
SEEDS / PROFILES VERSIONING
myproject
└─ src
└─ profiles
└─ default
└─ upgrades
└─ 1000
└─ 1001
└─ metadata.json
└─ groups.json
└─ permissions.json
└─ ...
$ pnpm seed:status
$ pnpm seed:upgrade
CONTENTTYPES
CONTENTTYPES
myproject
└─ src
└─ profiles
└─ default
└─ types
└─ schedule.json
└─ talk.json
SCHEDULE.JSON
{
"id": "Schedule",
"title:i18n": "Schedule",
"description:i18n": "Schedule for a conference.",
"global_allow": true,
"filter_content_types": true,
"allowed_content_types": ["Talk"],
"schema": {
"fieldsets": [
{
"fields": ["year"],
"id": "default",
"title:i18n": "Default"
}
],
TALK.JSON
{
"id": "Talk",
"title:i18n": "Talk",
"description:i18n": "Content type for a talk.",
"global_allow": false,
"filter_content_types": true,
"allowed_content_types": [],
"schema": {
"fieldsets": [
{
"fields": ["title", "description"],
"id": "default",
"title:i18n": "Default"
}
],
BEHAVIORS
dublin_core
dates
blocks
navroot
folderish
versioning
short_name
id_from_title
exclude_from_nav
BEHAVIORS
myproject
└─ src
└─ profiles
└─ default
└─ behaviors
└─ author.json
AUTHOR.JSON
{
"id": "author",
"title:i18n": "Author Information",
"description:i18n": "Adds firstname, lastname, bio and pictu
"schema": {
"fieldsets": [
{
"fields": ["firstname", "lastname", "bio", "picture"],
"id": "default",
"title:i18n": "Default"
}
],
"properties": {
"firstname": {
"title:i18n": "Firstname",
NESTED BEHAVIORS
{
"id": "author",
"title:i18n": "Author Information",
"description:i18n": "Adds name and bio fields.",
"schema": {
"behaviors": ["name", "bio"]
}
}
BEHAVIORS (CLASS BASED)
myproject
└─ src
└─ behaviors
└─ id_title_from_year
└─ id_title_from_year.js
DOCUMENT MODEL
/**
* Document Model.
* @module models/document/document
*/
/**
* A model for Document.
* @class Document
* @extends Model
*/
export class Document extends Model {
...
/**
ID_TITLE_FROM_YEAR.JS
/**
* Id and title from year behavior.
* @module behaviors/id_title_from_year/id_title_from_year
*/
import { uniqueId } from '@robgietema/nick/src/helpers/utils/u
/**
* Id and title from year behavior.
* @constant id_title_from_year
*/
export const id_title_from_year = {
/**
* Set id
* @method setId
INITIAL CONTENT
INITIAL CONTENT
myproject
└─ src
└─ profiles
└─ default
└─ documents
└─ schedule-2025.json
└─ schedule-2025.nick.json
└─ schedule-2025.documentation.json
└─ images
└─ rob.png
└─ steve.jpg
INITIAL CONTENT
profiles/default/documents/schedule-2025.json
{
"uuid": "405ca717-0c68-43a0-88ac-629a82658675",
"type": "Schedule",
"year": 2025,
"owner": "admin",
"workflow_state": "published"
}
INITIAL CONTENT
profiles/default/documents/schedule-2025.nick.json
{
"uuid": "605ca717-0c68-43a0-88ac-629a82658675",
"type": "Talk",
"title": "Nick: When Backend meets Frontend",
"description": "Nick is a nearly headless CMS written in Nod
"firstname": "Rob",
"lastname": "Gietema",
"bio": "Rob is a frontend webdeveloper for over 25 years. He
"picture": "/images/rob.png",
"length": "Long",
"level": "Beginner",
"owner": "robgietema",
"workflow_state": "approved"
}
VERSIONS
profiles/default/documents/schedule-2025.nick.json
{
"uuid": "605ca717-0c68-43a0-88ac-629a82658675",
"type": "Talk",
"title": "Nick: When Backend meets Frontend",
"description": "Nick is a nearly headless CMS written in Nod
"firstname": "Rob",
"lastname": "Gietema",
"bio": "Rob is a frontend webdeveloper for over 25 years. He
"picture": "/images/rob.png",
"length": "Long",
"level": "Beginner",
"owner": "robgietema",
"workflow_state": "approved"
"workflow_history": [
{
REDIRECTS
profiles/default/redirects.json
{
"purge": true,
"redirects": [{
"path": "/talks-2025",
"document": "405ca717-0c68-43a0-88ac-629a82658675"
}]
}
CONVERSION FROM PLONE
Import using plone.exportimport
pnpm convert <inputfolder> <outputfolder>
PERMISSION SYSTEM
Permissions
Roles (have permissions)
Groups (have roles)
Users (have roles, groups)
Local roles (user/group has a role on an object)
Local role permissions are inherited from the parent
Local role inheritence can be disabled per object
Workflows (have states and transitions)
States (have permissions per role)
Transitions (have permissions)
PERMISSIONS.JSON (GLOBAL)
{
"purge": false,
"permissions": [
{
"id": "View",
"title:i18n": "View"
},
{
"id": "Add",
"title:i18n": "Add"
},
{
"id": "Login",
"title:i18n": "Login"
},
PERMISSIONS.JSON (PROJECT)
{
"purge": false,
"permissions": [
{
"id": "Submit Talk",
"title:i18n": "Submit Talk"
},
{
"id": "Approve Talk",
"title:i18n": "Approve Talk"
}
]
}
ROLES.JSON (GLOBAL)
{
"purge": false,
"roles": [
{
"id": "Anonymous",
"title:i18n": "Anonymous",
"permissions": ["Login", "Register"]
},
{
"id": "Authenticated",
"title:i18n": "Authenticated",
"permissions": ["Logout", "Manage Preferences"]
},
{
"id": "Owner",
ROLES.JSON (PROJECT)
{
"purge": false,
"roles": [
{
"id": "Speaker",
"title:i18n": "Speaker",
"permissions": ["Submit Talk"]
},
{
"id": "Program Manager",
"title:i18n": "Program Manager",
"permissions": ["Approve Talk"]
}
]
}
USERS.JSON (PROJECT)
{
"purge": false,
"users": [
{
"id": "robgietema",
"password": "robgietema",
"fullname": "Rob Gietema",
"email": "robgietema@nickcms.org",
"groups": ["Speakers"]
},
{
"id": "admin",
"password": "admin",
"fullname": "Admin",
"email": "admin@nickcms.org",
GROUPS.JSON (PROJECT)
{
"purge": false,
"groups": [
{
"id": "Speakers",
"title:i18n": "Speakers",
"description:i18n": "",
"email": "",
"roles": ["Speaker"]
}
]
}
WORKFLOWS.JSON
{
"purge": false,
"workflows": [
{
"id": "talk_workflow",
"title:i18n": "Talk Workflow",
"description:i18n": "Workflow for talk submission and ap
"json": {
"initial_state": "submitted",
"states": {
"submitted": {
"title:i18n": "Submitted",
"description:i18n": "Talk has been submitted.",
"transitions": ["approve", "reject"],
"permissions": {
TALK.JSON
{
"id": "Talk",
"title:i18n": "Talk",
"description:i18n": "Content type for a talk.",
"global_allow": false,
"filter_content_types": true,
"allowed_content_types": [],
"schema": {
"fieldsets": [
{
"fields": ["title", "description"],
"id": "default",
"title:i18n": "Default"
}
],
SHARING
myproject/src/profiles/default/documents/schedule-2025.json
{
"uuid": "405ca717-0c68-43a0-88ac-629a82658675",
"type": "Schedule",
"year": 2025,
"owner": "admin",
"workflow_state": "published",
"sharing": {
"users": [
{
"id": "robgietema",
"roles": ["Reader"]
}
],
"groups": [
{
VOCABULARIES
VOCABULARIES
myproject
└─ src
└─ vocabularies
└─ talk-levels
└─ talk-levels.js
VOCABULARY
/**
* Talk levels vocabulary.
* @module vocabularies/talk-levels/talk-levels
*/
import { objectToVocabulary } from '@robgietema/nick/src/helpe
/**
* Returns the talk levels vocabulary.
* @method talkLevels
* @returns {Array} Array of terms.
*/
export async function talkLevels(req, trx) {
// Return terms
return objectToVocabulary({
PROFILE VOCABULARIES
myproject
└─ src
└─ profiles
└─ default
└─ vocabularies
└─ talk-length.json
PROFILE VOCABULARY
{
"id": "talk-length",
"title:i18n": "Talk Length",
"items": [
{ "title:i18n": "Short", "token": "Short" },
{ "title:i18n": "Long", "token": "Long" }
]
}
TALK.JSON
{
"id": "Talk",
"title:i18n": "Talk",
"description:i18n": "Content type for a talk.",
"global_allow": false,
"filter_content_types": true,
"allowed_content_types": [],
"schema": {
"fieldsets": [
{
"fields": ["title", "description", "length", "level"],
"id": "default",
"title:i18n": "Default"
}
],
SEARCH & CATALOG
CATALOG
Indexes
type (path, uuid, integer, date, text, string,
boolean, string[], embed)
operators
Metadata
name
type
attribute
CATALOG.JSON (PROJECT)
{
"indexes": [
{
"name": "author",
"type": "string",
"attr": "author",
"title:i18n": "Author",
"description:i18n": "The author's name",
"group": "Text",
"enabled": false,
"sortable": true,
"operators": {
"string.contains": {
"title:i18n": "Contains",
"description:i18n": "",
BEHAVIORS (INDEXES)
myproject
└─ src
└─ behaviors
└─ author_index
└─ author_index.js
└─ total_time_index
└─ total_time_index.js
...
AUTHOR INDEX
/**
* Author index behavior.
* @module behaviors/author_index/author_index
*/
/**
* Author index behavior.
* @constant author_index
*/
export const author_index = {
/**
* Get author
* @method author
* @param {Object} trx Transaction object.
* @returns {String} author
TOTALTIME INDEX
/**
* Total time index behavior.
* @module behaviors/total_time_index/total_time_index
*/
import { map } from 'lodash';
/**
* Total time index behavior.
* @constant total_time_index
*/
export const total_time_index = {
/**
* Get total time
* @method totalTime
EVENTS
EVENTS
onBeforeAdd
onAfterAdd
onAfterModified
onBeforeCopy
...
EVENT FUNCTION
onBeforeAdd(context, trx, ...params)
EVENTS
myproject
└─ src
└─ events
└─ reindex_parent_on_modified
└─ reindex_parent_on_modified.js
EVENT
/**
* Reindex parent on modified
* @module events/reindex_parent_on_modified
*/
const reindex_parent_on_modified = {
onAfterModified: async (context, trx) => {
if (context.type !== 'Talk') return;
// Fetch parent
if (!context._parent) {
await context.fetchRelated('_parent', trx);
}
// Reindex parent
CONTROLPANELS
CONTROLPANELS
myproject
└─ src
└─ profiles
└─ default
└─ controlpanels
└─ venue.json
CONTROLPANEL
{
"id": "venue",
"title:i18n": "Venue",
"group": "General",
"schema": {
"fieldsets": [
{
"behavior": "plone",
"fields": [
"name",
"address",
"website"
],
"id": "default",
"title": "Default"
GETTINGS SETTINGS
import { Controlpanel } from '@robgietema/nick/src/models';
const controlpanel = await Controlpanel.fetchById('venue');
const config = controlpanel.data;
config.name
.address
.website
NAVIGATION CONTROLPANEL
{
"id": "navigation",
"title:i18n": "Navigation",
"group": "General",
"schema": {
"fieldsets": [
{
"fields": ["displayed_types", "additional_items"],
"id": "general",
"title": "General"
}
],
"properties": {
"displayed_types": {
"additionalItems": true,
OVERWRITE SETTINGS
{
"id": "navigation",
"data": {
"displayed_types": ["Folder", "Page", "Schedule"]
}
}
I18N
I18N
myproject
└─ locales
└─ en
└─ LC_MESSAGES
└─ myproject.po
└─ nl
└─ LC_MESSAGES
└─ myproject.po
└─ myproject.pot
└─ en.json
└─ nl.json
I18N
$ pnpm i18n
I18N IN JS
req.i18n('Translate me please!')
I18N IN JSON
{
"id": "talk",
"title:i18n": "Talk",
"description:i18n": "Content type for a talk.",
...
}
PO / POT FILES
#. Default: "Content"
#: src/routes/controlpanels/controlpanels.js:59
msgid "Content"
msgstr ""
msgctxt "Routes|Controlpanels"
TESTING
TESTING
myproject
└─ docs
└─ examples
└─ types
└─ schedule.req
└─ schedule.res
└─ src
└─ tests
└─ types
└─ types.test.js
SCHEDULE.REQ
GET /@types/Schedule HTTP/1.1
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ
SCHEDULE.RES
HTTP/1.1 200 OK
Content-Type: application/json
{
"required": [
"year"
],
"fieldsets": [
{
"id": "default",
"title": "Default",
"fields": [
"year"
]
}
TYPES.TEST.JS
import app from '@robgietema/nick/src/app';
import { testRequest } from '@robgietema/nick/src/helpers';
describe('Types', () => {
it('should return the schedule type', () =>
testRequest(app, 'types/schedule'));
});
DOCS
DOCS
myproject
└─ docs
└─ index.md
└─ types.md
INDEX.MD
---
layout: default
nav_exclude: true
---
# My Project
## Introduction
My awesome project!
TYPES.MD
---
nav_order: 1
permalink: /types
---
# Types
## Get the schema with GET
To get the schema of a content type, access the `/@types` endp
```
{% include_relative examples/types/schedule.req %}
```
DOCS
HOW TO INTEGRATE WITH A
BACKEND
BACKEND FOR FRONTEND (BFF)
CREATE CONTENT USING THE API
POST /news HTTP/1.1
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ
Content-Type: application/json
{
"@type": "Page",
"title": "My News Item",
"description": "News Description"
}
CREATE CONTENT USING THE
CLIENT
import { Client } from '@robgietema/nick';
const cli = Client.initialize({ apiPath: 'http://localhost:808
const login = await cli.login({ username: 'admin', password: '
const { data } = await cli.createContent({
token: login.data.token,
path: '/news',
data: {
'@type': 'Page',
title: 'My News Item',
description: 'News Description',
},
});
AI
CHAT ENDPOINT
POST /@chat HTTP/1.1
Accept: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ
Content-Type: application/json
{
"prompt": "What is the largest land animal?",
"context": [ ... ],
"messages": [ ... ],
}
RESPONSE
HTTP/1.1 200 OK
Content-Type: application/json
{
"model": "qwen3",
"created_at": "2025-01-01T00:00:00.00000Z",
"response": "The largest land animal is the African bush ele
"done": true,
"done_reason": "stop",
"context": [
...
]
"total_duration": 356186167,
"load_duration": 18592125,
"prompt eval count": 139,
QUESTIONS?
Want to implement a site using Nick? Talk to me!
But what about AI? See my talk later today!
slideshare.net/robgietema/nick-when-backend-meets-frontend
github.com/robgietema/nick-example

Nick: When Backend meets Frontend, Plone Conference 2025