Nick: When Backend meets Frontend, Plone Conference 2025

robgietema 0 views 112 slides Oct 16, 2025
Slide 1
Slide 1 of 112
Slide 1
1
Slide 2
2
Slide 3
3
Slide 4
4
Slide 5
5
Slide 6
6
Slide 7
7
Slide 8
8
Slide 9
9
Slide 10
10
Slide 11
11
Slide 12
12
Slide 13
13
Slide 14
14
Slide 15
15
Slide 16
16
Slide 17
17
Slide 18
18
Slide 19
19
Slide 20
20
Slide 21
21
Slide 22
22
Slide 23
23
Slide 24
24
Slide 25
25
Slide 26
26
Slide 27
27
Slide 28
28
Slide 29
29
Slide 30
30
Slide 31
31
Slide 32
32
Slide 33
33
Slide 34
34
Slide 35
35
Slide 36
36
Slide 37
37
Slide 38
38
Slide 39
39
Slide 40
40
Slide 41
41
Slide 42
42
Slide 43
43
Slide 44
44
Slide 45
45
Slide 46
46
Slide 47
47
Slide 48
48
Slide 49
49
Slide 50
50
Slide 51
51
Slide 52
52
Slide 53
53
Slide 54
54
Slide 55
55
Slide 56
56
Slide 57
57
Slide 58
58
Slide 59
59
Slide 60
60
Slide 61
61
Slide 62
62
Slide 63
63
Slide 64
64
Slide 65
65
Slide 66
66
Slide 67
67
Slide 68
68
Slide 69
69
Slide 70
70
Slide 71
71
Slide 72
72
Slide 73
73
Slide 74
74
Slide 75
75
Slide 76
76
Slide 77
77
Slide 78
78
Slide 79
79
Slide 80
80
Slide 81
81
Slide 82
82
Slide 83
83
Slide 84
84
Slide 85
85
Slide 86
86
Slide 87
87
Slide 88
88
Slide 89
89
Slide 90
90
Slide 91
91
Slide 92
92
Slide 93
93
Slide 94
94
Slide 95
95
Slide 96
96
Slide 97
97
Slide 98
98
Slide 99
99
Slide 100
100
Slide 101
101
Slide 102
102
Slide 103
103
Slide 104
104
Slide 105
105
Slide 106
106
Slide 107
107
Slide 108
108
Slide 109
109
Slide 110
110
Slide 111
111
Slide 112
112

About This Presentation

This talk will show you how you can create a site using Nick, show the differences compared with Plone as a backend and a new of integrating frontend and backend systems.


Slide Content

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 PythonNode
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 msPlone: 2187 ms

DELETE
Delete a Page with 10000 children
Nick: 400 msPlone: 29691 ms

RENAME
Rename a Page with 10000 children
Nick: 4429 msPlone: 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
*@methodsetId

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
Korqtv"wukpi"rnqpg0ezrqtvkorqtv
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": "[email protected]",
"groups": ["Speakers"]
},
{
"id": "admin",
"password": "admin",
"fullname": "Admin",
"email":"[email protected]",

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
returnobjectToVocabulary({

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
*@methodtotalTime

EVENTS

EVENTS
onBeforeAdd
onA?erAdd
onA?erModified
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);
}
//Reindexparent

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,
"promptevalcount":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