Offline-first inspection architecture
Overview
This document describes a pragmatic architecture for applications that manage questionnaires and inspections (roughly 300–400 questions), support photos and local storage, and synchronize with a remote API. The approach is designed for Next.js with a local SQLite database.
Design goals
- Clear separation of concerns: UI, domain logic, persistence, sync, and server API.
- Reliable local-first behavior with durable local saves and a straightforward sync model.
- Maintainable, testable modules with minimal coupling between layers.
Architecture layers
- UI — pages and components (Next.js App Router)
- Domain features — feature modules for inspections, questionnaires, vessels, etc.
- Local database — SQLite schema and access layer
- Sync engine — queue, background worker, and retry logic
- Server API — endpoints that accept and apply client changes
Project structure (recommended)
src/
├── app/ # Next.js App Router
│ ├── (dashboard)/
│ │ ├── inspections/
│ │ │ ├── page.tsx
│ │ │ └── [inspectionId]/page.tsx
│ │ ├── vessels/page.tsx
│ │ └── questionnaires/page.tsx
│ ├── api/sync/route.ts # sync endpoint(s)
│ ├── layout.tsx
│ └── page.tsx
├── features/ # domain modules (inspections, questionnaires, vessels)
├── db/ # local database (schema, migrations)
├── sync/ # sync engine and queue
├── store/ # UI state
├── components/ # shared UI components
├── hooks/ # custom hooks (useInspection, useSync)
├── lib/ # utilities
└── types/Local database
Keep the schema compact and versioned. Use a snapshot pattern for inspection items so each inspection stores a stable copy of questionnaire text when created.
export const inspections = sqliteTable("inspections", {
id: text("id").primaryKey(),
vesselId: text("vessel_id"),
questionnaireId: text("questionnaire_id"),
date: text("date"),
})
export const inspectionItems = sqliteTable("inspection_items", {
id: text("id").primaryKey(),
inspectionId: text("inspection_id"),
chapter: text("chapter"),
section: text("section"),
question: text("question"),
text: text("text"),
answer: text("answer"),
remarks: text("remarks"),
syncStatus: text("sync_status")
})Possible answer values: YES, NO, NA, NOT_SEEN.
Domain service examples
Keep domain logic inside feature modules. Services should operate on the local database and maintain sync metadata.
export async function getInspectionItems(inspectionId: string) {
return db
.select()
.from(inspectionItems)
.where(eq(inspectionItems.inspectionId, inspectionId))
}export async function answerQuestion(id: string, answer: string) {
await db
.update(inspectionItems)
.set({ answer, syncStatus: "pending" })
.where(eq(inspectionItems.id, id))
}Sync queue and engine
Local changes should append a compact entry to a sync_queue table. A background worker (triggered at app start, on network reconnect, or manually) attempts to send pending items to the server and marks items as synced or failed with retry metadata.
Example queue columns: id, entity_type, entity_id, operation, payload, status, created_at.
export async function runSync() {
const items = await getPendingQueue()
for (const item of items) {
// POST to /api/sync and handle success/failure with retries
}
}Sync considerations (common pitfalls)
- Idempotency: ensure server handlers are idempotent (use operation IDs).
- Conflict handling: choose a merge strategy (last-write-wins, server-authoritative, or manual reconciliation).
- Large payloads: upload large attachments (photos) separately and reference them in sync payloads.
Example server API
POST /api/sync should validate, apply, and respond with a clear status. Server-side storage can be PostgreSQL, SQLite, or MySQL depending on scale and operational needs.
UI and components
Design the inspection UI around the snapshot model. Typical composition:
InspectionPage
├─ ChapterSidebar
├─ ProgressBar
└─ QuestionList (QuestionCard components)Example QuestionCard (conceptual):
export function QuestionCard({ item }) {
return (
<div>
<p>{item.question} {item.text}</p>
<div className="actions">
<button>YES</button>
<button>NO</button>
<button>N/A</button>
<button>NOT SEEN</button>
</div>
<textarea placeholder="Remarks" />
</div>
)
}Progress tracking
Compute progress locally from the inspection items: answered / total.
Attachments
Store photos locally and reference file paths in an attachments table with sync_status. Upload attachments during sync; prefer resumable or chunked uploads for large files.
Recommended development phases
Phase 1 — Data
1. Define SQLite schema and migrations
2. Implement questionnaire import from CSV (preserve versions)
Phase 2 — Core
3. Create inspection entity and snapshot generation
4. Implement local read/write services
Phase 3 — UI
5. Chapter navigation and question list
6. Question card interactions and local save
7. Progress indicator and reporting views
Phase 4 — Offline & Sync
8. Persist local saves reliably
9. Implement sync queue and retry logic
10. Implement background sync engine and attachment uploadWhy this architecture works
- Snapshot table keeps each inspection consistent for reporting and audits.
- Local SQLite provides robust offline behavior.
- Background sync with a queue handles intermittent connectivity.
- Versioned questionnaires allow safe updates without corrupting past inspections.
Next steps
- Implement idempotent server endpoints and a conflict resolution strategy.
- Add tests for the sync flow (unit + integration with a test DB).
- Optionally, add a lightweight script to generate a questionnaire DB from CSV.