Node.js brings JavaScript to the backend. Combined with Strapi, it’s a powerful way to build modern, scalable web applications
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.
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:
Promise.all() and return a single merged response.documentId and flattened responses, teams with a Node.js layer made one update — no frontend changes required.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.xStart 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.
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 — DateClick 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.
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:
Settings → API Tokens → Create new API Tokennode-consumer-token, Type to Read-only, Duration to UnlimitedIn 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.
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.
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,
};
}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.
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}`);
});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.
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 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');
}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 });
}
});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.
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.
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.
Integrating NodeJS with Strapi allows you to leverage Strapi's flexible content management capabilities alongside NodeJS's high-performance, event-driven architecture. This combination enables the development of scalable, efficient applications with optimal performance for both the backend and frontend.
To integrate NodeJS with Strapi, you need Strapi v4 or later, Node.js version 18 or higher, npm or yarn for package management, and basic knowledge of JavaScript and API integration. You will also need Git for version control.
Set up Strapi by running the command npx create-strapi-app my-project --quickstart. This creates a Strapi project with a NodeJS backend, and you can use Strapi's flexible content modeling features to define your content types and manage them through Strapi's admin panel.
You can use libraries like Axios to interact with Strapi’s REST API from NodeJS. Example: axios.get('http://localhost:1337/api/products') retrieves data from the Strapi API. You can also use GraphQL to fetch data, providing more flexibility for complex queries.
You can create custom middleware in Strapi to enhance the request-response cycle. For example, use middleware to handle tasks like logging, authentication, or data transformations. Strapi's middleware documentation provides detailed instructions for implementing custom middleware.
NodeJS enables non-blocking, asynchronous I/O, making it ideal for handling large numbers of concurrent requests efficiently. Strapi benefits from NodeJS's scalability and high throughput, ensuring fast API responses, even under heavy load. Additionally, NodeJS allows for faster data processing and optimized API requests.
Yes, Strapi supports both REST and GraphQL APIs, allowing you to choose the most suitable option for your needs. REST APIs are ideal for simpler use cases, while GraphQL provides more flexibility for complex data fetching, which is useful for modern front-end frameworks and advanced integrations.
Strapi uses JWT (JSON Web Tokens) for authentication. You can implement user authentication by sending login requests to Strapi's authentication endpoints, storing the JWT securely, and using it to authenticate further API requests.
Strapi and NodeJS applications can be scaled by adding more servers to handle increased traffic. You can use load balancing and clustering techniques for horizontal scaling. Additionally, leveraging caching strategies, optimizing database queries, and using CDN networks can further improve performance as your app grows.
Best practices include using secure API authentication (e.g., JWT), optimizing database queries, handling errors gracefully, storing sensitive information like API keys in environment variables, and ensuring robust logging and monitoring for real-time debugging.
Support is available through Strapi's Open Office Hours, where you can ask experts about the integration process. Additionally, you can visit Strapi's community forum, check the Strapi documentation, or engage with the community on Discord for help with troubleshooting and best practices.