The problem:

At Conio we have a company site built with next js. Some pages are static, others are dynamic with server side and others a mix of them. In some pages content comes from CMS (Storyblok).

Marketing team is fully responsible for contents. Team enter on CMS, edit/create/delte a component (for example an FAQ) and then trigger a new deploy.

This works but render of a content inside CMS can be very different than the same data rendered on the company site. So basically some times the team trigger more than 1 deploy to get the final result or maybe team is afraid.


The solution (my solution):

We can combine 3 features:

  1. Draft mode on Vercel

  2. Authentication on production site for marketing team

  3. version "draft" for contents. Storyblok cms handle "draft" and "published" version.

When we develop in local next js load content each time develper refresh the page. So basically for
app/support/[slug].tsx like that

// app/support/[slug]/page.tsx

export async function generateStaticParams() {
    const slugs = await getFaqsFromCsm({ version: "published"m})
    return slugs;
}

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
    const { slug } = await params;
    const faq = await getFaqFromSlug(slug,"published")
    return <SlugPage locale={locale} ISbStoryData={faq} />;
}

both methods runs in localhost. So if I edit a content on CMS, save the record and reload local site I see new changes. But this does not happend on production site for static pages.
Draft Mode allows to run the site like it is running on localhost, so both methods runs.

We want that only marketing team can enter on the preview version of site so we add a simple authentication for each urls that start with /preview. We can create a middleware.ts like that

// middleware.tsx

import { NextResponse } from "next/server";
import type { MiddlewareConfig, NextRequest } from "next/server";

// Step 1. HTTP Basic Auth Middleware for Challenge
export function middleware(req: NextRequest) {
    if (!isAuthenticated(req)) {
        return new NextResponse("Authentication required", {
            status: 401,
            headers: { "WWW-Authenticate": "Basic" },
        });
    }

    return NextResponse.next();
}

// Step 2. Check HTTP Basic Auth header if present
function isAuthenticated(req: NextRequest) {
    const authheader = req.headers.get("authorization") || req.headers.get("Authorization");

    if (!authheader) {
        return false;
    }

    const auth = Buffer.from(authheader.split(" ")[1], "base64").toString().split(":");
    const user = auth[0];
    const pass = auth[1];

    if (user == process.env.AUTH_USER && pass == process.env.AUTH_PASS) {
        return true;
    } else {
        return false;
    }
}

// Step 3. Configure "Matching Paths" below to protect routes with HTTP Basic Auth
export const config: MiddlewareConfig = {
    matcher: ["/preview/:path*"],
};

Now if user navigate to /preview or prepend /preview before an existing url (like /preview/support) site ask credentials to user

Now we need to handle a "fake" preview page so we create

// app/preview/route.tsx

import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'

export async function GET() {
    const draft = await draftMode()
    draft.enable()

    redirect("/")
}

And

// app/preview/[...path]/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'

export async function GET(
    request: Request,
    { params }: { params: Promise<{ path: string[] }> }
) {

    const draft = await draftMode()
    draft.enable()

    const { path } = await params
    const destination = '/' + (path?.join('/') ?? '')

    redirect(destination)
}

The last want allow us to prepend a "/preview" on existing url. So if production site has:
www.mysite.com/support/how-become-rich => www.mysite.com/preview/support/how-become-rich.

Opening www.mysite.com/preview/support/how-become-rich user need to login, and if login is OK, user will be redirect to www.mysite.com/support/how-become-rich with draft mode enable.

Now, we need to edit app/support/[slug]/page.tsx because we want "draft" content from CMS instead "published" is draftMode is enable:

// app/support/[slug]/page.tsx

export async function generateStaticParams() {
    const slugs = await getFaqsFromCsm({ version: "published"m})
    return slugs;
}

export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
    const { slug } = await params;
    
    const { isEnabled } = await draftMode()
    
    const faq = await getFaqFromSlug(slug,isEnabled ? "draft": "published")
    return <SlugPage locale={locale} ISbStoryData={faq} />;
}

Now, user can open CMS, edit content, save (without publish), and reload the page (on production site 😃 ).

In my case I wanted to show a banner for marketing team on site, so they know that are looking at preview version. In this banner I added a basic button to exit draftMode:

// components/GlobalDraftWarningAlert.tsx

"use client"
import { exitDraftMode } from "app/actions/exitDraftMode"
import { usePathname } from "next/navigation"

export function GlobalDraftWarningAlert() {
    const pathname = usePathname()
    return <div className="text-[12px] flex items-center gap-2 fixed pluto bottom-4 mx-auto w-1/2 left-4 bg-red-500 z-[99999999] p-2 text-white opacity-80 _motion-safe:animate-[blink_1s_step-end_infinite]">
        <span>{`LA MODALITà DRAFT DEL CMS E' ATTIVA. VEDRAI QUALSIASI COSA CHE E' PRESENTE SUL CMS ANCHE NON PUBBLICATA`}</span>
        <form action={exitDraftMode}>
            <input type="hidden" name="pathname" value={pathname} />
            <button className="bg-green-600 text-white border border-white px-4 py-2 rounded-md font-medium hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-white/70 transition">
                Esci dalla modalià
            </button>
        </form>
    </div>
}