Wiring a Next.js Landing Page to Contentful Without the SDK
Piotr Żarów
CTO at Dev and Deliver
2026-08-27
#Development
Time to read
10 mins
In this article
Introduction
The short version
Why use a CMS for a landing page at all?
Why the types come first
The fetch library
ISR and the 'REVALIDATE' constant
Why Contentful env vars must not use the 'NEXT_PUBLIC_' prefix
Parallel fetches in the page component
The ArticleTeaserContent pattern
Turbo cache and the 'env' declaration
The seed script
Real pain points
FAQ
Agency take
Share this article
Introduction
The contentful npm package weighs about 200KB. The Content Delivery API it wraps is plain JSON over HTTPS. For a landing page with three content types and a handful of entries, the SDK adds nothing except bundle size and a dependency to maintain. We used raw fetch with Next.js's { next: { revalidate } } option instead.
This is Article 3 in a five-part series documenting how we built an open-source Turborepo starter with Next.js and NestJS - from scratch, with Claude Code, verified by hand. The repo: https://github.com/DevAndDeliver/turborepo.
The short version
- No Contentful SDK. Raw
fetchagainst the Content Delivery API, ~200KB saved, simpler to reason about. - Server-side env vars only.
CONTENTFUL_SPACE_IDandCONTENTFUL_ACCESS_TOKENnever reach the browser. NoNEXT_PUBLIC_prefix needed. - ISR at 12 hours. One constant
REVALIDATE = 43200shared across all fetch functions. Marketing copy doesn't need hourly refreshes. - Graceful fallbacks. Missing credentials, empty space, or network error - all three return static defaults. Clone the repo without a Contentful account and get a working page, not a crash.
Promise.allfor parallel fetches. Three independent requests (hero, features, articles) run simultaneously. Sequentialawaitwould add ~600ms of waterfall latency.- Shared types in
packages/types. CMS field names must match the TypeScript interfaces. If they drift, TypeScript tells you immediately. - Idempotent seed script via CMA. One command sets up the entire Contentful space. Run it twice and nothing breaks.
- Five real things went wrong. All documented below.
Why use a CMS for a landing page at all?
The brief for this starter was to build something real - not a toy with hardcoded strings, but something a non-developer could update without opening VS Code.
The hero headline, the feature cards, the article teasers in the footer: these are exactly the kind of copy that clients want to change on their own timeline. Wiring them to a CMS is the right call for any client project, and documenting how we wire to Contentful is the right call for an article series.
We picked Contentful because its free tier is generous, its Content Delivery API is a clean REST API with predictable JSON responses, and the setup is fast enough to do live in a blog article. Nothing about this is Contentful-specific. The same pattern works with any headless CMS that exposes a REST API.
Why the types come first
Before any fetching, we define what the data looks like. That lives in packages/types - the shared package from Article 1.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21// packages/types/src/contentful.ts export interface HeroContent { headline: string; subline: string; ctaLabel: string; } export interface FeatureContent { title: string; description: string; order: number; } export interface ArticleTeaserContent { number: string; title: string; excerpt: string; href: string; published: boolean; order: number; }
These interfaces are the contract between Contentful and the app. The field IDs in Contentful must match these property names exactly. If you rename a field in the CMS and forget to update the interface - or vice versa - TypeScript surfaces the mismatch the next time you build. The CMS does not drift silently from the code.
The fetch library
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18// apps/web/src/lib/contentful.ts import type { HeroContent, FeatureContent, ArticleTeaserContent } from "@repo/types"; const SPACE_ID = process.env.CONTENTFUL_SPACE_ID; const ACCESS_TOKEN = process.env.CONTENTFUL_ACCESS_TOKEN; const BASE_URL = `https://cdn.contentful.com/spaces/${SPACE_ID}/environments/master`; const REVALIDATE = 43200; // 12 hours async function fetchEntries<T>(contentType: string): Promise<T[]> { const res = await fetch( `${BASE_URL}/entries?content_type=${contentType}&access_token=${ACCESS_TOKEN}`, { next: { revalidate: REVALIDATE } }, ); if (!res.ok) throw new Error(`Contentful ${contentType}: ${res.status}`); const json = (await res.json()) as { items: Array<{ fields: T }> }; return json.items.map((item) => item.fields); }
fetchEntries<T> is the only function that talks to the Contentful API. Everything else is just a wrapper that calls it with the right content type. The type parameter T lets TypeScript infer the return type automatically - fetchEntries<HeroContent> returns HeroContent[], no manual casting needed.
The .map((item) => item.fields) at the end is doing important work. Contentful's API doesn't return { title, excerpt } directly - it returns { items: [{ sys: {...}, fields: { title, excerpt } }] }. We unwrap that once in fetchEntries so every caller receives plain T objects with no Contentful envelope.
The public functions
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29export async function fetchHero(): Promise<HeroContent> { if (!SPACE_ID || !ACCESS_TOKEN) return DEFAULT_HERO; try { const entries = await fetchEntries<HeroContent>("landingHero"); return entries[0] ?? DEFAULT_HERO; } catch { return DEFAULT_HERO; } } export async function fetchFeatures(): Promise<FeatureContent[]> { if (!SPACE_ID || !ACCESS_TOKEN) return DEFAULT_FEATURES; try { const entries = await fetchEntries<FeatureContent>("featureItem"); return entries.sort((a, b) => a.order - b.order); } catch { return DEFAULT_FEATURES; } } export async function fetchArticles(): Promise<ArticleTeaserContent[]> { if (!SPACE_ID || !ACCESS_TOKEN) return DEFAULT_ARTICLES; try { const entries = await fetchEntries<ArticleTeaserContent>("articleTeaser"); return entries.sort((a, b) => a.order - b.order); } catch { return DEFAULT_ARTICLES; } }
Three failure modes, all handled per function:
- Missing credentials -
if (!SPACE_ID || !ACCESS_TOKEN) return DEFAULT_X. Someone who clones the open-source repo without a Contentful account gets the static defaults and a working page. - Empty space -
entries[0] ?? DEFAULT_HERO. The space exists but has no entries. Common on a fresh setup before running the seed script. - Network error - the
catchblock returns defaults. A transient Contentful outage at build time does not break the deployment.
ISR and the 'REVALIDATE' constant
1const REVALIDATE = 43200; // 12 hours
This constant is used on every fetch call via { next: { revalidate: REVALIDATE } }. There are two things worth spelling out here.
First: { next: { revalidate: N } } is Next.js-specific syntax on fetch. It's not standard browser fetch. Under the hood, it tells Next.js to store the response in its data cache and re-use it for up to N seconds before re-fetching. This gives you ISR (Incremental Static Regeneration) at the data level - not at the page level via export const revalidate, though you can use both together.
Second: Next.js's fetch in Server Components defaults to force-cache - it will cache indefinitely and never re-fetch unless you tell it otherwise. { next: { revalidate: N } } opts into the ISR behavior. Omit it and your landing page content never updates after the first build. This is easy to miss because there's no warning, no error - the page just silently serves stale content forever.
12 hours is the right interval for marketing copy and article teasers. They're not time-sensitive. A content editor updates the hero headline, and it propagates within the next 12-hour window. If you need faster propagation, you can add an on-demand revalidation endpoint and call it from a Contentful webhook - but that's more complexity than this project needs.
One constant, one place to change it. If you decide 6 hours is better, you change one line.
Why Contentful env vars must not use the 'NEXT_PUBLIC_' prefix
1 2CONTENTFUL_SPACE_ID=wi8s9m1znj1z CONTENTFUL_ACCESS_TOKEN=...
Both of these are accessed only in apps/web/src/lib/contentful.ts, which runs exclusively in Server Components. They never touch the browser. NEXT_PUBLIC_ would expose them in the client bundle - not a catastrophic security risk for read-only delivery tokens, but unnecessary exposure with no upside.
NEXT_PUBLIC_ env vars are for values you legitimately need in the browser: API URLs, feature flags, analytics IDs. Read-only CMS tokens are not in that category.
Parallel fetches in the page component
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21// apps/web/src/app/page.tsx export default async function Home() { const [hero, features, articles] = await Promise.all([ fetchHero(), fetchFeatures(), fetchArticles(), ]); return ( <main className="overflow-x-hidden"> <Navbar /> <Hero headline={hero.headline} subline={hero.subline} /> <Features items={features} /> <TechStack /> <Articles articles={articles} /> <WaitlistForm /> <Contact /> <Footer /> </main> ); }
Promise.all runs all three fetches simultaneously. The three Contentful requests have no dependencies on each other - hero data is not needed to fetch features. Sequential await statements would add roughly 600ms of waterfall latency (three ~200ms requests back-to-back instead of in parallel).
There's also something subtle in the Hero invocation: notice we pass headline and subline but not ctaLabel, even though HeroContent has a ctaLabel field. The Hero CTA is currently hardcoded to "Contact us" pointing to the #contact anchor. We kept ctaLabel in the type and in Contentful for forward compatibility - if we want to make it CMS-driven later, the field is already there. This is one of the minor advantages of CMS-first thinking: adding a field costs nothing, and you can defer wiring it up until it's actually needed.
The ArticleTeaserContent pattern
Article teasers are a good example of using CMS content to drive page behavior, not just page copy.
1 2 3 4 5 6 7 8export interface ArticleTeaserContent { number: string; title: string; excerpt: string; href: string; published: boolean; // <-- this one order: number; }
The published field controls whether the Articles component renders the teaser as a clickable <a> or a non-clickable <div>. When an article goes live, you flip published to true and set href to the published URL in Contentful. No code change. No redeploy. ISR picks it up within 12 hours (or immediately on next build).
This is the kind of thing that makes a CMS worth the overhead on a content site. Shipping a new blog post means editing one entry in Contentful, not opening a pull request.
Turbo cache and the 'env' declaration
1 2 3 4 5 6 7 8 9 10 11 12 13 14// turbo.json { "tasks": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**", "dist/**"], "env": [ "NODE_ENV", "CONTENTFUL_SPACE_ID", "CONTENTFUL_ACCESS_TOKEN" ] } } }
Article 1 covered this pattern. Contentful is where it actually matters in practice.
Turborepo's build cache includes source files, config files, and declared env vars in its cache key. Without CONTENTFUL_SPACE_ID and CONTENTFUL_ACCESS_TOKEN in the env array, Turborepo doesn't know these variables affect the build output. You change the space ID - pointing at a staging space with different content - and Turborepo serves a cached build from the previous space. The content on the page is wrong, there's no error, and the cache hit indicator shows green.
Declare your env vars. Pay for it once in boilerplate, avoid it as a production debugging session.
The seed script
The seed script creates the Contentful content types and entries via the Content Management API. Run it once per space; run it twice and nothing breaks.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27// scripts/seed-contentful.ts // npx tsx scripts/seed-contentful.ts const SPACE_ID = process.env.CONTENTFUL_SPACE_ID; const MGMT_TOKEN = process.env.CONTENTFUL_MANAGEMENT_TOKEN; const BASE = `https://api.contentful.com/spaces/${SPACE_ID}/environments/master`; const HEADERS = { Authorization: `Bearer ${MGMT_TOKEN}`, "Content-Type": "application/vnd.contentful.management.v1+json", }; async function contentTypeExists(id: string): Promise<boolean> { const res = await fetch(`${BASE}/content_types/${id}`, { headers: HEADERS }); return res.status === 200; } async function upsertContentType(id: string, def: ContentTypeDef) { if (await contentTypeExists(id)) { console.log(` content type '${id}' already exists — skipping`); return; } const ct = await cma<{ sys: { version: number } }>("PUT", `/content_types/${id}`, def); await cma("PUT", `/content_types/${id}/published`, undefined, { "X-Contentful-Version": String(ct.sys.version), }); }
Idempotency works at two levels:
- Content types:
contentTypeExistschecks by ID before creating. If it exists, skip. Contentful's Management API throws a 409 on duplicate content type IDs - we avoid that entirely. - Entries:
entryCountForTypequeries how many entries exist for a content type before creating any. If the count is non-zero, skip the entire batch. This is intentionally coarse - it skips all entry creation if any entries exist - which is the right behavior for a seed script. A seed script is for initializing an empty space, not for updating an existing one.
The script also auto-loads from apps/web/.env.local without requiring dotenv as a dependency:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15try { const envPath = resolve(process.cwd(), "apps/web/.env.local"); const lines = readFileSync(envPath, "utf-8").split("\n"); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eq = trimmed.indexOf("="); if (eq === -1) continue; const key = trimmed.slice(0, eq).trim(); const val = trimmed.slice(eq + 1).trim(); if (!process.env[key]) process.env[key] = val; } } catch { // .env.local is optional - env vars may be set externally }
A manual .env parser is 15 lines. A dotenv dependency is 15 lines plus a transitive dep tree. We chose the 15 lines.
The script is registered in the root package.json:
1 2 3 4 5{ "scripts": { "seed:contentful": "npx tsx scripts/seed-contentful.ts" } }
1pnpm seed:contentful
Real pain points
1. Contentful's response shape wraps fields in an envelope
The API response for a GET /entries request is not a flat array of your content objects. It's:
1 2 3 4 5 6 7 8{ "items": [ { "sys": { "id": "...", "type": "Entry", ... }, "fields": { "title": "...", "excerpt": "..." } } ] }
If you treat the items directly as your content type, you'll be accessing item.fields.title instead of item.title everywhere downstream. The generic fetchEntries<T> helper unwraps this once:
1 2const json = (await res.json()) as { items: Array<{ fields: T }> }; return json.items.map((item) => item.fields);
One unwrap, one place, all callers receive clean T objects. The Contentful SDK does this for you - that's part of what the 200KB buys. Raw fetch means you do it yourself, once.
2. Why your content never updates: 'force-cache' is the default
Next.js's fetch in Server Components uses force-cache by default. This means: fetch once at build time, cache forever, never re-fetch. No warnings, no errors.
{ next: { revalidate: N } } opts into ISR. Without it, your Contentful data is fetched at build time and frozen until the next full deployment. Content editors update the CMS, wait 12 hours, nothing changes. They update it again, still nothing. The ISR behavior requires that one option. Don't forget it.
3. The seed script needs 'tsx', not 'ts-node'
tsx handles TypeScript with ESM and top-level await without extra configuration. ts-node requires --esm flags and still has edge cases with path resolution in monorepos with "type": "module" in some packages (as we have). npx tsx scripts/seed-contentful.ts just works. ts-node requires a tsconfig investigation.
We added tsx as a dev dependency at the root of the monorepo. It's also available as npx tsx without installation if you prefer not to add it.
4. Delivery Token vs. Management Token: which goes where
The Content Delivery API token (CONTENTFUL_ACCESS_TOKEN) is read-only. It can only read published content. It's safe to include in CI environment variables and server-side env files.
The Content Management API token (CONTENTFUL_MANAGEMENT_TOKEN) is not read-only. It can create, update, publish, and delete anything in your space - content types, entries, assets, the works. If it leaks, someone can wipe your content.
Keep the management token out of the running app's environment entirely. It belongs in .env.local only, used only when running the seed script. The .env.example documents this explicitly:
1 2 3 4 5 6# Delivery token - read-only, needed by the running app CONTENTFUL_ACCESS_TOKEN=your_delivery_token_here # Management token - write-access, seeder only, NOT needed by the running app # Never set this in Vercel/Railway/production env vars CONTENTFUL_MANAGEMENT_TOKEN=your_management_token_here
The comment is there so the next developer who reads the env file understands the distinction without having to dig through the Contentful docs.
5. Publish your entries after seeding
Running the seed script creates and publishes content types, then creates and publishes entries. "Published" in Contentful means the entry is available via the Content Delivery API. Entries exist in two states: draft (management API only) and published (delivery API visible).
The seed script calls PUT /entries/{id}/published on every entry it creates. But if you've been manually creating entries in the Contentful UI and left them as drafts, fetchEntries will return an empty array - and the defaults kick in. The behavior is intentional, but it can be confusing on first run if you're used to CMSes where "created" and "visible" are the same thing.
If your page is showing static defaults after seeding, check the Contentful dashboard: are your entries published (green dot) or draft (grey dot)?
FAQ
Do I need the Contentful SDK?
No. Raw fetch against the Content Delivery API does the same job, saves roughly 200KB, and is simpler to reason about.
Why doesn't my Contentful content update after publishing?
Next.js fetch in Server Components defaults to force-cache - fetch once at build time, cache forever, no warning. ISR requires opting in explicitly with a revalidate value.
Should Contentful tokens use the 'NEXT_PUBLIC_' prefix?
No. These fetches run server-side, so the tokens must stay server-only. A NEXT_PUBLIC_ prefix would inline them into the client bundle.
What's the difference between the Delivery and Management token?
The Delivery token is read-only and needed by the running app. The Management token has write access, is used only by the seed script, and should never be set in production environment variables.
Why does the seed script need 'tsx' rather than 'ts-node'?
ts-node doesn't handle the module setup this repo uses. tsx runs the script as-is.
Why is my seeded content missing from the site?
Entries created through the Management API are drafts until published. Seeding creates them; publishing makes them visible to the Delivery API.
Agency take
We add a CMS to nearly every client project, usually in the first sprint. Not because clients always know they need it, but because they always discover they need it - usually the week before launch when the CEO wants to change the hero headline.
Raw fetch over the SDK is the right call for a landing page with a handful of content types. If the project grows to a hundred content types with rich text, linked references, and locale variants, the SDK starts earning its 200KB. For a marketing page, it doesn't.
The pattern we settled on - types first, fetch library second, graceful defaults throughout - is the same pattern we use on client projects. The types live in packages/types and are shared across the monorepo. The fetch library is a thin wrapper that handles the Contentful response shape once and never leaks it upstream. The defaults ensure the site works at every stage of development, including for developers who clone the open-source repo without a Contentful account.
The seed script is the piece that pays off most obviously in agency settings. Client projects have staging spaces, production spaces, and occasionally a new developer who needs to set up a fresh environment. An idempotent seed script reduces that from a 30-minute manual task to one command.
The open-source starter is at https://github.com/DevAndDeliver/turborepo. Next up: wiring in a Cal.diy embed for the contact section and shipping the terminal window animation that replaced the old hero.
Dev and Deliver is a software agency based in Kraków, Poland. devanddeliver.com
Piotr Żarów
CTO at Dev and Deliver
Share this post
Related posts
Want to light up your ideas with us?
Kickstart your new project with us in just 1 step!
Prefer to call or write a traditional e-mail?









