Creating SPA/PWA with Jumentix
Responsibility in context
- Owns: Frontend/offline journey for Service Management style apps
- Used with: designer-core, cana, service-management app hub
- Not responsible for: Server DB repositories or gRPC Node clients
What it is
This guide describes how to plan and deliver a Single Page Application (SPA) or Progressive Web App (PWA) that stays aligned with backend contracts and supports offline-first storage through Jumentix packages and Service Management designers.
Why it exists
Frontend and backend teams often diverge after the first sprint — DTO shapes drift, realtime channels are guessed, and “offline support” becomes a fragile localStorage hack. Jumentix keeps one design document for domains and contracts, generates matching SDKs, and uses Cana (IndexedDB) for durable offline data.
Prerequisites
| Item | Required | Notes |
|---|---|---|
| Bun 1.3.13+ | Yes | Getting started |
| Service Management app | Recommended | Domain Designer + Communication Interface Designer |
| Backend contracts | Yes | OpenAPI and/or AsyncAPI from REST/realtime guides |
| Browser with IndexedDB | Yes | Chrome, Firefox, Safari, Edge |
Glossary
| Term | Meaning on this page |
|---|---|
| SPA | Single-page app — UI loads once; routing happens client-side. |
| PWA | Web app installable/offline-capable via service worker + local storage. |
| Domain Designer | Service Management tool for entities, relationships, bounded contexts. |
| Communication Interface Designer | Tool picking REST-only vs WebSocket vs gRPC consumption. |
| designer-core | @jumentix/designer-core — validates design documents (domains, relationships). |
| Cana | @jumentix/cana — IndexedDB adapter; not the same as localStorage. |
| Conflict strategy | Rule when offline edits sync — e.g. last-write-wins or domain merge. |
| Service profile | Env keys like JUMENTIX_HTTP_FRAMEWORK chosen in Service Configuration. |
Numbered steps
Step 1 — Model business domains first (< 20 minutes)
Open Service Management → Domain Designer and define:
- Domains and bounded contexts — what belongs together vs what is separate.
- Entities and value objects — fields, types, invariants.
- Relationships and constraints — cardinality, required links, uniqueness.
- API and event contracts — which resources expose REST vs realtime channels.
This keeps frontend state models and backend OpenAPI/AsyncAPI aligned from iteration one.
Success check: exported design validates with zero blocking issues in designer-core.
Step 2 — Define communication contracts (< 10 minutes)
Use Communication Interface Designer to choose how the SPA talks to backend:
| Mode | Choose when | Client package |
|---|---|---|
| REST only | CRUD screens, no live push | @jumentix/sdk-rest-client |
| WebSocket + REST | Live dashboards, notifications | @jumentix/sdk-websocket-client + REST fallback |
| gRPC + REST | Node-based BFF consuming backend | gRPC SDK (Node) + REST for browser |
Contracts become the source for SDK integration — do not hand-write fetch URLs that are not in OpenAPI.
Success check: OpenAPI/AsyncAPI files match designer export; operationIds stable.
Step 3 — Configure service runtime (< 10 minutes)
In Service Management → Service Configuration:
- Pick service type:
RESTAPI,websocketAPI + RESTAPI, orgrpcAPI + RESTAPI. - Set deployment target and runtime profile.
- Edit env keys the backend will use:
JUMENTIX_HTTP_FRAMEWORK=express
JUMENTIX_REALTIME_API=yes # if using realtime
JUMENTIX_REALTIME_API_PROTOCOL=websocket # browser pathCross-check REST and Realtime guides for startup commands.
Success check: backend dev profile starts with the same env the designer exported.
Step 4 — Build offline-capable PWA storage (< 30 minutes)
For offline-first architecture:
- Validate design with
@jumentix/designer-corebefore persisting data. - Open Cana IndexedDB database matching your entity stores.
- Read/write locally during offline periods; queue sync operations.
- Sync asynchronously when online — use REST/WS contracts, not ad-hoc JSON.
- Document conflict strategy explicitly (last-write-wins or domain-specific merge).
Static offline write:
const db = await cana.open({ name: 'my-pwa', version: 1 });
await db.put('tasks', { id: '1', title: 'Draft offline', updatedAt: Date.now() });Success check: data survives browser reload; client.backend reports indexeddb.
Step 5 — Validate delivery readiness (< 15 minutes)
Run these gates before calling the PWA “done”:
| Gate | Command / action |
|---|---|
| Contract alignment | OpenAPI/AsyncAPI diff vs designer export |
| Backend CI | bun run test:unit, bun run oas:check-routes |
| Frontend build | Production bundle builds without contract import errors |
| Offline smoke | Load app offline; Cana records still readable |
| Playgrounds | designer-core + Cana Run green below |
Success check: all gates pass; offline reload shows persisted entities.
Step 6 — Wire designers to storage (hands-on)
Validate a design document, then persist records with Cana.
designer-core — validates { domains, relationships } shape:
Validate a design
Normalize the sample model and collect validation issues (real designer-core API).
### Validate a design
```ts
const raw = api.buildSampleModelPayload();
const state = api.normalizeStatePayload(raw);
const issues = api.collectModelIssues(state);
const errors = issues.filter((issue) => issue.severity === 'error');
return {
ok: errors.length === 0,
issueCount: issues.length,
errorCount: errors.length,
sample: issues.slice(0, 3)
};
```const raw = api.buildSampleModelPayload();
const state = api.normalizeStatePayload(raw);
const issues = api.collectModelIssues(state);
const errors = issues.filter((issue) => issue.severity === 'error');
return {
ok: errors.length === 0,
issueCount: issues.length,
errorCount: errors.length,
sample: issues.slice(0, 3)
};Cana — IndexedDB put/get inside validated entity stores:
Getting started
Open a client, create Category and Task records, then read them back.
### Getting started
```ts
const client = cana.createClient({
name: dbName,
schema: {
version: 1,
stores: [
{ name: 'categories', keyPath: 'id', indexes: [{ name: 'byName', keyPath: 'name', unique: true }] },
{
name: 'tasks',
keyPath: 'id',
indexes: [
{ name: 'byCategory', keyPath: 'categoryId' },
{ name: 'byCompleted', keyPath: 'completed' },
{ name: 'byUpdatedAt', keyPath: 'updatedAt' }
]
}
]
}
});
await client.open();
await client.table('categories').add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: Date.now(),
updatedAt: Date.now()
});
await client.table('tasks').add({
id: 'task-1',
title: 'Write the Cana tutorial',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: Date.now(),
updatedAt: Date.now()
});
return {
backend: client.backend,
category: await client.table('categories').get('work'),
task: await client.table('tasks').get('task-1')
};
```const client = cana.createClient({
name: dbName,
schema: {
version: 1,
stores: [
{ name: 'categories', keyPath: 'id', indexes: [{ name: 'byName', keyPath: 'name', unique: true }] },
{
name: 'tasks',
keyPath: 'id',
indexes: [
{ name: 'byCategory', keyPath: 'categoryId' },
{ name: 'byCompleted', keyPath: 'completed' },
{ name: 'byUpdatedAt', keyPath: 'updatedAt' }
]
}
]
}
});
await client.open();
await client.table('categories').add({
id: 'work',
name: 'Work',
color: '#2563eb',
createdAt: Date.now(),
updatedAt: Date.now()
});
await client.table('tasks').add({
id: 'task-1',
title: 'Write the Cana tutorial',
categoryId: 'work',
completed: false,
priority: 'high',
createdAt: Date.now(),
updatedAt: Date.now()
});
return {
backend: client.backend,
category: await client.table('categories').get('work'),
task: await client.table('tasks').get('task-1')
};Common errors
| Symptom | Likely cause | Fix | Verify success |
|---|---|---|---|
designer-core Run throws on forEach | Invalid payload shape (entities toy object) | Use { domains, relationships } via buildSampleModelPayload pattern | Playground { ok: true } |
TransactionInactive in Cana | Network I/O inside Cana transaction | Only IndexedDB awaits inside tx callback | Cana playground Run green |
| SPA calls wrong URL | Hand-written paths bypass OpenAPI | Generate calls from @jumentix/sdk-rest-client | Network tab matches spec paths |
| Offline data missing after reload | Used localStorage instead of Cana | Migrate stores to Cana IndexedDB | Reload retains records |
| Realtime works locally, fails prod | WS URL not in AsyncAPI / env mismatch | Align Service Configuration env with deployment | WS connect + subscribe smoke |
| Sync conflicts silently overwrite | No documented conflict strategy | Pick LWW or merge rules per entity | Test offline edit + online sync |
Junior checklist (“I can …”)
- Model one bounded context in Domain Designer with entities and relationships.
- Choose REST-only or WebSocket+REST in Communication Interface Designer.
- Explain why Cana is not
localStorage. - Run designer-core playground (Run green, valid model issues list).
- Run Cana playground (Run green, records created).
- Name my conflict strategy for at least one entity.
- Link frontend SDK calls to OpenAPI
operationIds.
Next step
Deepen offline and design skills on Cana usage and designer-core usage, then explore the full Packages map for persistence and communication helpers.