Guidely architecture
Guidely is a product-adoption platform: a management dashboard where product teams author in-app guides, and an SDK that customers install into their React, Next.js, Svelte, SvelteKit or plain JavaScript applications to render those guides to end users.
Repository layout
apps/
web Next.js 15 App Router dashboard + management API + public SDK API + worker
demo-react Sample SaaS app ("Northwind CRM") built with Next.js, with @guidelytech/react installed
demo-svelte The same sample SaaS app built with SvelteKit, with @guidelytech/svelte installed
packages/
shared Zod schemas and TypeScript types shared by the API, editor, and SDK
guide-runtime Browser engine: selectors, positioning (floating-ui), overlays, step rendering (Shadow DOM)
sdk-core Framework-independent client: config loading, identity, events, triggers, targeting, storage
react <GuidelyProvider/> and hooks (wraps sdk-core)
nextjs Next.js helpers (App Router route-change bridge, env helpers)
svelte createGuidely(), stores, actions (wraps sdk-core)
sveltekit SvelteKit helpers (navigation bridge, load helpers)
guide-editor Reusable React editor components (step list, properties, preview, rule builder)
embed Script-tag build that exposes window.Guidely
e2e/ Playwright end-to-end suite (dashboard + both demo apps)
docs/ Documentation
Runtime topology
┌──────────────────────────────┐ ┌───────────────────────────────────────────┐
│ Customer application │ │ apps/web (Next.js) │
│ @guidelytech/react | svelte │ HTTPS │ /api/v1/sdk/* public SDK API │
│ └─ @guidelytech/sdk-core ────┼───────►│ (env public key + CORS allow-list) │
│ └─ guide-runtime │ │ /api/v1/* management API │
│ (Shadow DOM overlays) │ │ (session cookie + workspace RBAC) │
└──────────────────────────────┘ │ /app/* dashboard UI │
│ worker.ts pg-boss jobs │
└──────────┬───────────────────┬────────────┘
│ Prisma │ optional
┌─────▼─────┐ ┌──────▼─────┐
│ PostgreSQL │ │ Redis │
└───────────┘ └────────────┘
- The **management API** and the **public SDK API** are separate route groups with separate
authentication. Management routes require a session cookie and verify workspace membership and role on every request. SDK routes authenticate with an environment's public key (
pk_<env>_<random>), which is not a secret: it only unlocks published configuration and event ingestion for that environment, restricted by allowed origins. - Dashboard secrets (session secret, database) never reach the browser SDK.
Core domain model
See apps/web/prisma/schema.prisma for the authoritative schema. Key decisions:
- **Guide is the universal content entity.** Tours, modals, tooltips, banners, checklists, and
surveys are all
Guiderows with akind. They share one draft/version/publish pipeline, one targeting model, one SDK delivery path, and one analytics pipeline. Checklist tasks and survey questions live inside the versioned guide config (JSON, validated by Zod). Progress and responses are relational tables (ChecklistProgress,SurveyResponse) because they are queried for reporting. - **Immutable versions.** `Guide.draftConfig` is the editable working copy. Publishing snapshots it
into a
GuideVersion(append-only).GuideEnvironmentStatepoints each environment at the live version. Rollback repoints the environment at an older version; it never mutates a version. Every publish/rollback/pause is recorded inPublishEventandAuditLog. - **Tenant scoping.** Every tenant-owned table carries `workspaceId` (directly or via project).
Service functions take an authenticated
Actorand resolve the workspace from the resource, never from client input. - End users (
EndUser,Account) are the customer's users, distinct from dashboardUser.
Guide configuration (shared/zod)
GuideConfig = { steps: Step[], targeting: RuleGroup, trigger: TriggerConfig, frequency: FrequencyConfig, appearance, checklist?, survey? }. Each Step has a type (modal | tooltip | popover | spotlight | hotspot | banner | slideout | checklist | custom), content blocks (sanitized HTML), buttons with actions (next, back, dismiss, complete, url, startGuide, event), a target ({ selectors: SelectorStrategy[], waitMs, fallback }), placement, and optional branches.
Targeting evaluation
Rules are a tree of RuleGroup { op: 'and'|'or', rules: (Rule|RuleGroup)[] }.
| Runs in SDK (browser) | Runs on server |
|---|---|
| URL / route / query params | explicit audience membership (returned as audienceIds on identify) |
| device, browser, OS | guide history (completed/dismissed ids) returned by identify |
| user & account attributes the app supplied to identify() | checklist progress |
| custom events seen this session | percentage rollout salt |
| first visit / returning | |
| date/time | |
| percentage rollout (deterministic hash of userId + guideId) |
Server-side evaluation never ships audience rules to the browser; only the resulting membership IDs for the identified user are returned.
Publishing & caching
GET /api/v1/sdk/config?key=pk_… returns published guides for one environment with an ETag derived from the environment's configVersion counter. Publishing increments the counter and invalidates the in-process (or Redis) cache. SDK clients send If-None-Match and keep the last good config in localStorage so they still work when the API is down.
Events pipeline
Autocapture (page_view, page_leave, click, rage_click) and error tracking (exception) use this same pipeline; ingestion also groups exceptions into ErrorIssue rows. Session recordings take a separate path (POST /api/v1/sdk/replay, gzip chunks into ReplaySession / ReplayChunk) because they are large and binary-ish. See product-analytics.md.
SDK → batched POST /api/v1/sdk/events (≤50 events, idempotent by eventId, retry with bounded exponential backoff, bounded local queue) → ProductEvent (unique on (projectId, eventId)) and derived GuideSession/StepInteraction rows written in the same transaction → reporting uses aggregate SQL with indexes on (projectId, environment, occurredAt) and (guideId, occurredAt). A pg-boss worker rolls up daily GuideDailyStat rows and applies retention. When event volume exceeds roughly 50M rows/month, move ingestion behind a queue (Kafka/SQS) and reporting into a columnar store; the EventIngestService boundary is the seam.
Browser authoring
Cross-origin iframes cannot be relied upon (CSP / X-Frame-Options / auth). Guidely instead uses SDK authoring mode:
- Dashboard creates an
AuthoringSession(short-lived signed token, bound to project + env + guide). - Dashboard opens the target app in a new window with
?guidely_authoring=<token>. - The installed SDK detects the param, verifies it with the public API, and enters authoring mode:
a Shadow-DOM toolbar, hover outlines, click-to-pick, and selector generation (id → data-* → aria → stable CSS path, with fallbacks).
- Picks are sent to the opener via `postMessage` (origin-checked) **and** stored on the authoring
session so the dashboard can poll if the opener reference is lost.
- The dashboard editor receives the selector and renders the step in the live app through the
same runtime ("preview in app").
If the SDK is not installed yet, the editor still works with manual selectors and the built-in simulated preview; the demo apps have the SDK installed so the full flow can be exercised.
Security summary
- Session cookies: httpOnly, SameSite=Lax, Secure in production, rotated on login, DB-backed.
- Mutating management requests require a matching
Origin/Sec-Fetch-Site(CSRF). - All author HTML is sanitized with DOMPurify (server) and re-sanitized in the runtime before
injection;
<script>, event handlers,javascript:URLs are stripped. - Rate limits: per IP + per public key on SDK endpoints; per user on management endpoints.
- Tenant isolation is enforced in service functions and verified by integration tests.