Build a Nextcloud app on the Conduction stack — Part 2: Schemas + manifest
Define the category, pet, and order 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 nine-part app-building tutorial series. Part 1 left you with an empty app shell, a chassis, no data. Part 2 fills the chassis: three schemas (modelled on the OpenAPI Pet Store), 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/petstore_register.json and replace the placeholder article schema with the real three. The shape mirrors the canonical OpenAPI Pet Store. 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.
"category": {
"slug": "category",
"title": "Category",
"description": "A category of pets (Dogs, Cats, Reptiles, Fish, ...).",
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "example": "Dogs" },
"description": { "type": "string", "example": "Friendly mammals, four legs, often barks" }
}
}
The pet schema adds the typed relation to category:
"pet": {
"slug": "pet",
"title": "Pet",
"type": "object",
"required": ["name", "category", "status"],
"properties": {
"name": { "type": "string", "example": "doggie" },
"category": {
"type": "string",
"x-openregister-relations": {
"schema": "category",
"cardinality": "many-to-one"
}
},
"status": {
"type": "string",
"enum": ["available", "pending", "sold"]
},
"tags": {
"type": "array",
"items": { "type": "string", "example": "friendly" }
},
"photoUrls": {
"type": "array",
"items": { "type": "string", "format": "uri" }
},
"price": { "type": "number", "minimum": 0, "example": 49.95 },
"notes": { "type": "string" }
}
}
The order schema is the same shape: a relation, a couple of date-times, an enum status, a quantity, and a boolean completion flag.
The full file ships in the petstore repo, with five seed pets, three categories (Dogs, Cats, Fish), and three orders 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": {
"petstore": {
"slug": "petstore",
"title": "PetStore",
"description": "Categories, pets, and orders for the PetStore app.",
"version": "0.2.0",
"schemas": ["category", "pet", "order"]
}
},
"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
The recommended way is to trigger the SettingsController via the app's HTTP API (the app exposes POST /api/settings/load):
curl -X POST -b cookies.txt \
-H "requesttoken: $REQUESTTOKEN" \
http://localhost:8080/index.php/apps/petstore/api/settings/load
The repair step that runs during occ app:enable fires before Nextcloud has loaded peer-app autoloaders. If your InitializeSettings repair step injects OCA\OpenRegister\Service\ConfigurationService, the container can fail to resolve the class and the import bails out partway through (Could not resolve OCA\OpenRegister\Service\ConfigurationService in nextcloud.log). The HTTP path runs inside a fully-booted request where every app's autoloader is on the path, so the import completes cleanly. Fleet apps work around this by using a repair step that defers to a runtime trigger; see decidesk's InitializeRegister.php for the canonical pattern.
Alternatively, disable + re-enable the app, which fires the <install> repair step from appinfo/info.xml (works when the autoloader is healthy, see the tip above):
docker exec nextcloud php occ app:disable petstore
docker exec nextcloud php occ app:enable petstore
Either way, you should see in the OpenRegister registers list:
slug: petstore title: PetStore application: petstore schemas: category, pet, order
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 pattern (Configuration file not found: html/custom_apps/petstore/...) is a relative-path mistake. Use \OC::$SERVERROOT as the anchor when stripping prefixes.
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://codeberg.org/Conduction/nextcloud-vue/raw/branch/main/src/schemas/app-manifest-v2.schema.json",
"version": "2.0.0",
"dependencies": ["openregister"],
"menu": [
{"id": "pets-index", "label": "petstore.menu.pets", "icon": "icon-category-organization", "route": "pets-index", "order": 10},
{"id": "orders-index", "label": "petstore.menu.orders", "icon": "icon-calendar", "route": "orders-index", "order": 20},
{"id": "categories-index", "label": "petstore.menu.categories", "icon": "icon-category-monitoring", "route": "categories-index", "order": 30}
],
"pages": [
{"id": "pets-index", "route": "/", "type": "index", "title": "petstore.pets.indexTitle",
"config": {"register": "petstore", "schema": "pet",
"columns": ["name","category","status","tags","price"],
"filters": ["category","status","tags"],
"defaultSort": {"key": "name", "order": "asc"}}},
{"id": "pets-detail", "route": "/pets/:id", "type": "detail", "title": "petstore.pets.detailTitle",
"config": {"register": "petstore", "schema": "pet", "sidebar": true}}
/* orders + categories 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 PetStore 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: 'petstore', 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/petstore'),
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: 'notes', 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/petstore/
docker exec nextcloud apache2ctl graceful
Open /apps/petstore/. The left rail now reads Pets · Orders · Categories (the translation keys will look like petstore.menu.pets 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 pet. 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 PetsList.vue, no PetForm.vue, no usePets.js. When you add a price field to the pet schema, every place a pet appears (table column, detail row, filter sidebar, form dialog) picks up the new field automatically.
This is the schema-driven discipline described 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 order schema makes every order's ship-date appear in the user's Nextcloud Calendar, no controller, no event, no listener.