Starter template for building external modules for the Nekazari platform.
Modules are built as Module Federation 2.0 remotes (dist/remoteEntry.js + dist/mf-manifest.json + dist/assets/) plus a dist/manifest.json. All are uploaded to MinIO and loaded at runtime by the host via loadRemote(). No build-time coupling to the host.
git clone https://github.com/nkz-os/nkz-module-template.git my-module
cd my-module
pnpm installDo a find-and-replace across the repo for these placeholders:
| Placeholder | Example value | Where |
|---|---|---|
MODULE_NAME |
soil-sensor |
package.json (name, nkz.moduleId), Module.tsx (id), k8s/, SQL |
MODULE_DISPLAY_NAME |
Soil Sensor |
Module.tsx (displayName), locales/, k8s/, SQL |
MODULE_ROUTE |
/soil-sensor |
Module.tsx (route), SQL |
YOUR_ORG |
acme-corp |
k8s/backend-deployment.yaml, SQL |
Then edit src/Module.tsx to declare your slots, accent colour, navigation entry, permissions, and data dependencies.
my-module/
├── src/
│ ├── Module.tsx # SINGLE SOURCE OF TRUTH — defineModule({...})
│ ├── App.tsx # Main page component (rendered at route)
│ ├── main.tsx # Dev-only entry (Vite) — wraps in MockProvider
│ ├── slots/index.ts # Declare which host slots you occupy
│ ├── components/slots/ # Slot React components
│ ├── locales/{en,es}.json # i18n bundles
│ └── types/ # TypeScript types
├── backend/ # FastAPI backend (optional, delete if unused)
├── k8s/
│ ├── backend-deployment.yaml # K8s Deployment + Service for backend
│ └── registration.sql # Insert/update marketplace_modules
├── vite.config.ts # One-liner: defineConfig(nkzModulePreset())
├── package.json # nkz.moduleId points here
└── dist/
├── remoteEntry.js # Federation remote entry
├── mf-manifest.json # Federation manifest (shared deps + exposes)
├── manifest.json # NKZ data manifest (auto-generated)
└── assets/ # Sync + async chunks
Edit src/Module.tsx:
import { defineModule } from '@nekazari/module-kit';
import { lazy } from 'react';
import { moduleSlots } from './slots';
const MainPage = lazy(() => import('./App'));
export default defineModule({
id: 'soil-sensor',
displayName: 'Soil Sensor',
version: '1.0.0',
hostApiVersion: '^2.0.0',
accent: { base: '#A16207', soft: '#FEF3C7', strong: '#713F12' },
icon: 'sprout',
main: MainPage,
route: '/soil-sensor',
navigation: { section: 'modules', priority: 60 },
slots: moduleSlots,
api: { basePath: '/api/soil-sensor' }, // optional — only if backend
requiredRoles: ['Farmer', 'TenantAdmin'],
requiredPlan: 'basic',
data: {
entities: ['AgriParcel', 'AgriSoil'], // CSP-of-data allowlist
timeseries: ['soil_observations'],
},
});The dist/manifest.json is auto-emitted from this declaration. You don't write it by hand.
All hooks from @nekazari/module-kit resolve inside the host (production) or against in-memory mocks (pnpm run dev).
const { user, tenantId, roles, hasRole, hasPlan } = useAuth();
const { t, lang, setLang } = useI18n();
const { emit, on } = usePlatformEvents(); // namespaced to module:<id>:
// NGSI-LD entities (CRUD + cache via TanStack Query)
const { data: parcels } = useEntities('AgriParcel', { q: 'category=="vineyard"' });
const { data: parcel } = useEntity('urn:ngsi-ld:AgriParcel:42');
const { mutateAsync: createParcel } = useCreateEntity();
// Timescale
const { data: temps } = useTimeseries({
entityId: 'urn:ngsi-ld:WeatherObserved:station-1',
attribute: 'temperature',
from: new Date(Date.now() - 7 * 86400_000),
to: new Date(),
});
// File storage scoped to tenants/<tenant>/modules/<id>/
const { upload, getUrl } = useFiles();
const { url } = await upload(file, 'reports/2026/foo.pdf');
// Your own backend (basePath from defineModule({ api }))
const { data: forecast } = useGet<Forecast>('/forecast/today');
const { mutateAsync: createOrder } = usePost<{ ok: boolean }, OrderBody>('/orders');You never write fetch, never handle JWT cookies, never construct Fiware-Service headers.
pnpm run build
# → dist/remoteEntry.js (federation remote entry)
# → dist/mf-manifest.json (shared deps + exposes)
# → dist/manifest.json (NKZ metadata for host + gateway CSP)
# → dist/assets/ (sync + async chunks)The @nekazari/module-builder@^2.0.3 preset (nkzModulePreset()) configures Module Federation 2.0 via @module-federation/vite:
- Singleton shared deps —
react,react-dom,react-router-dom,@nekazari/*,i18next,react-i18nextresolved by the host at runtime. Never bundle them. src/Module.tsx→export default defineModule({...})is the single entry point. The builder auto-generates the federation expose.
pnpm run dev
# Vite dev server at http://localhost:5003
# Wraps the module in MockProvider — useAuth/useOrion/useFiles/etc. return
# in-memory fixtures, no platform required.For integration with a real backend, set VITE_PROXY_TARGET=https://your-api-domain in .env.
mc cp --recursive dist/ minio-srv/nekazari-frontend/modules/MODULE_NAME/kubectl exec -n nekazari deployment/postgresql -- \
psql -U postgres -d nekazari -f /tmp/registration.sqlOr insert manually and set remote_entry_url = '/modules/MODULE_NAME/mf-manifest.json'.
docker build -t ghcr.io/YOUR_ORG/MODULE_NAME-backend:v1.0.0 ./backend
docker push ghcr.io/YOUR_ORG/MODULE_NAME-backend:v1.0.0
kubectl apply -f k8s/backend-deployment.yaml -n nekazariAdd an ingress rule routing /api/MODULE_NAME → MODULE_NAME-api-service:8000 before the generic /api catch-all.
Edit src/slots/index.ts to register your components in host slots:
import { ExampleSlot } from '../components/slots/ExampleSlot';
export const moduleSlots = {
'context-panel': [
{ id: 'soil-sensor-panel', component: ExampleSlot, priority: 10 },
],
};Available slot types:
| Slot | Where it renders |
|---|---|
context-panel |
Side panel when an entity is selected |
bottom-panel |
Tabbed panel at the bottom of the viewer |
map-layer |
Overlay or toolbar button on the 3D map |
layer-toggle |
Toggle entry in the layer panel |
entity-tree |
Context menu in the entity tree |
dashboard-widget |
Card in the tenant dashboard |
The module-kit translates your {id, component, priority} entries into the runtime SlotWidgetDefinition shape automatically — component is the actual React reference, not a string.
When the bundle calls a platform API, the SDK injects X-Module-Id. The gateway reads it, fetches your module's manifest.json from MinIO, and validates that the requested type= (NGSI-LD) or hypertable (Timescale) appears in your declared data.entities / data.timeseries.
- No
data.entitiesdeclared → fail-open (legacy modules keep working). data.entities: ['*']→ wildcard, opt out of enforcement (not recommended).data.entities: ['AgriParcel']→ only?type=AgriParcelis allowed; anything else returns 403.
Declare exactly what you need. This is the platform's lightweight defence-in-depth — no replacement for sandboxing.
- Keep
i18next@^23.11.0andreact-i18next@^14.1.0— must match the host's singleton versions to avoid federation runtime version mismatch warnings. - Never bundle shared deps — React, ReactDOM, react-router-dom,
@nekazari/*, i18next, react-i18next. They come from the host as federation singletons. Bundling creates two instances and breaks hooks. mainwrapper pattern — prefer wrappinglazy(() => import('./App'))in a regular function component for Suspense boundaries and context providers. Seenkz-module-vegetation-health/src/Module.tsx.
Apache-2.0 — you are free to license your derived module under any terms.