Skip to content

SSR Safety

All askable-ui packages are safe to use in server-side rendering environments (Next.js App Router, Nuxt, SvelteKit, etc.).

How it works

ctx.observe() checks for browser globals before doing anything:

ts
function isBrowser(): boolean {
  return (
    typeof window !== 'undefined' &&
    typeof document !== 'undefined' &&
    typeof MutationObserver !== 'undefined'
  );
}

If any of these are missing, observe() is a silent no-op. Creating a context on the server is safe — it just won't start tracking until the browser hydrates.

Each framework adapter also ensures the module-level global context is never persisted across SSR requests. When window is undefined, a fresh throwaway context is created per render, preventing state leakage between concurrent server requests.

What renders on the server

All adapters render a predictable initial state on the server:

ValueServer
focus / getFocus()null
promptContext / toPromptContext()'No UI element is currently focused.'
getHistory()[]
toHistoryContext()'No interaction history.'

This means your LLM calls still work on the server — they just receive the empty-state strings. If you need to guard against sending LLM requests with no context, check focus !== null before calling.


React — Next.js App Router

useAskable() observes the DOM only inside a useEffect, which never runs on the server. It is safe to call in any component tree.

tsx
// app/dashboard/page.tsx — Server Component
// Components inside can use useAskable() — it silently returns no-focus state
export default function Page() {
  return <Dashboard />;
}

Components that call useAskable() must be Client Components:

tsx
// components/ChatInput.tsx
'use client';
import { useAskable } from '@askable-ui/react';

export function ChatInput() {
  const { promptContext } = useAskable();
  // Server render: 'No UI element is currently focused.'
  // Client (after hydration): live focus context
}

The <Askable> component renders data-askable attributes during SSR — the HTML is present immediately and hydrates without a flash:

tsx
// ✅ data-askable appears in server-rendered HTML
<Askable meta={{ widget: 'revenue-chart' }}>
  <RevenueChart />
</Askable>

Concurrent request safety: Each SSR render gets a fresh, independent context object. There is no cross-request state leakage, even under concurrent load.


Vue — Nuxt

useAskable() starts observing in onMounted(), which is client-only. The composable is safe to call in Nuxt pages and layouts without any special configuration.

ts
// pages/dashboard.vue
<script setup>
import { useAskable } from '@askable-ui/vue';

const { promptContext } = useAskable();
// Server render: promptContext.value = 'No UI element is currently focused.'
// Client (after hydration): live context
</script>

The <Askable> component renders correctly during SSR:

vue
<template>
  <!-- data-askable is in server-rendered HTML -->
  <Askable :meta="{ widget: 'revenue-chart' }">
    <RevenueChart />
  </Askable>
</template>

If you need to branch on whether focus is set:

ts
const { focus } = useAskable();

// Check before sending to LLM
const context = focus.value ? promptContext.value : null;

Svelte — SvelteKit

createAskableStore() calls observe() immediately, but the call is guarded — it is a no-op on the server.

ts
// +layout.svelte or +page.svelte
<script>
  import { createAskableStore } from '@askable-ui/svelte';

  const { focus, promptContext } = createAskableStore();
  // Server: $focus = null, $promptContext = 'No UI element is currently focused.'
  // Client (after hydration): live context
</script>

For SvelteKit with SSR, create the store in the component body (not at module level) to ensure each server request gets a fresh instance:

ts
// ✅ Per-render instance — safe for concurrent requests
<script>
  import { createAskableStore } from '@askable-ui/svelte';
  const store = createAskableStore();
</script>

// ❌ Module-level — shared across all server requests
import { createAskableStore } from '@askable-ui/svelte';
const store = createAskableStore(); // runs once at import time

Scoped contexts in SSR

If you create a context manually, observe() is already a no-op on the server:

ts
import { createAskableContext } from '@askable-ui/core';

// ✅ Safe — observe() is a no-op outside browser
const ctx = createAskableContext();
ctx.observe(document);

// ✅ Explicit browser guard (optional)
if (typeof window !== 'undefined') {
  ctx.observe(document);
}

Server/client privacy boundary

Askable captures data entirely in the browser. Nothing is automatically sent to your server. The flow is:

Browser                              Server / LLM
──────────────────────────────       ──────────────────────────
[data-askable] elements observed

ctx.getFocus() → AskableFocus

ctx.toPromptContext() → string  ──→  system prompt for LLM call

What to be mindful of:

  1. Sensitive meta — if data-askable attributes contain PII or internal IDs, use sanitizeMeta to strip them before they reach the prompt. See Sanitization.

  2. Text contenttext is the trimmed textContent of the element. For elements that display sensitive user data (account numbers, addresses), use sanitizeText or a textExtractor that returns a safe label.

  3. TransportpromptContext is a plain string. You decide when and whether to include it in your LLM call. It is never sent anywhere by askable-ui itself.

ts
// Recommended server-side pattern: validate before forwarding
app.post('/api/chat', (req, res) => {
  const { context, question } = req.body;
  // context comes from the browser — treat it as user input
  const safeContext = context?.slice(0, 2000) ?? ''; // length-limit
  // pass to LLM
});

Released under the MIT License.