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 Guide rows with a kind. 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). GuideEnvironmentState points 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 in PublishEvent and AuditLog.

  • **Tenant scoping.** Every tenant-owned table carries `workspaceId` (directly or via project).

    Service functions take an authenticated Actor and resolve the workspace from the resource, never from client input.

  • End users (EndUser, Account) are the customer's users, distinct from dashboard User.

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 paramsexplicit audience membership (returned as audienceIds on identify)
device, browser, OSguide history (completed/dismissed ids) returned by identify
user & account attributes the app supplied to identify()checklist progress
custom events seen this sessionpercentage 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:

  1. Dashboard creates an AuthoringSession (short-lived signed token, bound to project + env + guide).
  2. Dashboard opens the target app in a new window with ?guidely_authoring=<token>.
  3. 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).

  4. 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.

  5. 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.