Can't get Side-by-side Web Previews plugin to work with NextJS app dir on Vercel

Hi. I’m having big trouble getting the side-by-side Web Previews plugin to work. I have a /api/draft route that sets draftMode to true and I have a /api/preview-links route for the plugin. When editing a page in the CMS, the sidebar pops up with the preview site but the problem is that it is not server side rendered, it is the static build of my site. However, when I copy the URL of the preview site by clicking the icon in the top right of the preview window, and paste that link into my browser, the site gets server side rendered and everything works as it should. What could be the problem causing it not to work in the preview? When debugging, I validated that the draftMode does never equal true in the side-by-side preview window, whatever I try to do. But it seems that the cookie ā€œ__prerender_bypassā€ gets set in the side-by-side preview.

Hello @simon3

You can use this snippet on your endpoint to make sure that the cookie is set and not lost:

  //to avoid losing the cookie on redirect in the iFrame
  const cookieStore = cookies();
  const cookie = cookieStore.get('__prerender_bypass')!;
  cookies().set({
    name: '__prerender_bypass',
    value: cookie?.value,
    httpOnly: true,
    path: '/',
    secure: true,
    sameSite: 'none',
  });

I tried pasting that code into both the api/draft and api/preview-links endpoints but it didn’t help.
Here’s my current code:

//   /api/preview-links
import { SchemaTypes } from "@datocms/cma-client-node";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

const corsInitOptions = {
  headers: {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
  },
};

const baseUrl = process.env.VERCEL_URL
  ? // Vercel auto-populates this environment variable
    `https://${process.env.VERCEL_URL}`
  : // Netlify auto-populates this environment variable
    process.env.URL;

/*
  This endpoint is for the Web Previews DatoCMS plugin:
  https://www.datocms.com/marketplace/plugins/i/datocms-plugin-web-previews

  After installing the plugin on the project, insert the following frontend settings:

  Name: Production Website
  URL: <YOUR_WEBSITE>/api/preview-links
*/

function generatePreviewUrl({
  item,
  itemType,
}: {
  item: SchemaTypes.Item;
  itemType: SchemaTypes.ItemType;
}) {
  switch (itemType.attributes.api_key) {
    case "insight":
      return `/insights/${item.attributes.slug}`;
    case "insights_page":
      return "/insights";
    case "homepage":
      return "/";
    default:
      return null;
  }
}

export async function OPTIONS(request: Request) {
  return NextResponse.json({ success: true }, corsInitOptions);
}

export async function POST(request: Request) {
  const requestBody = await request.json();
  const url = generatePreviewUrl(requestBody);

  if (!url) {
    return NextResponse.json({ previewLinks: [] }, corsInitOptions);
  }

  const cookieStore = cookies();
  const cookie = cookieStore.get("__prerender_bypass")!;
  cookies().set({
    name: "__prerender_bypass",
    value: cookie?.value,
    httpOnly: true,
    path: "/",
    secure: true,
    sameSite: "none",
  });

  const previewLinks = [
    {
      label: "Published version",
      url: `${baseUrl}${url}`,
    },
    {
      label: "Draft version",
      url: `${baseUrl}/api/draft?redirect=${url}`,
    },
  ];

  return NextResponse.json({ previewLinks }, corsInitOptions);
}
//   /api/draft
import { cookies, draftMode } from "next/headers";
import { redirect } from "next/navigation";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);

  draftMode().enable();

  const cookieStore = cookies();
  const cookie = cookieStore.get("__prerender_bypass")!;
  cookies().set({
    name: "__prerender_bypass",
    value: cookie?.value,
    httpOnly: true,
    path: "/",
    secure: true,
    sameSite: "none",
  });

  // Redirect to the homepage, or to the URL provided with the `redirect` query string parameter:
  const redirectUrl = new URL(
    searchParams.get("redirect") || "/",
    `https://${process.env.VERCEL_URL}`
  );

  redirect(`${redirectUrl.pathname}${redirectUrl.search}`);
}

I see, thank you!

We have a starter

That has WebPreviews installed, and it uses the appDir, so if you’d like, you can copy the implementation from there: https://github.com/datocms/next-minimalistic-photography/blob/main/app/api/web-previews/route.ts

As it should work exactly as it shows in the starter

The starter you linked does not use NextJS draft mode, and my current code is copied from this starter Next.js Template blog - Start a Next.js blog in minutes which does use NextJS draft mode. But as I said, it already works perfectly in my browser, just not in the iframe which is the weird part.

Sorry @simon3! I meant to link to another starter, we’re actually about to release this week a starter that has this exact example (with draft with webpreivews & with appDir) I’ll get back to you as soon as it is out

1 Like

Hi,

I’ve now migrated my code using the code in your latest starter template, but the problem still persists. The iframe side-by-side preview does not display the unpublished version of my site using the draft mode in NextJS. It does however work when I visit the site with my browser (Chrome v117.0.5938.92) through the /api/draft/enable?url=/&token=secretToken endpoint. I tried debugging the issue and would you believe it, the draft mode side-by-side preview correctly works in Firefox! I then tried it in Safari, but just like in Chrome, it didn’t work, which is a bummer. Seems like some issue with the iframe and the NextJS draft mode __prerender_bypass cookie.

1 Like

Are you looking into this? Would be nice to have side-by-side preview work on Chrome and Safari.

Hello @simon3

Using chrome Version 116.0.5845.187, in the exact code inside the starter https://www.datocms.com/marketplace/starters/next-13-company-landing-page-demo the iFrames seemed to work as expected for the preview:

If you are using a cloned project from that starter, perhaps an extension or a cache conflict or specific configuration of your browser is causing the issue?

Hi, we have exactly the same problem, with Chrome and Safari. In them only published records can be viewed in the side by side web preview. We are also using NextJS and it’s draftMode. When visiting the previewURL in a separate browser tab it works, but when visiting it side by side we get a 404 error for not published records.

Also the link to the ā€œNext 13 Company Landing Page Demo - DatoCMSā€ is a 404.

@m.finamor Thanks for the snippet! This works for us in Chrome when we use the side-by-side preview, but unfortunately Safari is not working. We suspect this has to do with how Safari handles third-party cookies and storage inside iframes but have not found a solution. Have you been able to get around this?

@mike3

Thanks for surfacing this. What you are seeing matches what we’ve been able to reproduce. Next.js draft mode relies on the __prerender_bypass and __next_preview_data cookies, and when the preview runs inside the plugin’s iframe Safari treats those cookies as third party and blocks them. That is why Chrome works with the snippet that re‑sets __prerender_bypass, while Safari still shows the published version. Next.js’ own docs call this out for preview and draft mode, since third party cookies are required when you test inside an embedded context: https://nextjs.org/docs/app/guides/draft-mode and https://nextjs.org/docs/pages/guides/draft-mode. Our article walks through how Next’s preview cookies work as well: https://www.datocms.com/blog/how-next-js-preview-mode-works-an-in-depth-guide.

There are two pragmatic ways around Safari’s policy:

If you want to keep using Next’s draft mode cookie, add a tiny client‑side hook that asks Safari for storage access from within the iframe, then reloads the page once permission is granted. This uses the Storage Access API and only kicks in on Safari. Editors usually have to have visited your site once in a top level tab for this to succeed. MDN has the details at https://developer.mozilla.org/en-US/docs/Web/API/Storage_Access_API. A minimal example you can drop into a small client component that you render only when you detect a preview session is:

'use client';

import { useEffect } from 'react';

export default function EnsureStorageAccess() {
  useEffect(() => {
    const run = async () => {
      if (!('hasStorageAccess' in document)) return;
      try {
        // @ts-ignore TS does not know this API yet in some setups
        const has = await document.hasStorageAccess();
        if (!has) {
          // @ts-ignore
          await document.requestStorageAccess();
          location.reload();
        }
      } catch {
        // ignore, fall back to published
      }
    };
    run();
  }, []);
  return null;
}

If you prefer to avoid cookies inside iframes entirely, steer the ā€œDraftā€ link returned by your /api/preview-links handler to a URL that activates preview through a signed query parameter, then decide server side to use the DatoCMS Preview API for that request. This avoids relying on Next’s draft mode cookie, which Safari blocks in an iframe. For example, have your preview links endpoint return something like https://your-site.vercel.app$``{url}?datocms-preview=${process.env.DATOCMS_PREVIEW_SECRET}. Here is a trimmed version of the links endpoint, based on the plugin docs, that does that:

// app/api/preview-links/route.ts
import { NextResponse } from 'next/server';
import type { SchemaTypes } from '@datocms/cma-client-node';

function generatePreviewPath({ item, itemType }: {
  item: SchemaTypes.Item; itemType: SchemaTypes.ItemType;
}) {
  switch (itemType.attributes.api_key) {
    case 'post': return `/posts/${item.attributes.slug}`;
    case 'homepage': return '/';
    default: return null;
  }
}

export async function POST(req: Request) {
  const body = await req.json();
  const path = generatePreviewPath(body);
  if (!path) return NextResponse.json({ previewLinks: [] });

  const baseUrl = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : process.env.URL!;
  const token = process.env.DATOCMS_PREVIEW_SECRET!;

  return NextResponse.json({
    previewLinks: [
      { label: 'Published', url: `${baseUrl}${path}` },
      { label: 'Draft', url: `${baseUrl}${path}?datocms-preview=${token}` },
    ],
  }, {
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    },
  });
}

Then in your data layer, switch to the Preview API when that signed flag is present. Using our official GraphQL client for the CDA, this can look like:

// app/lib/datocms.ts
import { createClient } from '@datocms/cda-client';
import { headers } from 'next/headers';

export function datocmsClientForRequest() {
  const h = headers();
  const url = new URL(h.get('x-url') || 'http://x/');
  // If you don’t forward x-url, you can pass the NextRequest to this function instead
  const previewParam = url.searchParams.get('datocms-preview');
  const isPreview = previewParam && previewParam === process.env.DATOCMS_PREVIEW_SECRET;

  return createClient({
    apiToken: isPreview ? process.env.DATOCMS_CDA_PREVIEW_TOKEN! : process.env.DATOCMS_CDA_TOKEN!,
    environment: 'main',
    baseUrl: isPreview ? 'https://graphql.datocms.com/preview' : 'https://graphql.datocms.com',
  });
}

In an App Router server component or loader you can then do:

// example usage in a server component
import { datocmsClientForRequest } from '@/app/lib/datocms';
import { headers } from 'next/headers';

export default async function Page() {
  // ensure x-url is set via middleware, or adapt to read from cookies/request
  const client = datocmsClientForRequest();
  const { page } = await client.query({ /* your GraphQL query here */ });
  return <main>{page.title}</main>;
}

This approach keeps the ā€œdraftnessā€ entirely URL driven for the preview session, so Safari’s third party cookie policy does not get in the way. It also plays nicely with the Web Previews plugin, since the plugin simply loads whatever URL you return. The plugin documentation and Next.js example endpoints are here in case you want to compare with our starters: https://www.datocms.com/marketplace/plugins/i/datocms-plugin-web-previews and https://github.com/datocms/nextjs-starter-kit/blob/main/src/app/api/preview-links/route.tsx plus https://github.com/datocms/nextjs-starter-kit/blob/main/src/app/api/draft-mode/enable/route.tsx.

If you want to try the Storage Access API path first, make sure your page only shows that request after a user action inside the iframe, since Safari requires interaction, and note that the editor might need to open your preview once in a top level tab for the permission to be grantable. The MDN notes are a good reference: https://developer.mozilla.org/en-US/docs/Web/API/Document/requestStorageAccess.

Also double check your CSP to allow the plugin host, as described here, or the iframe will not load at all: https://www.datocms.com/marketplace/plugins/i/datocms-plugin-web-previews.

2 Likes