These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Node.js?
Node.js is an open-source, cross-platform JavaScript runtime built on Google's V8 engine. It executes JavaScript outside the browser using an asynchronous and event-driven architecture designed for building scalable network applications.
For Strapi integration specifically, a few Node.js capabilities matter most: non-blocking I/O lets a single process handle thousands of concurrent outbound API calls; the native fetch API (global in Node.js 18+) means zero dependencies for HTTP requests; and the npm ecosystem gives you access to packages like @strapi/client, qs, and Express. Since Strapi itself runs on Node.js, you get full-stack JavaScript — the same language on both sides of the API boundary.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Node.js with Strapi
Calling Strapi directly from a browser works for prototypes, but production applications benefit from a server-side intermediary. Here's what a Node.js layer gives you:
- API token isolation. Your Strapi API tokens stay in a server process. The frontend calls your Node.js service; Node.js authenticates to Strapi. The token never reaches the browser.
- Per-client response shaping. A Node.js BFF centralizes query construction and returns different payloads per client type — full relational data for web, compressed payloads for mobile — from one codebase.
- Multi-source aggregation. A product page needing content from Strapi, inventory from a warehouse API, and pricing from a commerce engine can resolve all three with
Promise.all()and return a single merged response. - Business logic encapsulation. Computed fields, user-specific filtering, and access-controlled transformations belong in a service layer, not in Strapi's auto-generated endpoints or your frontend components.
- CMS decoupling. Your frontend has no direct dependency on Strapi's response shape. When Strapi v5 introduced
documentIdand flattened responses, teams with a Node.js layer made one update — no frontend changes required. - Webhook and automation processing. Strapi v5 fires webhooks on content lifecycle events. Processing those — pushing to search indexes, triggering notifications, syncing external systems — requires server-side code.
How to Integrate Node.js with Strapi
Prerequisites
Before starting, confirm you have the following installed:
| Tool | Required Version | Notes |
|---|---|---|
| Node.js | v20, v22, or v24 (LTS) | Only even-numbered LTS releases are supported |
| npm | v6+ | Ships with Node.js |
| Strapi | v5.x | This guide uses Strapi v5 |
| Database | PostgreSQL 14+, MySQL 8+, or SQLite 3 | SQLite is fastest for local development |
You should also be comfortable with JavaScript ES modules (import/export), async/await, and basic REST API concepts.
Verify your Node.js version:
node --version
# Should output v20.x.x, v22.x.x, or v24.x.xStep 1: Create a Strapi v5 Backend
Start by scaffolding a new Strapi v5 project. When creating a project with npx create-strapi@latest, refer to the official Strapi v5 CLI documentation for the currently supported flags:
npx create-strapi@latest strapi-backend --non-interactive --skip-cloud --tsThis creates a TypeScript project with SQLite. In Strapi v5, npx create-strapi@latest can configure a PostgreSQL database connection for a new project through the interactive custom setup prompts, but the specific --dbclient, --dbhost, --dbport, --dbname, --dbusername, and --dbpassword flags could not be verified in the documentation:
npx create-strapi@latest strapi-backend \
--dbclient=postgres \
--dbhost=localhost \
--dbport=5432 \
--dbname=blogdb \
--dbusername=bloguser \
--dbpassword=secret \
--tsStart the development server:
cd strapi-backend
npm run developOpen http://localhost:1337/admin and complete the registration form to create your administrator account.
Step 2: Create a Content Type and Add Entries
Navigate to the Content-Type Builder in the Admin Panel and create a new Collection Type called Article with these fields:
title— Short text, requiredcontent— Long textslug— Short text, uniqueexcerpt— Long textpublishedDate— Date
Click Save. Strapi v5 auto-generates REST endpoints, including the default /api/articles route.
Next, open Content Manager, create two or three article entries, and Publish each one. In Strapi v5 REST API responses, published entries are returned by default; draft entries can be requested with the status=draft query parameter.
Step 3: Configure API Permissions and Generate a Token
Your Node.js app needs permission to read content. Head to Settings → Users & Permissions → Roles → Public, expand the Article section, and enable the find and findOne checkboxes. Click Save.
This allows unauthenticated access to article listings. For server-to-server communication, generate an API token instead:
- Go to
Settings → API Tokens → Create new API Token - Set Name to
node-consumer-token, Type toRead-only, Duration toUnlimited - Click Save and copy the token immediately — it's shown only once
Step 4: Initialize the Node.js Consumer Project
In a separate directory, set up your Node.js application. This guide uses @strapi/client or axios to interact with Strapi from Node.js, with express and dotenv added as needed for the application setup:
mkdir node-consumer && cd node-consumer
npm init -y
npm install @strapi/client axios express dotenvCreate a .env file with your Strapi connection details:
STRAPI_API_URL=http://localhost:1337
STRAPI_API_TOKEN=your_api_token_here
PORT=3000Add "type": "module" to package.json to enable ES module syntax:
{
"name": "node-consumer",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"@strapi/client": "latest",
"axios": "latest",
"dotenv": "^16.0.0",
"express": "^4.18.0"
}
}The node --watch flag (Node.js 18+) provides auto-restart on file changes without needing nodemon.
Step 5: Connect Using the Official Strapi SDK
The @strapi/client package is Strapi's JavaScript/TypeScript client library. It targets the REST Content API and provides collection-based abstractions with built-in TypeScript types.
Create src/strapi-client.js:
// src/strapi-client.js
import { strapi } from '@strapi/client';
import 'dotenv/config';
const client = strapi({
baseURL: process.env.STRAPI_API_URL,
auth: process.env.STRAPI_API_TOKEN,
});
const articles = client.collection('articles');
/**
* Strapi v5 responses are FLAT — fields sit directly on each data object.
* No .attributes nesting. documentId is the stable identifier.
*/
export async function getAllArticles({
page = 1,
pageSize = 10,
sort = 'publishedDate:desc',
locale = 'en',
} = {}) {
try {
return await articles.find({
sort,
locale,
pagination: { page, pageSize },
populate: '*',
});
} catch (error) {
console.error('Error fetching articles:', error.message);
throw error;
}
}
export async function getArticleById(documentId) {
try {
return await articles.findOne(documentId);
} catch (error) {
console.error(`Error fetching article ${documentId}:`, error.message);
throw error;
}
}
export async function getArticleBySlug(slug) {
try {
const result = await articles.find({
filters: { slug: { $eq: slug } },
populate: '*',
});
return result.data?.[0] ?? null;
} catch (error) {
console.error(`Error fetching article by slug "${slug}":`, error.message);
throw error;
}
}
export { client };A few things to note here. The baseURL points to the Strapi API root, including the /api prefix — for example, http://localhost:1337/api. An invalid baseURL throws a StrapiInitializationError, and a bad token throws a StrapiValidationError, so you get clear error messages if the connection setup is wrong.
Step 6: Connect Using Native Fetch
If you prefer zero dependencies for HTTP calls, native fetch works well. One behavior to watch: fetch() does not reject on HTTP 4xx/5xx responses. You need to check response.ok manually.
Create src/strapi-fetch.js:
// src/strapi-fetch.js
import 'dotenv/config';
const BASE_URL = process.env.STRAPI_API_URL;
const TOKEN = process.env.STRAPI_API_TOKEN;
async function strapiRequest(path, options = {}) {
const url = new URL(`/api${path}`, BASE_URL);
const response = await fetch(url.toString(), {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TOKEN}`,
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error(`Strapi API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
export async function getBlogPosts({ page = 1, pageSize = 10 } = {}) {
return strapiRequest(
`/articles?populate=*&sort=publishedDate:desc` +
`&pagination[page]=${page}&pagination[pageSize]=${pageSize}`
);
}
export async function getBlogPostBySlug(slug) {
const result = await strapiRequest(
`/articles?filters[slug][$eq]=${encodeURIComponent(slug)}&populate=*`
);
return result.data?.[0] ?? null;
}
// Fetch multiple endpoints concurrently
export async function fetchHomepageContent() {
const [latestPosts, categories] = await Promise.all([
strapiRequest('/articles?pagination[limit]=4&sort=publishedDate:desc&populate=*'),
strapiRequest('/categories?populate=*'),
]);
return {
latestPosts: latestPosts.data,
categories: categories.data,
};
}Step 7: Connect Using Axios
If you want an HTTP client with interceptors, reusable instances, and automatic rejection on non-2xx responses, Axios is a solid middle ground between the SDK and raw fetch.
Create src/strapi-axios.js:
// src/strapi-axios.js
import axios from 'axios';
import 'dotenv/config';
const api = axios.create({
baseURL: `${process.env.STRAPI_API_URL}/api`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
},
});
export async function getArticlesAxios({ page = 1, pageSize = 10 } = {}) {
const response = await api.get('/articles', {
params: {
populate: '*',
sort: 'publishedDate:desc',
'pagination[page]': page,
'pagination[pageSize]': pageSize,
},
});
return response.data;
}
export async function getArticleBySlugAxios(slug) {
const response = await api.get('/articles', {
params: {
populate: '*',
'filters[slug][$eq]': slug,
},
});
return response.data.data?.[0] ?? null;
}
export async function getHomepageContentAxios() {
const [articlesResponse, categoriesResponse] = await Promise.all([
api.get('/articles', {
params: {
populate: '*',
sort: 'publishedDate:desc',
'pagination[limit]': 4,
},
}),
api.get('/categories', {
params: {
populate: '*',
},
}),
]);
return {
latestPosts: articlesResponse.data.data,
categories: categoriesResponse.data.data,
};
}In practice, the tradeoff is straightforward: use @strapi/client when you want Strapi-specific abstractions, use fetch when you want no extra dependency, and use Axios when you want a general-purpose HTTP client with a little more structure.
Step 8: Build a Service Layer and Express Server
The service layer formats Strapi v5 responses for your API consumers. This is where you handle the v5-specific response structure — fields sit directly on each object, and documentId (a string) is the stable identifier instead of the numeric id.
Create src/blog-service.js:
// src/blog-service.js
import { getAllArticles, getArticleBySlug, getArticleById } from './strapi-client.js';
function formatArticle(article) {
if (!article) return null;
return {
id: article.documentId, // Use documentId, not numeric id
title: article.title,
slug: article.slug,
excerpt: article.excerpt,
content: article.content,
publishedDate: article.publishedDate,
createdAt: article.createdAt,
updatedAt: article.updatedAt,
};
}
export async function listArticles(options = {}) {
const result = await getAllArticles(options);
return {
articles: (result.data || []).map(formatArticle),
pagination: result.meta?.pagination ?? {},
};
}
export async function getArticle(documentId) {
const article = await getArticleById(documentId);
return formatArticle(article);
}
export async function getArticleBySlugService(slug) {
const article = await getArticleBySlug(slug);
return formatArticle(article);
}Now wire it up with Express in src/server.js:
// src/server.js
import express from 'express';
import 'dotenv/config';
import { listArticles, getArticle, getArticleBySlugService } from './blog-service.js';
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 3000;
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', strapiUrl: process.env.STRAPI_API_URL });
});
// GET /articles?page=1&pageSize=10&sort=publishedDate:desc
app.get('/articles', async (req, res) => {
try {
const { page = 1, pageSize = 10, sort = 'publishedDate:desc' } = req.query;
const result = await listArticles({
page: Number(page),
pageSize: Number(pageSize),
sort,
});
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET /articles/slug/:slug — must be before /articles/:documentId
app.get('/articles/slug/:slug', async (req, res) => {
try {
const article = await getArticleBySlugService(req.params.slug);
if (!article) return res.status(404).json({ error: 'Article not found' });
res.json(article);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET /articles/:documentId
app.get('/articles/:documentId', async (req, res) => {
try {
const article = await getArticle(req.params.documentId);
if (!article) return res.status(404).json({ error: 'Article not found' });
res.json(article);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.listen(PORT, () => {
console.log(`Blog API Service running on http://localhost:${PORT}`);
console.log(`Consuming Strapi v5 at: ${process.env.STRAPI_API_URL}`);
});Step 9: Test the Integration
Open two terminals. Start Strapi in the first:
cd strapi-backend
npm run developStart the Node.js consumer in the second:
cd node-consumer
npm run devTest your endpoints:
curl http://localhost:3000/health
curl http://localhost:3000/articles
curl "http://localhost:3000/articles?page=2&pageSize=5"
curl http://localhost:3000/articles/slug/my-first-postThe response from /articles should return a flat structure with documentId as the identifier — confirming you're consuming the v5 response format correctly.
Project Example: Blog API Service with Newsletter Digest
Building on the integration above, let's add a newsletter digest generator. This demonstrates a practical pattern: your Node.js service pulls recent articles from Strapi, transforms the content, and exposes a dedicated endpoint that an email service or cron job can consume.
The complete project structure looks like this:
strapi-blog-api-service/
├── strapi-backend/ # Strapi v5 CMS instance
└── node-consumer/ # Node.js application
├── src/
│ ├── strapi-client.js # @strapi/client wrapper
│ ├── strapi-fetch.js # Raw fetch alternative
│ ├── strapi-axios.js # Axios alternative
│ ├── blog-service.js # Business logic layer
│ ├── newsletter.js # Newsletter digest generator
│ └── server.js # Express API server
├── .env
└── package.jsonThe Newsletter Module
The digest generator fetches the most recent articles and formats them for email distribution. Since Strapi v5 responses are flat, you access article.title and article.excerpt directly — no .attributes nesting.
Create src/newsletter.js:
// src/newsletter.js
import { getAllArticles } from './strapi-client.js';
export async function generateNewsletterDigest({ maxArticles = 5 } = {}) {
const result = await getAllArticles({
sort: 'publishedDate:desc',
pageSize: maxArticles,
});
const articles = result.data || [];
if (articles.length === 0) {
return {
subject: 'Weekly Digest',
items: [],
generatedAt: new Date().toISOString(),
};
}
return {
subject: `Weekly Digest — ${new Date().toLocaleDateString('en-US', {
month: 'long', day: 'numeric', year: 'numeric',
})}`,
generatedAt: new Date().toISOString(),
items: articles.map((article) => ({
title: article.title,
excerpt: article.excerpt ||
(article.content ? article.content.substring(0, 150) + '...' : ''),
slug: article.slug,
publishedDate: article.publishedDate,
url: `${process.env.SITE_URL || 'https://yourblog.com'}/articles/${article.slug}`,
})),
};
}
export function formatDigestAsText(digest) {
const lines = [
`Subject: ${digest.subject}`,
`Generated: ${digest.generatedAt}`,
'',
"=== THIS WEEK'S ARTICLES ===",
'',
];
digest.items.forEach((item, i) => {
lines.push(`${i + 1}. ${item.title}`);
lines.push(` ${item.excerpt}`);
lines.push(` Read more: ${item.url}`);
lines.push('');
});
return lines.join('\n');
}Adding the Newsletter Endpoint
Add the digest route to your Express server. Append this before the app.listen() call in src/server.js:
// Add to src/server.js — import at top
import { generateNewsletterDigest, formatDigestAsText } from './newsletter.js';
// GET /newsletter/digest?maxArticles=5&format=json|text
app.get('/newsletter/digest', async (req, res) => {
try {
const { maxArticles = 5, format = 'json' } = req.query;
const digest = await generateNewsletterDigest({ maxArticles: Number(maxArticles) });
if (format === 'text') {
res.type('text/plain').send(formatDigestAsText(digest));
} else {
res.json(digest);
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});Testing the Complete Service
With both services running, test the newsletter endpoint:
# JSON format (default)
curl http://localhost:3000/newsletter/digest
# Plain text format for email pipelines
curl "http://localhost:3000/newsletter/digest?format=text&maxArticles=3"The JSON response includes structured digest data. The plain text format outputs something a cron job can pipe directly into an email service. Both formats pull live content from Strapi v5 — after you publish a new article in the Admin Panel, subsequent digest requests can include it automatically, provided the project fetches published content and no caching layer delays the update.
Where This Fits Architecturally
This blog API service demonstrates the BFF pattern in practice. The frontend never calls Strapi directly — it calls http://localhost:3000/articles and http://localhost:3000/newsletter/digest. If you later switch from Strapi to a different CMS, or upgrade Strapi's content model, you update the Node.js service layer. The frontend contract stays the same.
You can extend this pattern further with Strapi webhooks — for example, triggering a cache invalidation in your Node.js service whenever an article is published. Strapi fires entry.publish events as HTTP POST requests, and your Express server can receive and process them.
For teams using TypeScript end-to-end, @strapi/client can be used from JavaScript/TypeScript projects. Pair it with tRPC in your Node.js service, and you can get end-to-end type safety to the frontend component — with input validation and inferred response shapes checked through TypeScript, provided your backend procedures are typed appropriately.
Strapi Open Office Hours
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, at 12:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Node.js documentation.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.