Plugin Tutorial: Build a Bookmarks Plugin
Plugin Tutorial: Build a Bookmarks Plugin
This tutorial walks through building a complete "bookmarks" plugin from scratch. By the end, you will have a plugin with two database tables, five REST endpoints, a content hook, middleware, and a helper module.
Prerequisites: a running ModulaCMS instance with plugin_enabled: true in modula.config.json. See configuration for setup.
1. Scaffold the Plugin
modulacms plugin init bookmarks \
--version 1.0.0 \
--description "Save and organize bookmarks" \
--author "Your Name" \
--license MIT
This creates:
plugins/
bookmarks/
init.lua
lib/
The generated init.lua contains a skeleton manifest and empty lifecycle functions.
2. Write the Manifest
Open plugins/bookmarks/init.lua and replace the contents with:
plugin_info = {
name = "bookmarks",
version = "1.0.0",
description = "Save and organize bookmarks",
author = "Your Name",
license = "MIT",
}
The name field must match the directory name. It becomes the database table prefix (plugin_bookmarks_) and the route prefix (/api/v1/plugins/bookmarks/).
Version matters. When you change the version string later, all route and hook approvals are revoked and require re-approval. This prevents stale approvals from applying to updated code.
3. Define Tables
Add an on_init() function to create your database tables. This runs once after all VMs are created.
function on_init()
db.define_table("collections", {
columns = {
{ name = "name", type = "text", not_null = true },
{ name = "description", type = "text" },
{ name = "sort_order", type = "integer", not_null = true, default = 0 },
{ name = "is_public", type = "boolean", not_null = true, default = 0 },
},
indexes = {
{ columns = {"name"} },
{ columns = {"is_public", "sort_order"} },
},
})
db.define_table("bookmarks", {
columns = {
{ name = "collection_id", type = "text", not_null = true },
{ name = "url", type = "text", not_null = true },
{ name = "title", type = "text", not_null = true },
{ name = "rating", type = "real" },
{ name = "notes", type = "text" },
{ name = "visited_at", type = "timestamp" },
},
indexes = {
{ columns = {"collection_id"} },
{ columns = {"url"} },
},
foreign_keys = {
{
column = "collection_id",
ref_table = "plugin_bookmarks_collections",
ref_column = "id",
on_delete = "CASCADE",
},
},
})
log.info("bookmarks plugin initialized")
end
Three columns are auto-injected on every table (id, created_at, updated_at) -- do not include them in your definition.
Foreign keys must reference tables owned by the same plugin. Note the full prefixed name plugin_bookmarks_collections in ref_table.
4. Register CRUD Routes
Add route registrations at module scope (top-level code), not inside on_init(). Routes and hooks must be registered at the top level.
-- List all bookmarks (with optional collection filter)
http.handle("GET", "/bookmarks", function(req)
local opts = { order_by = "created_at DESC", limit = 50 }
if req.query.collection_id then
opts.where = { collection_id = req.query.collection_id }
end
local bookmarks = db.query("bookmarks", opts)
local total = db.count("bookmarks", opts.where and { where = opts.where } or {})
return {
status = 200,
json = { bookmarks = bookmarks, total = total },
}
end)
-- Create a bookmark
http.handle("POST", "/bookmarks", function(req)
if not req.json then
return { status = 400, json = { error = "JSON body required" } }
end
local b = req.json
if not b.url or b.url == "" then
return { status = 400, json = { error = "url is required" } }
end
if not b.title or b.title == "" then
return { status = 400, json = { error = "title is required" } }
end
if not b.collection_id or b.collection_id == "" then
return { status = 400, json = { error = "collection_id is required" } }
end
if not db.exists("collections", { where = { id = b.collection_id } }) then
return { status = 400, json = { error = "collection not found" } }
end
local id = db.ulid()
db.insert("bookmarks", {
id = id,
collection_id = b.collection_id,
url = b.url,
title = b.title,
rating = b.rating,
notes = b.notes,
})
local created = db.query_one("bookmarks", { where = { id = id } })
return { status = 201, json = created }
end)
-- Get a single bookmark
http.handle("GET", "/bookmarks/{id}", function(req)
local bookmark = db.query_one("bookmarks", { where = { id = req.params.id } })
if not bookmark then
return { status = 404, json = { error = "not found" } }
end
return { status = 200, json = bookmark }
end)
-- Update a bookmark
http.handle("PUT", "/bookmarks/{id}", function(req)
if not req.json then
return { status = 400, json = { error = "JSON body required" } }
end
if not db.exists("bookmarks", { where = { id = req.params.id } }) then
return { status = 404, json = { error = "not found" } }
end
local updates = {}
local b = req.json
if b.url then updates.url = b.url end
if b.title then updates.title = b.title end
if b.rating then updates.rating = b.rating end
if b.notes then updates.notes = b.notes end
if next(updates) == nil then
return { status = 400, json = { error = "no fields to update" } }
end
db.update("bookmarks", {
set = updates,
where = { id = req.params.id },
})
local updated = db.query_one("bookmarks", { where = { id = req.params.id } })
return { status = 200, json = updated }
end)
-- Delete a bookmark
http.handle("DELETE", "/bookmarks/{id}", function(req)
if not db.exists("bookmarks", { where = { id = req.params.id } }) then
return { status = 404, json = { error = "not found" } }
end
db.delete("bookmarks", { where = { id = req.params.id } })
return { status = 204 }
end)
Each route becomes /api/v1/plugins/bookmarks/<path>. By default, routes require CMS authentication. Pass { public = true } as the fourth argument to make a route publicly accessible.
5. Add a Content Hook
Register a hook at module scope to react when new CMS content is created:
hooks.on("after_create", "content_data", function(data)
db.insert("activity", {
action = "content_created",
content_id = data.id,
title = data.title or "(untitled)",
})
log.info("Logged new content creation", { content_id = data.id })
end)
This hook runs asynchronously after the CMS transaction commits. It has full db.* access because it is an after-hook. Before-hooks block db.* calls to prevent transaction deadlocks.
You will need to add the activity table to on_init():
db.define_table("activity", {
columns = {
{ name = "action", type = "text", not_null = true },
{ name = "content_id", type = "text" },
{ name = "title", type = "text" },
},
indexes = {
{ columns = {"action"} },
},
})
6. Add Middleware
Middleware runs before all route handlers. Return a response table to short-circuit, or nil to continue to the route handler.
http.use(function(req)
-- Require API key on all public endpoints
if not req.headers["x-api-key"] then
return nil -- authenticated routes handle their own auth
end
-- If an API key is provided, validate it
if req.headers["x-api-key"] ~= "expected-key-here" then
return { status = 401, json = { error = "invalid api key" } }
end
return nil
end)
7. Extract Helpers
Create plugins/bookmarks/lib/validators.lua:
local M = {}
function M.is_valid_url(url)
if type(url) ~= "string" then return false end
if url == "" then return false end
return url:sub(1, 7) == "http://" or url:sub(1, 8) == "https://"
end
function M.not_empty(s)
return type(s) == "string" and #s > 0
end
function M.trim(s)
if type(s) ~= "string" then return s end
return s:match("^%s*(.-)%s*$")
end
return M
Use it in init.lua:
local validators = require("validators")
-- Resolves to: plugins/bookmarks/lib/validators.lua
Only files under the plugin's own lib/ directory are loadable. Path traversal (.., /, \) is rejected. Modules are cached after first load.
8. Validate
Before deploying, validate the plugin manifest and structure:
modulacms plugin validate ./plugins/bookmarks
This checks:
plugin_infotable exists and has required fields- Name matches directory name (lowercase alphanumeric plus underscores, max 32 chars)
- Version string is present
init.luaparses without syntax errors
Validation does not execute the plugin code or verify database operations.
9. Deploy and Approve
Copy the plugin directory to the server's plugin_directory path (default ./plugins/). If the server is running:
- With
plugin_hot_reload: true, the watcher detects the new directory and loads the plugin automatically. - Without hot reload, restart the server to pick up the plugin.
After the plugin loads, approve its routes and hooks:
# Approve all routes and hooks at once
modulacms plugin approve bookmarks --all-routes --all-hooks --yes
# Or approve individually
modulacms plugin approve bookmarks --route "GET /bookmarks"
modulacms plugin approve bookmarks --route "POST /bookmarks"
modulacms plugin approve bookmarks --route "GET /bookmarks/{id}"
modulacms plugin approve bookmarks --route "PUT /bookmarks/{id}"
modulacms plugin approve bookmarks --route "DELETE /bookmarks/{id}"
modulacms plugin approve bookmarks --hook "after_create:content_data"
See approval workflow for CLI, API, and TUI approval details.
10. Test with curl
BASE="http://localhost:8080/api/v1/plugins/bookmarks"
# Create a collection first (requires auth cookie)
curl -X POST "$BASE/collections" \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{"name": "Dev Resources", "is_public": 1}'
# Create a bookmark
curl -X POST "$BASE/bookmarks" \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{
"collection_id": "COLLECTION_ID_HERE",
"url": "https://go.dev/doc/",
"title": "Go Documentation",
"rating": 4.5
}'
# List bookmarks
curl "$BASE/bookmarks" -H "Cookie: session=YOUR_SESSION"
# Get a single bookmark
curl "$BASE/bookmarks/BOOKMARK_ID" -H "Cookie: session=YOUR_SESSION"
# Update
curl -X PUT "$BASE/bookmarks/BOOKMARK_ID" \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{"rating": 5.0}'
# Delete
curl -X DELETE "$BASE/bookmarks/BOOKMARK_ID" \
-H "Cookie: session=YOUR_SESSION"
You will need to add collection CRUD routes following the same pattern as bookmarks to make this fully functional.
11. Hot Reload for Development
Enable hot reload in modula.config.json:
{
"plugin_hot_reload": true
}
The watcher polls every 2 seconds for .lua file changes. When changes are detected:
- A 1-second debounce window waits for file writes to settle.
- A new plugin instance loads alongside the old one (blue-green).
- If the new instance succeeds, it replaces the old one atomically.
- If it fails, the old instance keeps running.
A 10-second cooldown prevents reload storms during rapid iteration. After 3 consecutive slow reloads (>10s each), the watcher pauses for that plugin.
Manual reload is always available:
modulacms plugin reload bookmarks
Complete init.lua
For reference, here is the complete plugin file combining all sections:
local validators = require("validators")
plugin_info = {
name = "bookmarks",
version = "1.0.0",
description = "Save and organize bookmarks",
author = "Your Name",
license = "MIT",
}
-- Middleware
http.use(function(req)
if req.headers["x-api-key"] and req.headers["x-api-key"] ~= "expected-key-here" then
return { status = 401, json = { error = "invalid api key" } }
end
return nil
end)
-- Routes
http.handle("GET", "/bookmarks", function(req)
local opts = { order_by = "created_at DESC", limit = 50 }
if req.query.collection_id then
opts.where = { collection_id = req.query.collection_id }
end
local bookmarks = db.query("bookmarks", opts)
local total = db.count("bookmarks", opts.where and { where = opts.where } or {})
return { status = 200, json = { bookmarks = bookmarks, total = total } }
end)
http.handle("POST", "/bookmarks", function(req)
if not req.json then
return { status = 400, json = { error = "JSON body required" } }
end
local b = req.json
if not validators.not_empty(b.url) then
return { status = 400, json = { error = "url is required" } }
end
if not validators.is_valid_url(b.url) then
return { status = 400, json = { error = "url must be http:// or https://" } }
end
if not validators.not_empty(b.title) then
return { status = 400, json = { error = "title is required" } }
end
if not validators.not_empty(b.collection_id) then
return { status = 400, json = { error = "collection_id is required" } }
end
if not db.exists("collections", { where = { id = b.collection_id } }) then
return { status = 400, json = { error = "collection not found" } }
end
local id = db.ulid()
db.insert("bookmarks", {
id = id,
collection_id = b.collection_id,
url = b.url,
title = validators.trim(b.title),
rating = b.rating,
notes = b.notes,
})
local created = db.query_one("bookmarks", { where = { id = id } })
return { status = 201, json = created }
end)
http.handle("GET", "/bookmarks/{id}", function(req)
local bookmark = db.query_one("bookmarks", { where = { id = req.params.id } })
if not bookmark then
return { status = 404, json = { error = "not found" } }
end
return { status = 200, json = bookmark }
end)
http.handle("PUT", "/bookmarks/{id}", function(req)
if not req.json then
return { status = 400, json = { error = "JSON body required" } }
end
if not db.exists("bookmarks", { where = { id = req.params.id } }) then
return { status = 404, json = { error = "not found" } }
end
local updates = {}
if req.json.url then updates.url = req.json.url end
if req.json.title then updates.title = req.json.title end
if req.json.rating then updates.rating = req.json.rating end
if req.json.notes then updates.notes = req.json.notes end
if next(updates) == nil then
return { status = 400, json = { error = "no fields to update" } }
end
db.update("bookmarks", { set = updates, where = { id = req.params.id } })
local updated = db.query_one("bookmarks", { where = { id = req.params.id } })
return { status = 200, json = updated }
end)
http.handle("DELETE", "/bookmarks/{id}", function(req)
if not db.exists("bookmarks", { where = { id = req.params.id } }) then
return { status = 404, json = { error = "not found" } }
end
db.delete("bookmarks", { where = { id = req.params.id } })
return { status = 204 }
end)
-- Hooks
hooks.on("after_create", "content_data", function(data)
db.insert("activity", {
action = "content_created",
content_id = data.id,
title = data.title or "(untitled)",
})
log.info("Logged new content creation", { content_id = data.id })
end)
-- Lifecycle
function on_init()
db.define_table("collections", {
columns = {
{ name = "name", type = "text", not_null = true },
{ name = "description", type = "text" },
{ name = "sort_order", type = "integer", not_null = true, default = 0 },
{ name = "is_public", type = "boolean", not_null = true, default = 0 },
},
indexes = {
{ columns = {"name"} },
{ columns = {"is_public", "sort_order"} },
},
})
db.define_table("bookmarks", {
columns = {
{ name = "collection_id", type = "text", not_null = true },
{ name = "url", type = "text", not_null = true },
{ name = "title", type = "text", not_null = true },
{ name = "rating", type = "real" },
{ name = "notes", type = "text" },
{ name = "visited_at", type = "timestamp" },
},
indexes = {
{ columns = {"collection_id"} },
{ columns = {"url"} },
},
foreign_keys = {
{
column = "collection_id",
ref_table = "plugin_bookmarks_collections",
ref_column = "id",
on_delete = "CASCADE",
},
},
})
db.define_table("activity", {
columns = {
{ name = "action", type = "text", not_null = true },
{ name = "content_id", type = "text" },
{ name = "title", type = "text" },
},
indexes = {
{ columns = {"action"} },
},
})
log.info("bookmarks plugin initialized")
end
function on_shutdown()
log.info("bookmarks plugin shutting down")
end