Tutorials

Headless CMS for Node.js: Best Options and How to Integrate

Jay Callicott··9 min read

Headless CMS for Node.js: Best Options and How to Integrate

If you're building with Node.js, you've probably considered rolling your own content backend — a few Postgres tables, an Express API, maybe some admin UI bolted on top. It works until you need rich text editing, image transformations, localization, roles and permissions, content versioning, and a preview system. At that point, you're no longer building your product. You're building a CMS.

A headless CMS gives you a structured content API without the maintenance burden. But not every CMS integrates equally well with the Node.js ecosystem. This guide covers the best options for Node.js and TypeScript developers, with real integration patterns for Express, Next.js, and Astro.

Why Use a Headless CMS with Node.js

The case for a headless CMS comes down to three things:

You separate content from code. Content lives in its own system with its own editing interface. Your Node.js app consumes it via API. Content editors don't need access to your repo, and developers don't get paged for typo fixes.

You get a content modeling system for free. Defining content types, field validation, relationships between entities, and content workflows is genuinely hard to build well. A headless CMS gives you all of this with a UI that non-technical editors can use.

You avoid reinventing infrastructure. Image optimization, CDN-backed delivery, API caching, webhooks, audit logs, role-based access — these are solved problems. Building them from scratch in Express or Fastify is a poor use of engineering time for most teams.

The trade-off is a dependency on an external service (or a self-hosted system you need to maintain). But for most Node.js applications, the trade-off is worth it.

Comparison: Headless CMS Platforms for Node.js

Here's how the major headless CMS platforms compare when evaluated specifically for Node.js and TypeScript development:

Platform npm SDK TypeScript Support API Type Hosting Node.js DX
Strapi @strapi/strapi Built-in (v5) REST + GraphQL Self-hosted / Cloud Excellent — it is Node.js
Payload CMS payload Native TypeScript REST + GraphQL + Local API Self-hosted / Cloud Excellent — runs in your Node app
Contentful contentful Generated via CLI REST + GraphQL SaaS Good — mature SDK
Sanity @sanity/client GROQ-typed / codegen GROQ + GraphQL SaaS Good — active ecosystem
Decoupled.io decoupled-client Auto-generated from schema JSON:API + GraphQL Managed Strong — typed client, zero manual types

Strapi

Strapi is built on Node.js (Koa under the hood), so if you're a Node developer, you're working in your own ecosystem. Strapi v5 ships with TypeScript support out of the box, and the content-type schemas generate types automatically. You get REST and GraphQL APIs, both fully queryable with filtering, sorting, and relationship population.

The advantage: you can extend Strapi with custom controllers, middleware, and lifecycle hooks using the same Node.js patterns you already know. The downside: self-hosting means you own the infrastructure, and Strapi Cloud pricing can scale faster than expected.

Payload CMS

Payload is the closest thing to "build your own CMS without actually building a CMS." It's a TypeScript-native framework that runs inside your Express or Next.js application. Your content config is code — TypeScript files that define collections, fields, and access control.

Because Payload runs in-process, you can use its Local API to query content without HTTP overhead. This is powerful for server-side rendering in Next.js or data loading in Express routes. The trade-off: your CMS is coupled to your application's deployment, which can complicate scaling.

Contentful

Contentful's contentful npm package is one of the most mature CMS SDKs in the JavaScript ecosystem. The SDK supports both the Content Delivery API and Content Preview API, with built-in link resolution for nested content. For TypeScript, you run contentful-typescript-codegen or Contentful's CLI to generate types from your content model.

The integration is well-documented and stable. The main friction for Node.js developers is the type generation step — it's a separate CLI command that needs to run whenever your content model changes.

Sanity

Sanity's @sanity/client works in any Node.js environment. GROQ (Sanity's query language) is flexible but proprietary — there's a learning curve if your team is used to GraphQL or REST. For TypeScript, sanity-typegen generates types from your GROQ queries, though the types are query-derived rather than schema-derived.

Sanity's real-time capabilities are strong if you need live-updating content in your Node.js app. The Sanity Studio (the editing interface) is a React app you can embed in your own project or host separately.

Decoupled.io

Decoupled.io provides a Typed Client that generates a fully typed TypeScript client from your content schema. You run a single CLI command, and the client gives you methods for every content type with full IDE autocomplete — no hand-written GraphQL queries, no manual type definitions.

Under the hood, the client supports both JSON:API and GraphQL, so you can pick the query style that fits your Node.js application. The schema sync command introspects your Drupal backend and generates everything — types, queries, and a client factory.

Code: Fetching CMS Content in Node.js

Here's what a basic integration looks like. This pattern works in Express, Fastify, or any Node.js server:

Plain fetch with TypeScript

// types.ts — define your content shape
interface Article {
  id: string
  title: string
  slug: string
  body: string
  publishedAt: string
  author: {
    name: string
    avatar: string
  }
}

interface CMSResponse<T> {
  data: T[]
  meta: { total: number }
}

// lib/cms.ts — reusable fetch wrapper
const CMS_URL = process.env.CMS_API_URL!
const CMS_TOKEN = process.env.CMS_API_TOKEN!

export async function fetchArticles(): Promise<Article[]> {
  const res = await fetch(`${CMS_URL}/api/articles?populate=author`, {
    headers: {
      Authorization: `Bearer ${CMS_TOKEN}`,
      'Content-Type': 'application/json',
    },
  })

  if (!res.ok) {
    throw new Error(`CMS error: ${res.status} ${res.statusText}`)
  }

  const json: CMSResponse<Article> = await res.json()
  return json.data
}

This works, but you're writing types by hand and keeping them in sync with your CMS schema manually. That's where typed SDKs and code generation help.

Using Decoupled.io's Typed Client

import { createClient } from 'decoupled-client'
import { createTypedClient } from './schema/client'

const base = createClient({
  baseUrl: process.env.DRUPAL_BASE_URL!,
  clientId: process.env.DRUPAL_CLIENT_ID!,
  clientSecret: process.env.DRUPAL_CLIENT_SECRET!,
})

const client = createTypedClient(base)

// Fully typed — fields, relationships, and return types
// are all inferred from your CMS schema
const articles = await client.getArticles({
  include: ['field_image', 'field_author'],
  sort: '-created',
  limit: 10,
})

// articles[0].title — string
// articles[0].field_image — typed image object
// articles[0].field_author.name — string

No manual type definitions. The types come from your schema, so they update when your content model changes.

Integration Patterns by Framework

Express.js

In Express, you typically fetch CMS content in route handlers or middleware. The key decision is caching — without it, every request hits your CMS API.

import express from 'express'
import { fetchArticles } from './lib/cms'

const app = express()

// Simple in-memory cache
let cache: { data: Article[]; expires: number } | null = null

app.get('/articles', async (req, res) => {
  if (!cache || Date.now() > cache.expires) {
    cache = {
      data: await fetchArticles(),
      expires: Date.now() + 60_000, // 1 minute
    }
  }
  res.json(cache.data)
})

For production, use a proper cache layer (Redis, node-cache) or set up webhook-based invalidation so your CMS pushes updates when content changes.

Next.js

Next.js gives you the most integration options. With the App Router, server components fetch CMS data at request time (or build time with static generation). Next.js also handles caching and revalidation natively. Our best CMS for Next.js guide covers this in depth.

// app/articles/page.tsx
import { createTypedClient } from '@/schema/client'

export const revalidate = 60 // revalidate every 60 seconds

export default async function ArticlesPage() {
  const client = createTypedClient(base)
  const articles = await client.getArticles({ limit: 20 })

  return (
    <ul>
      {articles.map((article) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  )
}

Astro

Astro's content layer can pull from a headless CMS at build time or on-demand with SSR. The pattern is similar to Next.js — fetch in the frontmatter section of .astro files:

---
// src/pages/articles.astro
import { fetchArticles } from '../lib/cms'

const articles = await fetchArticles()
---

<ul>
  {articles.map((article) => (
    <li><a href={`/articles/${article.slug}`}>{article.title}</a></li>
  ))}
</ul>

Astro's island architecture means your CMS data flows into static HTML by default, with interactive components hydrated only where needed. This works particularly well with headless CMS content that's mostly read-only.

Typed Clients and Code Generation

The biggest productivity win when using a headless CMS with TypeScript isn't the CMS itself — it's the type safety layer between your CMS and your code.

There are two main approaches:

GraphQL Code Generation

If your CMS exposes a GraphQL API, you can use GraphQL Code Generator to produce TypeScript types and typed query hooks from your schema and operations:

npm install -D @graphql-codegen/cli @graphql-codegen/typescript \
  @graphql-codegen/typescript-operations

You write .graphql files, run codegen, and get typed functions. This works well with Contentful, Sanity (via its GraphQL endpoint), Strapi, and Decoupled.io's GraphQL API.

The downside: it's an extra build step, and you need to keep your .graphql files in sync with your schema.

Schema-Derived Typed Clients

The alternative is a client that derives types directly from the CMS schema, eliminating the separate codegen step. Decoupled.io's Typed Client takes this approach — run npx decoupled-cli@latest schema sync, and you get a fully typed client with methods for every content type. No .graphql files, no separate codegen config.

Payload CMS works similarly — since your content types are defined in TypeScript, the types flow through your application naturally.

The approach you choose depends on your stack. If you're already using GraphQL across your application, codegen fits into your existing workflow. If you want zero-config type safety from your CMS specifically, a schema-derived typed client reduces boilerplate.

Self-Hosted vs. Managed

This decision affects your Node.js architecture more than you might expect:

Self-hosted (Strapi, Payload CMS, Decoupled.io via Drupal): You control the infrastructure. This matters for data residency, latency (co-locate your CMS and app), and cost at scale. But you own uptime, backups, and security patches.

Managed SaaS (Contentful, Sanity, Decoupled.io managed): The vendor handles infrastructure. Your Node.js app just calls an API endpoint. Simpler operationally, but you're dependent on the vendor's uptime and pricing model.

Hybrid (Decoupled.io): Decoupled.io offers managed hosting backed by an open-source Drupal foundation. If you outgrow the managed service, you can self-host the same Drupal backend. This avoids the lock-in that comes with proprietary SaaS platforms. See our headless CMS comparison for a detailed breakdown.

Choosing the Right CMS for Your Node.js Project

If you want the CMS inside your Node.js app: Payload CMS runs in-process. It's the most tightly integrated option.

If you want a CMS built on Node.js: Strapi gives you a familiar tech stack. You can debug it the same way you debug your own code.

If you want the most mature ecosystem: Contentful has the longest track record and the most battle-tested SDK for Node.js.

If you want type safety without manual work: Decoupled.io's typed client and Payload's TypeScript-native approach both eliminate hand-written types.

If you need advanced content modeling: Decoupled.io's Drupal backend offers the most sophisticated content modeling — entity references, revision workflows, multilingual support, and granular permissions.

For React-based applications, the CMS integration layer matters as much as the CMS itself. Pick a CMS whose SDK and API design match how your team actually writes Node.js and TypeScript code. A simpler integration your team understands will outperform a sophisticated one that only one developer can maintain.