Contentful
Introduction
Contentful is a Headless CMS which means it separates the “presentation layer” (FE) from the “management layer” ( backend). Marketing team can manage the content independently, while the FE team can re-use the content for different components in the FE.
Details
Setup
-
Create an account on contentful and simply follow usual instructions till the below screen is reached:
-
Here, choose start from scratch and follow the steps shown.
-
You can create a dummy content model named “Homepage” and fill it with following types as shown in the image below:
-
Now go to “content” tab and click on “Add Entry” and simply fill the details as required. You can fill any data.
-
Now, in your contentful dashboard, under the spaces, you’ll find “Settings” dropdown. Click on it:
-
You’ll find “API Keys” and “CMA Token” option there. Click on it:
-
Just click on both one by one, and create the API keys and tokens and retrieve the values for the
.envfile variables. -
Finally, go to “Settings” -> “Content Preview” for “Live Preview” feature. Once on that page, setup process and configure it like this for the localhost: http://localhost:3000/api/preview/enable-draft?secret=preview&slug={entry.fields.previewSlug}&locale={locale}
-
Focus on the preview url that is supplied as we will use the
secret,slug, andlocalein our codebase api routeapi/preview/enable-draft/route.ts. -
At this point, we are ready to consume this data in our codebase.
-
Open you next.js app and run
bun add @contentful/live-previewornpm install @contentful/live-previewto install the@contentful/live-previewpackage for setting up “Live Preview” feature for visual editing. -
Create an
.envfile and paste the following values in it:CONTENTFUL_ENVIRONMENT=master CONTENTFUL_SPACE_ID=<your_space_id> CONTENTFUL_ACCESS_TOKEN=<your_api_token> CONTENTFUL_PREVIEW_ACCESS_TOKEN=<your_preview_access_token> CONTENTFUL_PREVIEW_SECRET=preview # this can be any page value. here, it's a page url pointing to app/preview/page.tsx CONTENTFUL_MANAGEMENT_TOKEN=<your_cma_token> -
Now, create a new folder
[lang]insideappfolder. Inside it, create a folder namedpreview. Insidepreviewfolder, create alayout.tsxfile with following content:import { type ReactNode } from "react"; import { draftMode } from "next/headers"; import { ContentfulPreviewProvider } from "./_components/contentful-preview-provider"; export default function PreviewLayout({ children, }: { children: ReactNode; }) { const { isEnabled } = draftMode(); return ( <ContentfulPreviewProvider locale="en-US" enableInspectorMode={isEnabled} enableLiveUpdates={isEnabled} > {children} </ContentfulPreviewProvider> ); } -
Now, create a folder named
_componentsinside samepreviewfolder with a new filecontentful-preview-provider.tsxinside of it with the following content:"use client"; import { ContentfulLivePreviewInitConfig } from "@contentful/live-preview"; import { ContentfulLivePreviewProvider } from "@contentful/live-preview/react"; import { PropsWithChildren } from "react"; export function ContentfulPreviewProvider({ children, ...props }: PropsWithChildren<ContentfulLivePreviewInitConfig>) { return ( <ContentfulLivePreviewProvider {...props}> {children} </ContentfulLivePreviewProvider> ); } -
Next, inside the same
_components, create another filepreview-wrapper.tsxwith following content:"use client"; import { useContentfulLiveUpdates } from "@contentful/live-preview/react"; import HeroSection from "../../_components/hero-section"; import { HomeHeroSectionProps } from "@/lib/contentful/hero-section-api"; export default function PreviewWrapper({ data, }: { data: HomeHeroSectionProps; }) { const realtimeData = useContentfulLiveUpdates(data); return <HeroSection {...realtimeData} />; } -
Then, create a folder
[slug]insidepreviewfolder withpage.tsxfile and following content:import { getAllHomeHeroSections, getHomeHeroSection, HomeHeroSectionProps, } from "@/lib/contentful/hero-section-api"; import { notFound } from "next/navigation"; import PreviewWrapper from "../_components/preview-wrapper"; export async function generateStaticParams() { const allPreviews = await getAllHomeHeroSections(); return allPreviews.map((preview: HomeHeroSectionProps) => ({ slug: preview.previewSlug, })); } export default async function PreviewPage({ params, }: { params: { lang: string; slug: string }; }) { console.log({ params }); const homeHeroSection = await getHomeHeroSection( params.slug, true, params.lang, ); if (!homeHeroSection) { notFound(); } return ( <section className="bg-primary text-primary-foreground grid place-items-center"> <PreviewWrapper data={homeHeroSection} /> </section> ); } -
Now, our
previewfolder code is done and we need supporting code for the functions and utils we have used there. I am assuming that the tailwindcss is being used since it’s a nextjs app router app with clsx utils. So not worrying about that part. -
Go ahead and create a
contentfulfolder inside oflibfolder and createhero-section-api.tsfile inside of it with following content:export interface HomeHeroSectionProps { sys: { id: string; }; heading: string; description: string; formSubmitButton: string; formNoticeText: any; mediaSection: { sys: { id: string; }; url: string; }; layout: string; previewSlug: string; } // Set a variable that contains all the fields needed for blogs when a fetch for content is performed const HOME_HERO_SECTION_GRAPHQL_FIELDS = ` sys { id } __typename heading description formSubmitButton mediaSection { sys { id } __typename url } layout previewSlug `; async function fetchGraphQL( query: string, preview = false, tags: [string] = [""], ) { return fetch( `https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${ preview ? process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN : process.env.CONTENTFUL_ACCESS_TOKEN }`, }, body: JSON.stringify({ query }), next: { tags }, }, ).then((response) => response.json()); } function extractHomeHeroSectionEntries(fetchResponse: { data: { homepageCollection: { items: HomeHeroSectionProps[] } }; }) { return fetchResponse?.data?.homepageCollection?.items; } export async function getAllHomeHeroSections( limit = 3, isDraftMode = false, locale: string = "en-US", ) { const blogs = await fetchGraphQL( `query { homepageCollection(where:{heading_exists: true},limit: ${limit}, preview: ${ isDraftMode ? "true" : "false" }, locale: "${locale}") { items { ${HOME_HERO_SECTION_GRAPHQL_FIELDS} } } }`, isDraftMode, ["home-hero-section"], ); return extractHomeHeroSectionEntries(blogs); } export async function getHomeHeroSection( slug: string, isDraftMode = false, locale: string = "en-US", ) { const preview = await fetchGraphQL( `query { homepageCollection(where:{previewSlug: "${slug}"}, limit: 1, preview: ${ isDraftMode ? "true" : "false" }, locale: "${locale}") { items { ${HOME_HERO_SECTION_GRAPHQL_FIELDS} } } }`, isDraftMode, [slug], ); const data = extractHomeHeroSectionEntries(preview)[0]; return data; } -
Notice that we are using
graphqlin ourfetchcall to connect with thecontentful spaceusingspace_idandaccess_token. Based on the boolean flagpreviewwe are using eitherpreview_access_tokenoraccess_token. -
Next, we need to create
api pointsfor receiving the request from theContent Previewcall from the contentful. Remember this url http://localhost:3000/api/preview/enable-draft?secret=preview&slug={entry.fields.previewSlug}&locale={locale} we setup above in our Contentful Dashboard’sContent Preview? Yes, now we use it. -
Notice that the URL is going towards the
/api/preview/enable-draftroute. So, in our app, insideappfolder, we will replicate that folder structure and create a route file atapi/preview/enable-draft/route.tswith following content:import { getHomeHeroSection } from "@/lib/contentful/hero-section-api"; import { cookies, draftMode } from "next/headers"; import { redirect } from "next/navigation"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const secret = searchParams.get("secret"); const slug = searchParams.get("slug"); const locale = searchParams.get("locale") || "en-US"; const bypass = searchParams.get("x-vercel-protection-bypass"); console.log({ secret, slug, bypass }); if (!secret || !slug) { return new Response("Missing parameters", { status: 400 }); } // This secret should only be known to this route handler and the CMS if (secret !== process.env.CONTENTFUL_PREVIEW_SECRET) { return new Response("Invalid token", { status: 401 }); } // Fetch preview post to check if the provided `[slug]` exists const preview = await getHomeHeroSection(slug, true, locale); // If the [slug] doesn't exist prevent draft mode from being enabled if (!preview) { return new Response("Blog not found", { status: 404 }); } // Enable Draft Mode by setting the cookie draftMode().enable(); // Override cookie header for draft mode for usage in live-preview // https://github.com/vercel/next.js/issues/49927 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 path from the fetched post // We don't redirect to searchParams.[slug] as that might lead to open redirect vulnerabilities redirect( `/${locale}/preview/${preview.previewSlug}?x-vercel-protection-bypass=${bypass}&x-vercel-set-bypass-cookie=samesitenone`, ); } -
Make sure to fix all the path import related issues in your code as per your folder structure. In the above code, we are checking for 3 things:
secret,slug, andlocalewhich we have setup in theContent Previewsettings. Once we make sure they are present, we are trying to check if thepreviewDataexists using ourgraphqlrequests. If present, we are setting upcookiesand enablingdraftModeof nextjs and thenredirectingthe request to our[lang]/preview/[slug]route with proper values to be captured there and utilised for showing ourLive Preview. -
Now, go inside
[lang]folder and create a newlayout.tsxfile,_componentsfolder, andpage.tsxfile. You can delete thelayout.tsxandpage.tsxfile created by nextjs app cli inside theappfolder. -
Fill the
layout.tsxfile with following content:import type { Metadata } from "next"; import "./globals.css"; import { Questrial } from "next/font/google"; import { cn } from "@/lib/utils"; import Link from "next/link"; import Locale from "./_components/locale"; const q = Questrial({ weight: ["400"], subsets: ["latin"], variable: "--font-sans", }); export const metadata: Metadata = { title: "Contentful Evaluation", description: "Generated by create next app", }; const menus = [{ id: 1, name: "Home", href: "/" }]; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en" suppressHydrationWarning={true}> <body className={cn(q.variable, "font-sans antialiased")}> <header className="bg-primary text-primary-foreground p-6"> <div className="container flex items-center justify-between"> <div className="flex gap-x-4 lg:px-10 xl:px-20"> {menus.map((menu) => ( <Link href={menu.href} key={menu.href} className="font-semibold" > {menu.name} </Link> ))} </div> <div className="flex gap-x-4 lg:px-10 xl:px-20"> <Locale /> </div> </div> </header> {children} </body> </html> ); } -
Fill the
page.tsxfile with following content:import { getHomeHeroSection } from "@/lib/contentful/hero-section-api"; import HeroSection from "./_components/hero-section"; export default async function HomePage({ params, }: { params: { lang: string }; }) { const homeHeroSection = await getHomeHeroSection( "home-hero-section", false, params.lang, ); return ( <section className="bg-primary text-primary-foreground grid place-items-center"> <HeroSection {...homeHeroSection} /> </section> ); } -
And create 3 files inside
_componentsfolder, namedhero-section.tsx,locale.tsx, andhero-form.tsx. Filllocale.tsxwith this content:"use client"; import Link from "next/link"; export default function Locale() { return ( <> <Link href={"/en-US"} className="bg-primary-foreground text-primary rounded p-2 font-bold" > English </Link> <Link href={"/hi"} className="rounded bg-amber-600 p-2 font-bold text-white" > Hindi </Link> </> ); } -
Fill
hero-section.tsxwith this content:import { HomeHeroSectionProps } from "@/lib/contentful/hero-section-api"; import { cn } from "@/lib/utils"; import Image from "next/image"; import HeroForm from "./hero-form"; export default function HeroSection({ layout, heading, description, formSubmitButton, mediaSection, }: HomeHeroSectionProps) { return ( <section className={cn( "container mx-auto grid grid-cols-1 p-6 lg:grid-cols-2 lg:items-center", { "grid-cols-1": layout === "No Image" }, )} > <div className={cn( "flex flex-col items-center justify-center gap-2 lg:col-span-1 lg:mb-0 lg:items-start lg:p-10 xl:p-20", { "order-2 mt-6": layout === "Left Image and Right Text", }, { "order-1 mb-6": layout === "Right Image and Left Text", }, )} > <h1 className="text-center font-sans text-4xl font-bold leading-none lg:text-left lg:text-5xl lg:leading-tight"> {heading} </h1> <p className="mb-6 text-center font-sans text-base lg:text-left lg:text-lg"> {description} </p> <HeroForm submitBtnText={formSubmitButton} /> </div> <Image src={mediaSection?.url} alt={heading} width={800} height={480} quality={100} priority={true} className={cn( "rounded-2xl object-contain lg:col-span-1 lg:p-10 xl:p-20", { "order-1": layout === "Left Image and Right Text", }, { "order-2": layout === "Right Image and Left Text", }, { hidden: layout === "No Image", }, )} /> </section> ); } -
And fill
hero-form.tsxwith this content:"use client"; export default function HeroForm({ submitBtnText, }: { submitBtnText: string; }) { return ( <form className="grid w-full max-w-[550px] grid-cols-1 items-center rounded lg:grid-cols-3 lg:border lg:border-solid lg:border-slate-400"> <label className="lg:col-span-2"> <input type="email" name="email" id="email" placeholder="Enter your email address" className="form-input w-full rounded bg-transparent py-5 lg:border-none" /> </label> <button type="submit" className="text-primary mt-2 rounded bg-[#FDB71C] p-4 font-bold lg:col-span-1 lg:mt-0 lg:h-[90%] lg:w-[98%] lg:p-2" > {submitBtnText} </button> </form> ); } -
You will notice that the form is a dummy component. It’s fine, as it’s just a basic contentful setup walkthrough guide. None of these, apart from preview step, is required but since we are using
hero-section.tsxin ourpreview, we need it here. -
Finally, go to
next.config.[mjs|ts|js]file and place this content there:/** @type {import("next").NextConfig} */ const nextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: "images.ctfassets.net" }, ], formats: ["image/webp"], }, async redirects() { return [{ source: "/", destination: "/en-US", permanent: true }]; }, }; export default nextConfig; -
The
redirects()is required to redirect all incoming calls to our route to/en-USlocale by default. If you notice, our users can click on the<Locale />buttons to change it anytime they want in this small demo. -
Once this step is done, you can check all the imports and then run
npm run devorbun devto test out theLive Previewfeature in realtime.
Video Demo after above setup (not available to public till this month’s
end)
Evaluation
- Marketing Team Independence: The
Contentful Teamhas come up with a new offering calledContentful Studiobut that is not available in theFree Planto evaluate fully. In the absence of that evaluation, the verdict here is that it’s not possible in Free Plan but in theory, it allows the development team to create custom components for theDrag and Dropfeature to be used byMarketing team. How good is it? We cannot say. - Content Creation and Editing: It’s easy to edit content for the
Marketing teamthanks to theLive Previewfeature. However,Live Previewfeature needs to be setup for eachContent Modelby theDevelopment Teamin the test. - Page Creation and Publishing: It’s possible to create new pages later if the
Development teamhas setup thecatch_all_routeslug for the same. However, it’s not possible to setup dynamic structure of the pages. The structure will be predefined rather than what the marketing team later wants. In short, no design change can be done independent of development team. Again, is it possible inContentful Studio? No idea as it was not testable in the Free Plan. - Integration with Next.js: The docs of integration is not straight forward and required going to their github examples to setup properly with trial and error. Documentation has become quite vast and it’s difficult to find relevant topics quickly. A lot of time was wasted going in circles with no clear cut explanation as to what feature solves what problems in the Content Mangement System Domain.
Verdict
The final verdict depends on the quality of documentation (development team), ease of editing later (marketing team), and pricing (product team). So I rate it as: