Build a Nextcloud app on the Conduction stack — Part 2: Schemas + manifest
Define the desk, booking, and floor schemas in OpenRegister, declare them in a manifest.json, and watch CnAppRoot replace ~200 lines of hand-rolled Vue with three lines of config.
This is Part 2 of the six-part DeskDesk tutorial. Part 1 left you with an empty app shell — a chassis, no data. Part 2 fills the chassis: three schemas, a manifest, and the same five Cn* pages drive every list, every detail view, every dashboard with the schema as the single source of truth.
The shape we keep saying "this saves you code" finally has numbers behind it: ~200 lines of hand-rolled Vue collapse to three.
Step 1: Define the three schemas
Open lib/Settings/deskdesk_register.json and replace the placeholder article schema with the real three. Each schema is a JSON Schema with a couple of OpenRegister extensions: slug for the URL identifier, icon for the MDI glyph the UI uses, and x-openregister-relations for typed cross-schema links.
"floor": {
"slug": "floor",
"title": "Floor",
"description": "A physical floor in a building.",
"type": "object",
"required": ["label"],
"properties": {
"label": { "type": "string", "example": "Floor 3" },
"building": { "type": "string", "example": "Amsterdam HQ" },
"planImage": { "type": "string", "format": "uri" }
}
}
The desk schema adds the typed relation to floor:
"desk": {
"slug": "desk",
"title": "Desk",
"type": "object",
"required": ["label", "floor", "zone"],
"properties": {
"label": { "type": "string", "example": "3-East-12" },
"floor": {
"type": "string",
"x-openregister-relations": {
"schema": "floor",
"cardinality": "many-to-one"
}
},
"zone": {
"type": "string",
"enum": ["north", "south", "east", "west", "central"]
},
"equipment": {
"type": "array",
"items": { "type": "string", "enum": ["dual-monitor", "standing", "phonebooth-adjacent", "wired-network", "extra-power"] }
},
"capacity": { "type": "integer", "minimum": 1, "default": 1 },
"accessibility": { "type": "boolean", "default": false },
"photo": { "type": "string", "format": "uri" },
"notes": { "type": "string" }
}
}
Booking is the same shape — a relation, a couple of date-times, an enum status, and an optional RRULE for recurring bookings.
The full file ships in the deskdesk repo, with five seed desks, two floors, and three bookings so the app is browsable on first install.
Step 2: Declare the register itself
OpenRegister's import handler creates schemas eagerly, but it only creates a register when the JSON declares one explicitly. Add a components.registers block above your schemas:
"components": {
"registers": {
"deskdesk": {
"slug": "deskdesk",
"title": "DeskDesk",
"description": "Floors, desks, and bookings for the DeskDesk app.",
"version": "0.2.0",
"schemas": ["floor", "desk", "booking"]
}
},
"schemas": { /* ... */ },
"objects": { /* ... */ }
}
The schemas array tells OpenRegister which of the schemas in the same file belong to this register. The slug becomes the register's stable identifier.
Step 3: Trigger the import
Two ways. Either trigger it via the SettingsController (the app exposes POST /api/settings/load):
curl -X POST -b cookies.txt \
-H "requesttoken: $REQUESTTOKEN" \
http://localhost:8080/index.php/apps/deskdesk/api/settings/load
Or — cleaner during local development — disable + re-enable the app, which fires the <install> repair step from appinfo/info.xml:
docker exec nextcloud php occ app:disable deskdesk
docker exec nextcloud php occ app:enable deskdesk
Either way, you should see in the OpenRegister registers list:
slug: deskdesk title: DeskDesk application: deskdesk schemas: floor, desk, booking
The matching path the SettingsService takes is importFromFilePath with a path resolved relative to \OC::$SERVERROOT (which is /var/www/html in a default install). The boot-time bug I hit during this tutorial — Configuration file not found: html/custom_apps/deskdesk/... — was a relative-path mistake; the fix lands in the diff.
Step 4: The manifest
src/manifest.json is the single declarative description of the app shell. Four top-level keys you care about: $schema, dependencies, menu, pages.
{
"$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
"version": "2.0.0",
"dependencies": ["openregister"],
"menu": [
{"id": "desks-index", "label": "deskdesk.menu.desks", "icon": "icon-category-organization", "route": "desks-index", "order": 10},
{"id": "bookings-index", "label": "deskdesk.menu.bookings", "icon": "icon-calendar", "route": "bookings-index", "order": 20},
{"id": "floors-index", "label": "deskdesk.menu.floors", "icon": "icon-category-monitoring", "route": "floors-index", "order": 30}
],
"pages": [
{"id": "desks-index", "route": "/", "type": "index", "title": "deskdesk.desks.indexTitle",
"config": {"register": "deskdesk", "schema": "desk",
"columns": ["label","floor","zone","equipment","capacity","accessibility"],
"filters": ["floor","zone","equipment","accessibility"],
"defaultSort": {"key": "label", "order": "asc"}}},
{"id": "desks-detail", "route": "/desks/:id", "type": "detail", "title": "deskdesk.desks.detailTitle",
"config": {"register": "deskdesk", "schema": "desk", "sidebar": true}}
/* bookings + floors pages follow the same pattern */
]
}
A few rules to note:
- The
$schemaURL points atapp-manifest-v2.schema.json. This is the v2 schema (currently at version 2.7.0). It's the source of truth for whatmanifest.jsonis allowed to contain — your IDE picks up autocompletion and validation from it the moment the URL is present. page.idis the vue-router route name.CnPageRenderermatches$route.name === page.idto pick the right page entry frompages[].page.typeis a closed enum. The 13 supported types:index | detail | dashboard | logs | settings | chat | files | form | wiki | map | search | roadmap | custom. Most apps will useindex,detail,dashboard, andcustom; the others are there when you need them. See Part 5 forform/wiki/search/mapexamples.page.configis forwarded as props.register,schema,columns,filters,defaultSortmap toCnIndexPageprops one-to-one. The renderer also forwards top-level page fields (title,widgets,actions,sidebar,description,icon) alongsideconfig, and bridgesconfig.schema → objectTypeplus:id → objectIdfortype:'detail'so the lib's externalCnObjectSidebaractivates automatically.config.sidebar: trueontype:'detail'activates the host's mountedCnObjectSidebarwith its built-in tabs (Files, Notes, Tags, Tasks, Audit Trail) for the current object. Without it the detail page renders without a sidebar — useful for read-only views, but for DeskDesk every detail page wants the standard tabs, so we set it everywhere.
Step 5: Replace App.vue and the router
Now the satisfying part. Open src/App.vue — the 175-line shell from Part 1 — and replace it with this:
<template>
<CnAppRoot :app-id="appId" :manifest="manifest" />
</template>
<script>
import { CnAppRoot } from '@conduction/nextcloud-vue'
import manifest from './manifest.json'
export default {
name: 'App',
components: { CnAppRoot },
data: () => ({ appId: 'deskdesk', manifest }),
}
</script>
That's it. The <NcContent>, the dependency-check empty-state, the loading spinner, the menu, the sidebars, the user-settings dialog — CnAppRoot ships them all and gates them on the manifest's dependencies array.
Then src/router/index.js becomes a manifest-to-route mapping:
import Vue from 'vue'
import Router from 'vue-router'
import { generateUrl } from '@nextcloud/router'
import { CnPageRenderer } from '@conduction/nextcloud-vue'
import manifest from '../manifest.json'
Vue.use(Router)
const routes = manifest.pages.map((page) => ({
name: page.id,
path: page.route,
component: CnPageRenderer,
}))
routes.push({ path: '*', redirect: '/' })
export default new Router({
mode: 'history',
base: generateUrl('/apps/deskdesk'),
routes,
})
Every page in the manifest gets a route. Every route renders CnPageRenderer. CnPageRenderer reads the manifest, finds the matching page entry by route name, and dispatches by page.type. The most common dispatches:
type: "index"→CnIndexPagewith the schema's columns + filterstype: "detail"→CnDetailPagewith the schema's properties + the lib's external sidebartype: "dashboard"→CnDashboardPagewith the layout gridtype: "form"→CnFormPagefor create/edit flows (supports public-mode token-scoped variant)type: "wiki"→CnWikiPagefor markdown-article surfacestype: "search"→ faceted cross-schema searchtype: "custom"→ your own SFC registered incustomComponents
Schema 2.7.0 (the current beta) introduces a few v2-only shapes worth flagging — full coverage lives in Part 5, but it's useful to know the names now:
config.actionToggles— typed object ontype:'index'collapsing the nineshow*/selectableflags into one place.actionToggles: { showAdd: true, showEdit: false, showDelete: false }reads cleaner than nine sibling booleans.config.fieldWidgets[]— typed slot ontype:'form'andtype:'detail'for mounting a libCn*component as a single form field. e.g.fieldWidgets: [{id: 'body', component: 'CnMarkdownEditor'}].config.mode: 'public'— ontype:'form'andtype:'detail', marks an unauthenticated token-scoped page. Pair with a route-param sentinel likesubmitEndpoint: "/index.php/apps/myapp/api/public/:token".config.sidebar: true | {...}— boolean or object form. Object form ({enabled, show, register, schema, hiddenTabs, tabs}) gives full control; the boolean is the shortcut for the common case._noteontype:'custom'— required when the custom mounts a host-app SFC (no libCn*analogue); softened in 2.7.0 so that a custom withcomponent: 'CnSomething'no longer needs it.
You won't use most of those in Part 2 — three index pages, three detail pages, one dashboard, all schema-driven. They're listed here so when Part 5 reaches for them they're not surprises.
Step 6: Delete the old views
The whole point of Part 2 is that the framework writes the views for you. Delete:
src/navigation/MainMenu.vue— replaced by CnAppNav (auto-mounted by CnAppRoot)src/views/Dashboard.vue— placeholder; will return in a richer form when the dashboard page wants itsrc/views/items/ItemList.vue,src/views/items/ItemDetail.vue— replaced by CnIndexPage + CnDetailPagesrc/views/settings/UserSettings.vue,src/views/settings/Settings.vue— settings live on the admin page (AdminRoot.vue) and the in-app dialog ships with CnAppRoot
The before-and-after on disk:
before: 12 .vue files in src/, ~600 lines of hand-rolled component code
after: 3 .vue files (App.vue, AdminRoot.vue, that's it), ~30 lines
Step 7: Build, reload, browse
npm run build
docker cp ./js nextcloud:/var/www/html/custom_apps/deskdesk/
docker exec nextcloud apache2ctl graceful
Open /apps/deskdesk/. The left rail now reads Desks · Bookings · Floors (the translation keys will look like deskdesk.menu.desks until you add the l10n/ entries — that's a follow-up). The main column shows CnIndexPage with the schema's columns. Click a row, you're on CnDetailPage for that desk. The whole CRUD flow comes for free.
Why this matters
You wrote three JSON files. The framework reads them and renders the app. There is no DesksList.vue, no DeskForm.vue, no useDesks.js. When you add a priority field to the booking schema, every place a booking appears — table column, detail row, filter sidebar, form dialog — picks up the new field automatically.
This is the schema-driven discipline talked about on nextcloud-vue.conduction.nl. Part 3 puts it to work in a way that surprises people the first time: a single JSON block on the booking schema makes every booking appear in the user's Nextcloud Calendar, no controller, no event, no listener.
