Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-editing-cookies-samesite-vercel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sitecore-content-sdk/nextjs': patch
'create-content-sdk-app': patch
---

Fix cross-origin editing cookies and draft mode detection on Vercel. Editing cookies now include SameSite=None; Secure for cross-origin iframe compatibility, and page templates fall back to URL searchParams for editing detection when draftMode() returns false on serverless platforms.
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { isDesignLibraryPreviewData } from '@sitecore-content-sdk/nextjs/editing';
import { notFound } from 'next/navigation';
import { draftMode } from 'next/headers';
<% if (prerender === 'SSG') { -%>
<% if (prerender === 'SSG') {
-%>
import { SiteInfo } from '@sitecore-content-sdk/nextjs';
import sites from '.sitecore/sites.json';
import { routing } from 'src/i18n/routing';
import scConfig from 'sitecore.config';
import sites from '.sitecore/sites.json';
import { routing } from 'src/i18n/routing';
import scConfig from 'sitecore.config';
<% } -%>
import client from 'src/lib/sitecore-client';
import { getSitecorePage } from 'src/lib/cache/get-sitecore-page';
Expand All @@ -15,7 +16,7 @@ import { NextIntlClientProvider } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';

type PageProps = {
params: Promise<{ site: string; locale: string; path?: string[]; [key: string]: string | string[] | undefined }>;
params: Promise<{ site: string; locale: string; path?: string[];[key: string]: string | string[] | undefined }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};

Expand All @@ -33,15 +34,23 @@ export default async function Page({ params, searchParams }: PageProps) {
setRequestLocale(`${site}_${locale}`);

const draft = await draftMode();
const resolvedSearchParams = await searchParams;

// Detect editing mode: draftMode (standard) or URL parameters (fallback for
// serverless platforms like Vercel where draft cookies may not propagate)
const isEditing =
draft.isEnabled ||
resolvedSearchParams.sc_mode === 'edit' ||
resolvedSearchParams.mode === 'edit' ||
resolvedSearchParams.sc_headless_mode === 'edit';

// Fetch the page data from Sitecore
let page;
if (draft.isEnabled) {
const editingParams = await searchParams;
if (isDesignLibraryPreviewData(editingParams)) {
page = await client.getDesignLibraryData(editingParams);
if (isEditing) {
if (isDesignLibraryPreviewData(resolvedSearchParams)) {
page = await client.getDesignLibraryData(resolvedSearchParams);
} else {
page = await client.getPreview(editingParams);
page = await client.getPreview(resolvedSearchParams);
}
} else {
page = cachedPage;
Expand All @@ -61,40 +70,49 @@ export default async function Page({ params, searchParams }: PageProps) {
);
}

<% if (prerender === 'SSG') { -%>
<% if (prerender === 'SSG') {
-%>
// This function gets called at build and export time to determine
// pages for SSG ("paths", as tokenized array).
export const generateStaticParams = async () => {
if (process.env.NODE_ENV !== 'development' && scConfig.generateStaticPaths) {
return await client.getAppRouterStaticParams(
sites.map((site: SiteInfo) => site.name),
routing.locales.slice()
);
}
// Next.js 16 requires at least one result
// Return a default param for the root page
return [
{
site: sites[0]?.name || 'default',
locale: routing.defaultLocale || scConfig.defaultLanguage,
path: [],
},
];
};
if (process.env.NODE_ENV !== 'development' && scConfig.generateStaticPaths) {
return await client.getAppRouterStaticParams(
sites.map((site: SiteInfo) => site.name),
routing.locales.slice()
);
}
// Next.js 16 requires at least one result
// Return a default param for the root page
return [
{
site: sites[0]?.name || 'default',
locale: routing.defaultLocale || scConfig.defaultLanguage,
path: [],
},
];
};
<% } -%>
// Metadata fields for the page. Mirrors the Page draft-mode branching so the <title> matches the body.
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const { path, site, locale } = await params;

const draft = await draftMode();
const resolvedSearchParams = await searchParams;

// Detect editing mode: draftMode (standard) or URL parameters (fallback for
// serverless platforms like Vercel where draft cookies may not propagate)
const isEditing =
draft.isEnabled ||
resolvedSearchParams.sc_mode === 'edit' ||
resolvedSearchParams.mode === 'edit' ||
resolvedSearchParams.sc_headless_mode === 'edit';

let page;
if (draft.isEnabled) {
const editingParams = await searchParams;
if (isDesignLibraryPreviewData(editingParams)) {
page = await client.getDesignLibraryData(editingParams);
if (isEditing) {
if (isDesignLibraryPreviewData(resolvedSearchParams)) {
page = await client.getDesignLibraryData(resolvedSearchParams);
} else {
page = await client.getPreview(editingParams);
page = await client.getPreview(resolvedSearchParams);
}
} else {
page = await getSitecorePage({ site, locale, path: path ?? [] });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createEditingRenderRouteHandlers } from '@sitecore-content-sdk/nextjs/route-handler';
import { NextRequest } from 'next/server';

/**
* API route to handler Sitecore Editor rendeing.
Expand All @@ -10,6 +11,66 @@ import { createEditingRenderRouteHandlers } from '@sitecore-content-sdk/nextjs/r
* 2. Enable Next.js Draft Mode
* 3. Pass preview data as query string parameters, alongside required headers and cookies to an internal editing request
* 4. Return the rendered HTML for editing mode
*
* The wrapper below ensures all editing cookies carry SameSite=None; Secure so
* browsers allow them inside the cross-origin XM Cloud editor iframe.
*/

export const { GET, POST, OPTIONS } = createEditingRenderRouteHandlers({});
/** Cookie name prefixes that belong to Sitecore editing or Next.js preview */
const EDITING_COOKIE_PREFIXES = [
'__prerender_bypass',
'__next_preview_data',
'sc_mode',
'sc_headless_mode',
'sc_preview',
'sc_cid',
'sc_cid_personalize',
'sc_site',
];

function isEditingCookie(cookieStr: string): boolean {
const name = cookieStr.trimStart().split('=')[0];
return (
EDITING_COOKIE_PREFIXES.some((prefix) => name === prefix) ||
name.includes('#sc_')
);
}

function patchSameSite(setCookieValue: string): string {
let patched = setCookieValue
.replace(/;\s*SameSite=\w+/gi, '')
.replace(/;\s*Secure/gi, '');
return `${patched}; SameSite=None; Secure`;
}

function patchResponseCookies(response: Response): Response {
const setCookies = response.headers.getSetCookie();
if (!setCookies.length) return response;

const headers = new Headers(response.headers);
headers.delete('Set-Cookie');

for (const cookie of setCookies) {
headers.append('Set-Cookie', isEditingCookie(cookie) ? patchSameSite(cookie) : cookie);
}

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}

const handlers = createEditingRenderRouteHandlers({});

export const OPTIONS = handlers.OPTIONS;

export async function GET(req: NextRequest) {
const response = await handlers.GET(req);
return patchResponseCookies(response);
}

export async function POST(req: NextRequest) {
const response = await handlers.POST(req);
return patchResponseCookies(response);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { isDesignLibraryPreviewData } from '@sitecore-content-sdk/nextjs/editing';
import { notFound } from 'next/navigation';
import { draftMode, headers as nextHeaders } from 'next/headers';
<% if (prerender === 'SSG') { -%>
<% if (prerender === 'SSG') {
-%>
import { SiteInfo } from '@sitecore-content-sdk/nextjs';
import sites from '.sitecore/sites.json';
import { routing } from 'src/i18n/routing';
import scConfig from 'sitecore.config';
import sites from '.sitecore/sites.json';
import { routing } from 'src/i18n/routing';
import scConfig from 'sitecore.config';
<% } -%>
import client from 'src/lib/sitecore-client';
import Layout, { RouteFields } from 'src/Layout';
Expand All @@ -14,27 +15,43 @@ import { NextIntlClientProvider } from 'next-intl';
import { setRequestLocale } from 'next-intl/server';

type PageProps = {
params: Promise<{ site: string; locale: string; path?: string[]; [key: string]: string | string[] | undefined }>;
params: Promise<{ site: string; locale: string; path?: string[];[key: string]: string | string[] | undefined }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};

export default async function Page({ params }: PageProps) {
export default async function Page({ params, searchParams }: PageProps) {
const { site, locale, path } = await params;

// Set site and locale to be available in src/i18n/request.ts for fetching the dictionary
setRequestLocale(`${site}_${locale}`);

const draft = await draftMode();
const resolvedSearchParams = await searchParams;

// Detect editing mode: draftMode (standard) or URL parameters (fallback for
// serverless platforms like Vercel where draft cookies may not propagate)
const isEditing =
draft.isEnabled ||
resolvedSearchParams.sc_mode === 'edit' ||
resolvedSearchParams.mode === 'edit' ||
resolvedSearchParams.sc_headless_mode === 'edit';

// Fetch the page data from Sitecore
let page;
if (draft.isEnabled) {
if (isEditing) {
// Try preview data from headers first (SDK internal fetch path),
// fall back to searchParams (iframe URL params path on serverless platforms)
const headers = await nextHeaders();
const previewData = client.getPreviewData(headers);
const editingData =
previewData && Object.keys(previewData).length > 0
? previewData
: resolvedSearchParams;

if (isDesignLibraryPreviewData(previewData)) {
page = await client.getDesignLibraryData(previewData);
if (isDesignLibraryPreviewData(editingData)) {
page = await client.getDesignLibraryData(editingData);
} else {
page = await client.getPreview(previewData);
page = await client.getPreview(editingData);
}
} else {
page = await client.getPage(path ?? [], { site, locale });
Expand All @@ -54,26 +71,27 @@ export default async function Page({ params }: PageProps) {
);
}

<% if (prerender === 'SSG') { -%>
<% if (prerender === 'SSG') {
-%>
// This function gets called at build and export time to determine
// pages for SSG ("paths", as tokenized array).
export const generateStaticParams = async () => {
if (process.env.NODE_ENV !== 'development' && scConfig.generateStaticPaths) {
return await client.getAppRouterStaticParams(
sites.map((site: SiteInfo) => site.name),
routing.locales.slice()
);
}
// Next.js 16 requires at least one result
// Return a default param for the root page
return [
{
site: sites[0]?.name || 'default',
locale: routing.defaultLocale || scConfig.defaultLanguage,
path: [],
},
];
};
if (process.env.NODE_ENV !== 'development' && scConfig.generateStaticPaths) {
return await client.getAppRouterStaticParams(
sites.map((site: SiteInfo) => site.name),
routing.locales.slice()
);
}
// Next.js 16 requires at least one result
// Return a default param for the root page
return [
{
site: sites[0]?.name || 'default',
locale: routing.defaultLocale || scConfig.defaultLanguage,
path: [],
},
];
};
<% } -%>
// Metadata fields for the page.
export const generateMetadata = async ({ params }: PageProps) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createEditingRenderRouteHandlers } from '@sitecore-content-sdk/nextjs/route-handler';
import { NextRequest } from 'next/server';

/**
* API route to handler Sitecore Editor rendeing.
Expand All @@ -10,9 +11,69 @@ import { createEditingRenderRouteHandlers } from '@sitecore-content-sdk/nextjs/r
* 2. Enable Next.js Draft Mode
* 3. Pass preview data as query string parameters, alongside required headers and cookies to an internal editing request
* 4. Return the rendered HTML for editing mode
*
* The wrapper below ensures all editing cookies carry SameSite=None; Secure so
* browsers allow them inside the cross-origin XM Cloud editor iframe.
*/

// Force dynamic rendering since this route uses request headers, cookies, and draftMode
export const dynamic = 'force-dynamic';

export const { GET, POST, OPTIONS } = createEditingRenderRouteHandlers({});
/** Cookie name prefixes that belong to Sitecore editing or Next.js preview */
const EDITING_COOKIE_PREFIXES = [
'__prerender_bypass',
'__next_preview_data',
'sc_mode',
'sc_headless_mode',
'sc_preview',
'sc_cid',
'sc_cid_personalize',
'sc_site',
];

function isEditingCookie(cookieStr: string): boolean {
const name = cookieStr.trimStart().split('=')[0];
return (
EDITING_COOKIE_PREFIXES.some((prefix) => name === prefix) ||
name.includes('#sc_')
);
}

function patchSameSite(setCookieValue: string): string {
let patched = setCookieValue
.replace(/;\s*SameSite=\w+/gi, '')
.replace(/;\s*Secure/gi, '');
return `${patched}; SameSite=None; Secure`;
}

function patchResponseCookies(response: Response): Response {
const setCookies = response.headers.getSetCookie();
if (!setCookies.length) return response;

const headers = new Headers(response.headers);
headers.delete('Set-Cookie');

for (const cookie of setCookies) {
headers.append('Set-Cookie', isEditingCookie(cookie) ? patchSameSite(cookie) : cookie);
}

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}

const handlers = createEditingRenderRouteHandlers({});

export const OPTIONS = handlers.OPTIONS;

export async function GET(req: NextRequest) {
const response = await handlers.GET(req);
return patchResponseCookies(response);
}

export async function POST(req: NextRequest) {
const response = await handlers.POST(req);
return patchResponseCookies(response);
}
Loading