These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Deno?
Deno is a JavaScript and TypeScript runtime built on V8 that takes a different approach from Node.js. It runs TypeScript files directly without tsconfig.json, ts-node, or a build step. It implements web-standard APIs like fetch, WebSocket, and Web Crypto natively, so you write the same code you'd write in a browser.
The runtime is secure by default: programs have no file system, network, or environment variable access unless you explicitly grant it with permission flags. Deno also ships with a built-in formatter, linter, test runner, and npm compatibility via the npm: specifier, meaning you can import packages like @strapi/client without node_modules or package.json.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Deno with Strapi
Deno and Strapi v5 share a philosophy: reduce boilerplate so developers focus on what matters. Here's what the combination gives you:
- Zero-config TypeScript for API clients. Write
.tsfiles that consume Strapi's REST or GraphQL endpoints and run them directly withdeno run. No webpack, no tsc, no build pipeline. - No HTTP library required. Deno's built-in
fetchhandles every Strapi API call (GET, POST, PUT, DELETE) with the same syntax you'd use in a browser. That's one fewer dependency to audit and maintain. - Granular security scoping. Lock your application to only the Strapi domain and the specific environment variables it needs with
--allow-net=your-strapi.comand--allow-env=STRAPI_API_TOKEN. If an npm dependency is compromised, it can't reach anything else. - Strapi v5's flattened responses pair naturally with TypeScript. The removal of the nested
attributeswrapper in v5 means you can define interfaces that map directly to response shapes, and Deno's strict TypeScript mode catches mismatches at development time. - Built-in testing without npm installs. Test your Strapi client code using
deno test. No Jest, Mocha, or Chai configuration required. TypeScript test files execute natively with built-in coverage reports. - Edge deployment with npm support. Deno Deploy runs your Strapi-consuming app globally on edge infrastructure, and it supports npm packages, so you can use
@strapi/clientif needed. Deno is also listed on Strapi's integrations directory as a supported runtime for consuming Strapi APIs.
How to Integrate Deno with Strapi
Prerequisites
Before starting, make sure you have the following:
- Deno v2.x installed (installation guide)
- Node.js 18+ and npm (for running Strapi)
- Strapi v5 project, either existing or new (quick start)
- A terminal and code editor
- Basic familiarity with TypeScript, REST APIs, and headless Content Management System (CMS) concepts
Step 1: Set Up a Strapi v5 Project
If you already have a Strapi v5 instance running, skip ahead to Step 2. Otherwise, scaffold a new project:
npx create-strapi@latest my-strapi-backendFollow the prompts. Select TypeScript if offered, and use --skip-cloud if you don't need the Strapi Cloud trial. Once installed, start the development server:
cd my-strapi-backend
npm run developThe Admin Panel opens at http://localhost:1337/admin. Create your admin account on first launch.
Step 2: Create a Content-Type
Navigate to the Content-type Builder in the Admin Panel:
- Click "Create new collection type"
- Set the display name to
Article - Confirm the auto-generated API IDs (singular:
article, plural:articles) - Add the following fields:
title— Text (Short text)content— Rich text (Markdown)slug— Text (Short text, unique)
- Click Save and wait for the server to restart
After saving, go to Content Manager, create two or three sample articles, and publish them. Strapi v5's Document Service API returns draft versions by default internally, but the REST API returns published content by default, so publishing is essential for your Deno app to see the data.
Step 3: Generate an API Token
Your Deno application needs authenticated access to Strapi's API. Head to Settings → Global settings → API Tokens in the Admin Panel:
- Click "Create new API Token"
- Set the name to
Deno Frontend - Choose Token duration: Unlimited (for development; use shorter durations in production)
- Set Token type to Read-only (sufficient for fetching content)
- Click Save
- Copy the token immediately. It's only displayed once
The API_TOKEN_SALT environment variable in your Strapi project is cryptographically tied to token generation. Changing it invalidates all existing tokens instantly. Treat it as immutable in production.
Verify the token works:
curl -H "Authorization: Bearer YOUR_TOKEN_HERE" http://localhost:1337/api/articlesYou should see a response with the v5 flattened format:
{
"data": [
{
"id": 2,
"documentId": "hgv1vny5cebq2l3czil1rpb3",
"Name": "BMK Paris Bamako",
"Description": null,
"createdAt": "2024-03-06T13:42:05.098Z",
"updatedAt": "2024-03-06T13:42:05.098Z",
"publishedAt": "2024-03-06T13:42:05.103Z"
}
],
"meta": {
"pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 }
}
}Note that Strapi v5 uses documentId (a 24-character alphanumeric string) instead of numeric id for all single-document operations. This is the identifier you pass to GET, PUT, and DELETE endpoints.
Step 4: Initialize the Deno Project
Create a new directory for your Deno application and set up the configuration:
mkdir deno-strapi-app && cd deno-strapi-appSet up a deno.json file with your import map and task definitions:
{
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.0",
"@strapi/client": "npm:@strapi/client"
},
"tasks": {
"dev": "deno run --allow-net --allow-env main.ts",
"start": "deno run --allow-net --allow-env --allow-read main.ts",
"test": "deno test --allow-net --allow-env"
}
}The imports field acts as a centralized dependency map, similar to package.json dependencies but without node_modules. The tasks field defines named commands you can run with deno task.
Add a .env file for local development:
STRAPI_API_URL=http://localhost:1337
STRAPI_API_TOKEN=your_api_token_hereStep 5: Build a Strapi API Client in Deno
Next, build strapi-client.ts, a reusable module for all Strapi communication:
// strapi-client.ts
const STRAPI_URL = Deno.env.get("STRAPI_API_URL");
const STRAPI_TOKEN = Deno.env.get("STRAPI_API_TOKEN");
if (!STRAPI_URL || !STRAPI_TOKEN) {
throw new Error(
"STRAPI_API_URL and STRAPI_API_TOKEN environment variables are required"
);
}
interface StrapiError {
data: null;
error: {
status: number;
name: string;
message: string;
details: Record<string, unknown>;
};
}
interface StrapiListResponse<T> {
data: T[];
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
interface StrapiSingleResponse<T> {
data: T;
meta: Record<string, unknown>;
}
export async function fetchFromStrapi<T>(
endpoint: string
): Promise<StrapiListResponse<T>> {
const response = await fetch(`${STRAPI_URL}/api/${endpoint}`, {
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorBody: StrapiError = await response.json();
throw new Error(
`Strapi API error (${errorBody.error.status}): ${errorBody.error.message}`
);
}
return await response.json();
}
export async function fetchOneFromStrapi<T>(
endpoint: string,
documentId: string
): Promise<StrapiSingleResponse<T>> {
const response = await fetch(
`${STRAPI_URL}/api/${endpoint}/${documentId}`,
{
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
const errorBody: StrapiError = await response.json();
throw new Error(
`Strapi API error (${errorBody.error.status}): ${errorBody.error.message}`
);
}
return await response.json();
}A few things to note here: Deno's fetch does not reject on HTTP error status codes. You must check response.ok explicitly. Strapi v5's error responses include structured error.name, error.message, and error.details fields. Use them for meaningful error messages rather than just the status code.
Step 6: Fetch Content from Strapi
In main.ts, use the client:
// main.ts
import { fetchFromStrapi, fetchOneFromStrapi } from "./strapi-client.ts";
interface Article {
documentId: string;
title: string;
content: string;
slug: string;
publishedAt: string;
}
// Fetch all articles
const articlesResponse = await fetchFromStrapi<Article>("articles");
console.log(`Found ${articlesResponse.meta.pagination.total} articles:\n`);
for (const article of articlesResponse.data) {
console.log(`- ${article.title} (${article.documentId})`);
}
// Fetch a single article by documentId
if (articlesResponse.data.length > 0) {
const firstId = articlesResponse.data[0].documentId;
const single = await fetchOneFromStrapi<Article>("articles", firstId);
console.log(`\nFull article: ${single.data.title}`);
console.log(`Content: ${single.data.content}`);
}Run it with scoped permissions:
deno run --allow-net=localhost:1337 --allow-env=STRAPI_API_URL,STRAPI_API_TOKEN main.tsThe --allow-net=localhost:1337 flag restricts network access to your Strapi instance only. The --allow-env flag limits which environment variables the process can read. If a dependency tries to phone home or read your SSH keys, Deno blocks it.
Step 7: Query with Filters and Population
Strapi v5 does not populate relations by default. If your Article has a relation to an Author content type, you need to request it explicitly.
Add a filtered query function to strapi-client.ts:
// strapi-client.ts (add to existing file)
export async function queryStrapi<T>(
endpoint: string,
params: Record<string, string>
): Promise<StrapiListResponse<T>> {
const searchParams = new URLSearchParams(params);
const url = `${STRAPI_URL}/api/${endpoint}?${searchParams.toString()}`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorBody: StrapiError = await response.json();
throw new Error(
`Strapi API error (${errorBody.error.status}): ${errorBody.error.message}`
);
}
return await response.json();
}Use it with Strapi's filter operators:
// Fetch articles with populated author, filtered by title
const filtered = await queryStrapi<Article>("articles", {
"populate": "*",
"filters[title][$containsi]": "deno",
"sort[0]": "publishedAt:desc",
"pagination[pageSize]": "10",
});
console.log(`Matching articles: ${filtered.meta.pagination.total}`);Key filter operators you'll use regularly: $eq, $containsi (case-insensitive substring), $in (array membership), $between (range), and logical combiners like $or and $and.
Step 8: Add GraphQL Support (Optional)
If you prefer GraphQL, install the GraphQL plugin in your Strapi project. You can find it along with other community plugins on the Strapi marketplace:
cd my-strapi-backend
npm install @strapi/plugin-graphql
npm run developThe plugin exposes a single endpoint at /graphql. From your Deno app, create a reusable GraphQL client:
// graphql-client.ts
const STRAPI_URL = Deno.env.get("STRAPI_API_URL") ?? "http://localhost:1337";
const STRAPI_TOKEN = Deno.env.get("STRAPI_API_TOKEN") ?? "";
export async function queryStrapiGraphQL<T>(
query: string,
variables: Record<string, unknown> = {}
): Promise<T> {
const response = await fetch(`${STRAPI_URL}/graphql`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${STRAPI_TOKEN}`,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`Network error: ${response.status}`);
}
const body = await response.json();
// GraphQL [returns HTTP 200 even when queries fail](https://graphql.org/learn/debug-errors/)
if (body.errors?.length > 0) {
throw new Error(
`GraphQL errors: ${body.errors.map((e: { message: string }) => e.message).join(", ")}`
);
}
return body.data;
}Fetch articles using the v5 GraphQL API:
// main-graphql.ts
import { queryStrapiGraphQL } from "./graphql-client.ts";
interface ArticlesData {
articles: Array<{
documentId: string;
title: string;
content: string;
}>;
}
const data = await queryStrapiGraphQL<ArticlesData>(`
query GetArticles {
articles {
documentId
title
content
}
}
`);
for (const article of data.articles) {
console.log(`- ${article.title}`);
}For single document queries, use the string-based documentId, not a numeric ID:
query {
article(documentId: "abc123xyz456def789ghi012") {
title
content
}
}The dual error checking in queryStrapiGraphQL is important. Strapi's GraphQL error responses arrive as an errors array, with each entry containing a message and an extensions.error object with name, message, and details fields, but they arrive with an HTTP 200 status, so response.ok alone won't catch them.
Project Example: Articles Blog with Deno Fresh and Strapi
Let's build a server-rendered articles blog using Fresh, Deno's full-stack framework, with Strapi v5 as the content backend. Fresh renders all pages server-side by default and ships zero JavaScript to the client unless you explicitly create interactive "islands."
Initialize the Fresh Project
deno run -A jsr:@fresh/init@^2.0.0 fresh-strapi-blog
cd fresh-strapi-blogAdd Strapi environment variables to a .env file in the project root:
STRAPI_API_URL=http://localhost:1337/api
STRAPI_API_TOKEN=your_api_token_hereCreate the Strapi Client Utility
Add a shared utility for Strapi calls. Create utils/strapi.ts:
// utils/strapi.ts
const STRAPI_URL = Deno.env.get("STRAPI_API_URL") ?? "http://localhost:1337";
const STRAPI_TOKEN = Deno.env.get("STRAPI_API_TOKEN") ?? "";
export interface Article {
documentId: string;
title: string;
content: string;
slug: string;
publishedAt: string;
}
export interface StrapiListResponse<T> {
data: T[];
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface StrapiSingleResponse<T> {
data: T;
meta: Record<string, unknown>;
}
async function strapiHeaders(): Promise<Headers> {
const headers = new Headers();
headers.set("Content-Type", "application/json");
headers.set("Authorization", `Bearer ${STRAPI_TOKEN}`);
return headers;
}
export async function getArticles(): Promise<StrapiListResponse<Article>> {
const response = await fetch(
`${STRAPI_URL}/api/articles?sort[0]=publishedAt:desc`,
{ headers: await strapiHeaders() }
);
if (!response.ok) {
throw new Error(`Failed to fetch articles: ${response.status}`);
}
return response.json();
}
export async function getArticleBySlug(
slug: string
): Promise<Article | null> {
const response = await fetch(
`${STRAPI_URL}/api/articles?filters[slug][$eq]=${encodeURIComponent(slug)}`,
{ headers: await strapiHeaders() }
);
if (!response.ok) {
throw new Error(`Failed to fetch article: ${response.status}`);
}
const result: StrapiListResponse<Article> = await response.json();
return result.data.length > 0 ? result.data[0] : null;
}
export async function createArticle(
articleData: { title: string; content: string; slug: string }
): Promise<StrapiSingleResponse<Article>> {
const response = await fetch(`${STRAPI_URL}/api/articles`, {
method: "POST",
headers: await strapiHeaders(),
body: JSON.stringify({ data: articleData }),
});
if (!response.ok) {
const errorBody = await response.json();
throw new Error(
`Failed to create article: ${errorBody.error?.message ?? response.status}`
);
}
return response.json();
}The filter filters[slug][$eq]=value uses Strapi's exact match operator to find an article by slug. This avoids querying by documentId in URL paths, giving you clean /articles/my-article-slug routes.
Build the Articles Listing Page
Create routes/articles.tsx:
// routes/articles.tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import { getArticles, Article } from "../utils/strapi.ts";
export const handler: Handlers<Article[]> = {
async GET(_req, ctx) {
try {
const response = await getArticles();
return ctx.render(response.data);
} catch (error) {
console.error("Strapi fetch error:", error);
return ctx.render([]);
}
},
};
export default function ArticlesPage({ data }: PageProps<Article[]>) {
return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Articles</h1>
{data.length === 0 ? (
<p>No articles found. Make sure Strapi is running and has published content.</p>
) : (
<ul style={{ listStyle: "none", padding: 0 }}>
{data.map((article) => (
<li key={article.documentId} style={{ marginBottom: "1.5rem" }}>
<a href={`/articles/${article.slug}`}>
<h2>{article.title}</h2>
</a>
<time style={{ color: "#666" }}>
{new Date(article.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
</li>
))}
</ul>
)}
</div>
);
}Fresh's route handlers run entirely on the server. The GET handler fetches articles from Strapi before the page renders, and the browser receives pure HTML with no JavaScript bundle.
Build the Single Article Page
Create routes/articles.tsx:
// routes/articles.tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import { getArticles, Article } from "../utils/strapi.ts";
export const handler: Handlers<Article[]> = {
async GET(_req, ctx) {
try {
const response = await getArticles();
return ctx.render(response.data);
} catch (error) {
console.error("Strapi fetch error:", error);
return ctx.render([]);
}
},
};
export default function ArticlesPage({ data }: PageProps<Article[]>) {
return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>Articles</h1>
{data.length === 0 ? (
<p>No articles found. Make sure Strapi is running and has published content.</p>
) : (
<ul style={{ listStyle: "none", padding: 0 }}>
{data.map((article) => (
<li key={article.documentId} style={{ marginBottom: "1.5rem" }}>
<a href={`/articles/${article.slug}`}>
<h2>{article.title}</h2>
</a>
<time style={{ color: "#666" }}>
{new Date(article.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
</li>
))}
</ul>
)}
</div>
);
}Fresh's route handlers run entirely on the server. The GET handler fetches articles from Strapi before the page renders, and the browser receives pure HTML with no JavaScript bundle.
Build the Single Article Page
Create routes/articles/[slug].tsx for individual article pages:
// routes/articles/[slug].tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import { getArticleBySlug, Article } from "../../utils/strapi.ts";
export const handler: Handlers<Article | null> = {
async GET(_req, ctx) {
const article = await getArticleBySlug(ctx.params.slug);
if (!article) {
return new Response("Article not found", { status: 404 });
}
return ctx.render(article);
},
};
export default function ArticlePage({ data }: PageProps<Article>) {
return (
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<a href="/articles">← Back to articles</a>
<article>
<h1>{data.title}</h1>
<time style={{ color: "#666" }}>
{new Date(data.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
<div style={{ marginTop: "2rem", lineHeight: 1.7 }}>
{data.content}
</div>
</article>
</div>
);
}Conclusion and Next Steps
You now have a practical Deno and Strapi v5 setup: a typed API client, authenticated REST and GraphQL requests, and a Fresh app that renders Strapi content on the server. From here, the useful next steps are pagination, richer relation population, role-based permissions, and deployment hardening for production. If you're turning this into a real project, add tests around your Strapi client early. That's usually where integration issues show up first.
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, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Deno 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.