Learn how to integrate Deno with Strapi v5 using native fetch and TypeScript to consume REST and GraphQL APIs, build a Fresh app, and deploy it.

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.
Deno and Strapi v5 share a philosophy: reduce boilerplate so developers focus on what matters. Here's what the combination gives you:
.ts files that consume Strapi's REST or GraphQL endpoints and run them directly with deno run. No webpack, no tsc, no build pipeline.fetch handles 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.--allow-net=your-strapi.com and --allow-env=STRAPI_API_TOKEN. If an npm dependency is compromised, it can't reach anything else.attributes wrapper in v5 means you can define interfaces that map directly to response shapes, and Deno's strict TypeScript mode catches mismatches at development time.deno test. No Jest, Mocha, or Chai configuration required. TypeScript test files execute natively with built-in coverage reports.@strapi/client if needed. Deno is also listed on Strapi's integrations directory as a supported runtime for consuming Strapi APIs.Before starting, make sure you have the following:
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.
Navigate to the Content-type Builder in the Admin Panel:
Articlearticle, plural: articles)title — Text (Short text)content — Rich text (Markdown)slug — Text (Short text, unique)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.
Your Deno application needs authenticated access to Strapi's API. Head to Settings → Global settings → API Tokens in the Admin Panel:
Deno FrontendThe 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.
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_hereNext, 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.
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.
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.
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.
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."
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_hereAdd 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.
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.
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.
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>
);
}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.
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.
No. Strapi v5 still runs on Node.js, not Deno, so Deno does not replace the Strapi backend runtime. Where Deno fits well is around Strapi: as a frontend, middleware layer, edge function, or API consumer. In a typical Deno and Strapi setup, Strapi manages content and exposes REST or GraphQL APIs, while your Deno app fetches, transforms, and renders that content for users.
No. Deno includes the Web Fetch API by default, so you can call Strapi endpoints with global fetch() and skip Axios or node-fetch. For most Deno and Strapi integrations, native fetch, JSON parsing, and explicit response.ok checks are enough for authenticated REST requests and GraphQL POST calls. That keeps your stack smaller, reduces dependency maintenance, and makes it easier to trace request failures when something breaks in production.
In Strapi v5, documentId is the stable identifier used for document-level operations, while numeric id can still appear in API responses. This matters when your Deno app fetches, updates, or deletes a specific entry through Strapi's REST or GraphQL APIs. If you use id where documentId is expected, you'll usually end up with confusing 404s or mismatched queries. Keeping that distinction straight makes Strapi v5 integrations easier to debug, test, and maintain over time.
For most teams, Fresh is a natural fit for Strapi v5 because it supports server-rendered routes, simple handlers, and minimal client-side JavaScript by default. That works especially well for content-heavy websites pulling articles, documentation, or marketing pages from Strapi. If you need a lighter API-first layer or broader runtime portability, Hono is also a strong option for consuming Strapi content from a Deno-based application without much overhead.
Store your Strapi API token in environment variables and restrict access with Deno's permission flags, such as --allow-env=STRAPI_API_URL,STRAPI_API_TOKEN. That limits what your process can read and reduces accidental exposure. In production, keep secrets in your hosting platform rather than in committed .env files. It also helps to use Strapi tokens with minimal scopes, rotate them when needed, and never expose any privileged token to browser-side JavaScript or public client code.