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
The author opens in-context editing. The editor loads your preview URL in an iframe (or a popup) with
?is_incontext_editing_mode=trueand covers it with a loading overlay.The overlay stays up until your app sends a
readymessage.You never send
readyby hand. A Uniform SDK component sends it as a side effect of rendering your composition —<UniformComposition>in@uniformdev/canvas-vue,<UniformScript>(rendered for you byUniformComposition/UniformContext) in the Next.js SDKs. Depending on the SDK it either postsreadydirectly or injects the embed script that does.If your app renders a login screen instead of the composition, the composition never mounts, the embed script is never injected, and
readyis 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 |
|---|---|---|
| You know the visitor is unauthenticated and you are about to render a login UI | Hides the overlay, leaves the preview interactive, keeps waiting |
| Auth succeeded and you are mounting the composition without a full page reload | Puts the overlay back and waits for |
| 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 | 10s from page load | The signal is ignored; the editor keeps waiting for |
Total wait for | 20s (60s for | 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
readyalways recovers. If you overshoot a timeout and later sendready, the editor jumps to ready anyway. Failure screens are recoverable, not terminal.
Requirements
@uniformdev/canvas20.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, andSecurecookies 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.datais not a string, sopostMessage({ type: 'session-pending' })fails silently — no error, no warning, the overlay just never lifts.JSON.stringifyis not optional.'session-pending'is the wire name forsessionPending(). 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:
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.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.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" |
|
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 |
Signal appears to be ignored | Sent from a popup, where |
Hand-rolled | The payload was an object rather than a |
Overlay lifts, login works, but the page never becomes editable | The composition component never mounted, so the SDK never sent |
Login works in a normal tab but the session is gone inside the editor | Cookie missing |
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.