Previewing an authenticated site in the Canvas editor

Last updated: August 25, 2026

If your site requires a login before it renders anything, the Canvas in-context editor will appear to hang: the author sees the preview loading overlay instead of your login screen, and after ~20 seconds the editor gives up with "contextual editing is not enabled".

This guide shows how to fix that with channel.sessionPending(). The mechanism lives in @uniformdev/canvas and is plain DOM + postMessage, so it is framework-agnostic — there are recipes below for Astro, Vue/Nuxt, and Next.js, plus a version with no SDK import at all for static pages.

Why the overlay hides your login screen

  1. The author opens in-context editing. The editor loads your preview URL in an iframe (or a popup) with ?is_incontext_editing_mode=true and covers it with a loading overlay.

  2. The overlay stays up until your app sends a ready message.

  3. You never send ready by hand. A Uniform SDK component sends it as a side effect of rendering your composition — <UniformComposition> in @uniformdev/canvas-vue, <UniformScript> (rendered for you by UniformComposition / UniformContext) in the Next.js SDKs. Depending on the SDK it either posts ready directly or injects the embed script that does.

  4. If your app renders a login screen instead of the composition, the composition never mounts, the embed script is never injected, and ready is never sent.

The result is a deadlock: the author must log in to get past the overlay, but the overlay is on top of the login form and swallows the clicks.

sessionPending() breaks the deadlock by telling the editor "a human needs to interact with this page before contextual editing can start — take the overlay down and wait for me."

The handshake

Three signals, all on the channel returned by createCanvasChannel:

Signal

You send it when

Editor does

sessionPending()

You know the visitor is unauthenticated and you are about to render a login UI

Hides the overlay, leaves the preview interactive, keeps waiting

awaitingReady()

Auth succeeded and you are mounting the composition without a full page reload

Puts the overlay back and waits for ready

ready()

Never — the SDK's embed script sends it for you

Contextual editing goes live

Timing rules, which are the source of most integration bugs:

Constraint

Value

What happens if you miss it

Window in which sessionPending is accepted

10s from page load

The signal is ignored; the editor keeps waiting for ready only

Total wait for ready without an interstitial

20s (60s for localhost preview URLs)

Preview fails with "contextual editing is not enabled"

Max time in the interstitial

5 minutes

Preview fails; the author has to reload the preview

Two consequences worth designing around:

  • Signal within 10 seconds. Do not put a slow network round-trip in front of the signal. If you can determine "no session" on the server (middleware, SSR), do that and emit the signal in the interstitial's own HTML — that is always inside the window. A client-side fetch('/api/session') usually makes it, but it is the fragile version.

  • A late ready always recovers. If you overshoot a timeout and later send ready, the editor jumps to ready anyway. Failure screens are recoverable, not terminal.

Requirements

  • @uniformdev/canvas 20.58.0 or later (this is the framework-agnostic core package; you already have it as a transitive dependency of @uniformdev/canvas-vue / @uniformdev/uniform-nuxt / @uniformdev/next-app-router).

  • HTTPS in development. The preview runs your app in a cross-site iframe, so the session cookie needs SameSite=None; Secure, and Secure cookies are dropped over plain HTTP. See Cookies.

The shared piece

This module is the whole framework-agnostic core. Every recipe below imports from it.

// src/uniform/previewSession.ts
import {
  createCanvasChannel,
  IN_CONTEXT_EDITOR_QUERY_STRING_PARAM,
  isAllowedReferrer,
} from '@uniformdev/canvas';

/**
 * True when this document was opened by the Canvas in-context editor, either in an iframe or a popup.
 * Checks both the query string and the referrer, so a copy-pasted preview URL opened in a normal tab
 * does not start signalling to an editor that isn't there.
 */
export function isInContextEditingPreview(): boolean {
  if (typeof window === 'undefined') {
    return false;
  }

  const isEditorUrl = new URLSearchParams(window.location.search).has(
    IN_CONTEXT_EDITOR_QUERY_STRING_PARAM
  );

  return isEditorUrl && isAllowedReferrer(document.referrer);
}

export type PreviewSessionSignals = {
  /** Ask the editor to drop the loading overlay so the author can use your login UI. */
  sessionPending: () => void;
  /** Ask the editor to put the overlay back while the composition mounts. */
  awaitingReady: () => void;
  dispose: () => void;
};

/**
 * Opens a channel to the Canvas editor, or returns undefined when this page is not being previewed
 * by the editor (normal visitors, SSR, a login popup opened by your own page).
 */
export function createPreviewSessionSignals(): PreviewSessionSignals | undefined {
  if (!isInContextEditingPreview()) {
    return undefined;
  }

  const editorWindow: Window | null = window.opener ?? window.top;
  if (!editorWindow || editorWindow === window) {
    return undefined;
  }

  const channel = createCanvasChannel({ listenTo: [window], broadcastTo: [editorWindow] });

  return {
    sessionPending: () => channel.sessionPending(),
    awaitingReady: () => channel.awaitingReady(),
    dispose: () => channel.destroy(),
  };
}

Use isAllowedReferrer from the SDK rather than writing the referrer regex yourself. The list of allowed Uniform hosts changes, and a hand-copied regex silently stops matching.

If you cannot import the SDK

The signal is just JSON over postMessage, so a page that never touches your bundler can send it with no dependency at all. This is the right tool for a static interstitial — a file in Astro's public/, a CDN error page, a maintenance shell:

<script>
  (function () {
    if (!new URLSearchParams(window.location.search).has('is_incontext_editing_mode')) {
      return;
    }

    // Hand-copied from isAllowedReferrer. This will go stale — see the trade-off below.
    var allowedReferrer =
      /(^https:\/\/|\.)(uniform\.app|uniform\.wtf|uniformcode\.ai|localhost:\d{4})\//;
    if (!allowedReferrer.test(document.referrer || '')) {
      return;
    }

    var editorWindow = window.opener || window.top;
    if (editorWindow && editorWindow !== window) {
      editorWindow.postMessage(JSON.stringify({ type: 'session-pending' }), '*');
    }
  })();
</script>

Two details :

  • The payload must be a JSON string. The receiving channel drops any message whose event.data is not a string, so postMessage({ type: 'session-pending' }) fails silently — no error, no warning, the overlay just never lifts. JSON.stringify is not optional.

  • 'session-pending' is the wire name for sessionPending(). The other two are 'awaiting-ready' and 'ready'.

The '*' target origin matches what the SDK does. If you want it tighter, new URL(document.referrer).origin is the editor's origin and you have already validated it above.

The trade-off is that you now own a copy of the referrer allowlist, and it will drift.

Astro recipe: gate on the server, signal from the interstitial

Prefer this shape in Astro: deciding "no session" on the server means the signal ships with the first HTML response and cannot miss the 10s window.

Rewrite unauthenticated requests to an interstitial route, preserving the original query string so the is_incontext_editing_mode parameter survives:

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

const PUBLIC_PATHS = [/^\/auth\//, /^\/api\//];

export const onRequest = defineMiddleware((context, next) => {
  const { url, cookies } = context;

  if (cookies.get('session')?.value || PUBLIC_PATHS.some((pattern) => pattern.test(url.pathname))) {
    return next();
  }

  const destination = new URL('/auth/pending', url);
  url.searchParams.forEach((value, key) => destination.searchParams.set(key, value));
  destination.searchParams.set('goBackUrl', `${url.pathname}${url.search}`);

  // Rewrite, not redirect: the browser URL and the referrer stay untouched, which keeps the
  // editor's referrer check and the composition's own URL intact.
  return context.rewrite(`${destination.pathname}${destination.search}`);
});

The interstitial page signals on load and hands off to your identity provider:

---
// src/pages/auth/pending.astro
---

<html lang="en">
  <body>
    <main>
      <h1>Sign in to preview this page</h1>
      <button type="button" id="sign-in">Sign in</button>
    </main>

    
    <script>
      import { createPreviewSessionSignals } from '../../uniform/previewSession';

      const signals = createPreviewSessionSignals();
      signals?.sessionPending();

      document.getElementById('sign-in')?.addEventListener('click', () => {
        // Popup, not a redirect in place: see "Where the login has to happen" below.
        const popup = window.open('/auth/login', 'login', 'width=420,height=520,popup');

        window.addEventListener('message', (event) => {
          if (event.origin !== window.location.origin || event.data?.type !== 'auth-complete') {
            return;
          }
          popup?.close();

          // A rewrite leaves the browser URL on the composition, so `goBackUrl` is only in the
          // browser's query string if you redirected instead. Reload covers the rewrite case.
          // Either way this is a full navigation, so the middleware re-runs, sees the session,
          // renders the composition, and the SDK sends `ready`.
          const goBackUrl = new URLSearchParams(window.location.search).get('goBackUrl');
          if (goBackUrl) {
            window.location.href = goBackUrl;
          } else {
            window.location.reload();
          }
        });
      });
    </script>
  </body>
</html>

/auth/login is an ordinary login page (or a redirect into your IdP). Its only Uniform-specific job is to notify the opener when it finishes:

window.opener?.postMessage({ type: 'auth-complete' }, window.location.origin);
window.close();

That page must not call createPreviewSessionSignals(). Inside the popup, window.opener is your own preview page, not the editor, so a signal from there goes to the wrong window.

Vue recipe: a gate component around the composition

When the decision is client-side, wrap the composition in a gate. Keep the session check fast, and signal as soon as you know the visitor is anonymous.


<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';

import { createPreviewSessionSignals, type PreviewSessionSignals } from '../uniform/previewSession';
import LoginPrompt from './LoginPrompt.vue';

const session = ref<'loading' | 'authenticated' | 'anonymous'>('loading');
let signals: PreviewSessionSignals | undefined;

async function refreshSession() {
  try {
    const response = await fetch('/api/session', { credentials: 'include' });
    const { authenticated } = (await response.json()) as { authenticated?: boolean };
    session.value = authenticated ? 'authenticated' : 'anonymous';
  } catch {
    session.value = 'anonymous';
  }
}

onMounted(async () => {
  signals = createPreviewSessionSignals();

  await refreshSession();

  if (session.value === 'anonymous') {
    signals?.sessionPending();
  }
});

onUnmounted(() => signals?.dispose());

function onSignedIn() {
  // No page reload: tell the editor to show the overlay again while the composition mounts.
  signals?.awaitingReady();
  session.value = 'authenticated';
}
</script>

<template>
  <div v-if="session === 'loading'" />
  <LoginPrompt v-else-if="session === 'anonymous'" @signed-in="onSignedIn" />
  <slot v-else />
</template>

<PreviewAuthGate>
  <UniformComposition :data="composition" />
</PreviewAuthGate>

awaitingReady() is what makes the client-side path feel right: the author sees the overlay again instead of a half-mounted page. Note that it restarts the wait-for-ready budget (20s), so only call it when the composition is genuinely about to mount.

If you reload the page after login instead of transitioning in place, skip awaitingReady() entirely. The reload re-enters the normal flow, <UniformComposition> mounts, and the embed script sends ready. The editor stays overlay-free in the meantime, which is correct.

Nuxt: identical, with two notes. @uniformdev/uniform-nuxt registers the composition component globally as <Composition>, so the gate wraps that instead. And createPreviewSessionSignals() touches window, so it belongs in onMounted or a .client.ts plugin, never in setup that runs during SSR.

Next.js recipe

Same two options as above. Gate in middleware when the session is a cookie the server can read; use a client gate when only the browser knows.

Middleware is the closer analogue to the Astro recipe. nextUrl.clone() keeps the existing query string, so is_incontext_editing_mode survives without any manual copying:

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  if (request.cookies.get('session')?.value) {
    return NextResponse.next();
  }

  const destination = request.nextUrl.clone();
  destination.pathname = '/auth/pending';
  destination.searchParams.set('goBackUrl', `${request.nextUrl.pathname}${request.nextUrl.search}`);

  // Rewrite, not redirect: the browser URL and the referrer stay untouched, which keeps the
  // editor's referrer check and the composition's own URL intact.
  return NextResponse.rewrite(destination);
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|auth|favicon.ico).*)'],
};

The interstitial is a client component, because the signal needs window:

// app/auth/pending/page.tsx
import { PreviewLoginPrompt } from './PreviewLoginPrompt';

export default function AuthPendingPage() {
  return <PreviewLoginPrompt />;
}
// app/auth/pending/PreviewLoginPrompt.tsx
'use client';

import { useEffect } from 'react';

import { createPreviewSessionSignals } from '@/uniform/previewSession';

export function PreviewLoginPrompt() {
  useEffect(() => {
    const signals = createPreviewSessionSignals();
    signals?.sessionPending();

    return () => signals?.dispose();
  }, []);

  useEffect(() => {
    const onMessage = (event: MessageEvent) => {
      if (event.origin !== window.location.origin || event.data?.type !== 'auth-complete') {
        return;
      }

      // Full navigation, not router.push: the middleware has to re-run and see the cookie.
      // Under a rewrite the browser URL is still the composition's, so reload is the fallback.
      const goBackUrl = new URLSearchParams(window.location.search).get('goBackUrl');
      if (goBackUrl) {
        window.location.href = goBackUrl;
      } else {
        window.location.reload();
      }
    };

    window.addEventListener('message', onMessage);
    return () => window.removeEventListener('message', onMessage);
  }, []);

  return (
    <button
      type="button"
      onClick={() => window.open('/auth/login', 'login', 'width=420,height=520,popup')}
    >
      Sign in
    </button>
  );
}

Read goBackUrl from window.location.search rather than useSearchParams(). Under a rewrite the two disagree — the hook reflects the browser URL, which never had the parameter — and useSearchParams additionally forces the component into a Suspense boundary.

For a client-side gate, wrap the composition exactly like the Vue recipe:

// components/PreviewAuthGate.tsx
'use client';

import { type ReactNode, useEffect, useRef, useState } from 'react';

import { LoginPrompt } from '@/components/LoginPrompt';
import { createPreviewSessionSignals, type PreviewSessionSignals } from '@/uniform/previewSession';

export function PreviewAuthGate({ children }: { children: ReactNode }) {
  const [session, setSession] = useState<'loading' | 'authenticated' | 'anonymous'>('loading');
  const signalsRef = useRef<PreviewSessionSignals | undefined>(undefined);

  useEffect(() => {
    signalsRef.current = createPreviewSessionSignals();

    void (async () => {
      const response = await fetch('/api/session', { credentials: 'include' });
      const { authenticated } = (await response.json()) as { authenticated?: boolean };

      setSession(authenticated ? 'authenticated' : 'anonymous');
      if (!authenticated) {
        signalsRef.current?.sessionPending();
      }
    })();

    return () => signalsRef.current?.dispose();
  }, []);

  if (session === 'loading') {
    return null;
  }

  if (session === 'anonymous') {
    return (
      <LoginPrompt
        onSignedIn={() => {
          // No reload: put the overlay back while the composition mounts.
          signalsRef.current?.awaitingReady();
          setSession('authenticated');
        }}
      />
    );
  }

  return <>{children}</>;
}

Pages Router: the same gate, with router.pathname from next/router to detect the login route and _app.tsx as the wrapping point.

In both routers, make sure the route that renders your login form is reachable without a session and is excluded from the gate. Otherwise the gate recurses: no session means show the login page, and the login page itself needs a session.

Where the login has to happen

Do the actual credential entry in a popup or a top-level window, not inside the preview iframe. Most identity providers send X-Frame-Options: DENY or a restrictive frame-ancestors, so an in-iframe login renders a blank frame with a console error and no way forward.

The workable shapes, in order of preference:

  1. Popup (the examples above). The preview document stays alive, so the editor's channel stays alive, and you control the handoff with a same-origin postMessage.

  2. Interstitial rendered in the iframe, credentials in a popup. Same as above; the interstitial is just where you put the "Sign in" button and the sessionPending() call.

  3. Top-level redirect. Works, but the author leaves the editor and has to reopen the preview. Acceptable as a fallback when a popup is blocked.

Always verify event.origin on messages you receive, and keep the message payload to a bare "done" signal — never pass tokens through postMessage.

Cookies: the other half of the problem

Your app runs in a cross-site iframe relative to the Uniform dashboard, so a normal session cookie is not sent with the preview request. The session must be set with:

Set-Cookie: session=...; Path=/; HttpOnly; Secure; SameSite=None

SameSite=None requires Secure, and Secure cookies are dropped over plain HTTP — so local development has to run over HTTPS, or the session will appear to vanish every time the editor loads the preview.

Where browsers block third-party cookies outright, add Partitioned (CHIPS) so the cookie is stored against the embedding site:

Set-Cookie: session=...; Path=/; HttpOnly; Secure; SameSite=None; Partitioned

A partitioned cookie is a separate jar from the one your users get on the top-level site, which means the author will be asked to log in once inside the editor even if they are already logged in in a normal tab. That is expected, not a bug in the handshake.

Troubleshooting

Symptom

Likely cause

Overlay never lifts; preview fails with "contextual editing is not enabled"

sessionPending sent later than 10s after load, or not sent at all

Overlay never lifts, and nothing is sent

Referrer check failed. Either the preview was opened directly instead of by the editor, or the request bounced through a redirect chain (an IdP, or HTTP→HTTPS) that replaced document.referrer with something other than the Uniform host

Signal appears to be ignored

Sent from a popup, where window.opener is your own page rather than the editor

Hand-rolled postMessage has no effect at all

The payload was an object rather than a JSON.stringify'd string, so the receiver dropped it silently

Overlay lifts, login works, but the page never becomes editable

The composition component never mounted, so the SDK never sent ready

Login works in a normal tab but the session is gone inside the editor

Cookie missing SameSite=None; Secure, served over HTTP, or blocked as a third-party cookie (add Partitioned)

Login provider renders blank inside the preview

IdP refuses to be framed; move credential entry to a popup

Preview fails after a long login

The 5-minute interstitial cap elapsed; the author can reload the preview to retry

A note on the examples

Every snippet here is self-contained: the handshake is the three signals plus the shared previewSession.ts module, and the recipes differ only in where you hang them.

None of it is an auth stack. The session checks are deliberately reduced to "is there a cookie" so the Uniform-specific parts stay visible. Tokens, server-side validation, CSRF, rate limiting, and popup- blocked handling are yours and are not described on this page.