These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Fresh?
Fresh is a full-stack web framework built for the Deno runtime. Its defining feature is the islands architecture: pages render as pure HTML on the server by default, and only isolated interactive components (called "islands") receive client-side JavaScript for hydration. The result is dramatically smaller JavaScript payloads compared to traditional single-page application (SPA) frameworks.
Fresh uses Preact under the hood for its component model, supports file-based routing out of the box, and runs TypeScript natively with minimal build configuration. While Fresh eliminates the need for manual webpack or Vite configuration in development, it does use Vite internally for optimizing client-side island bundles during the build process. You write TypeScript and it works directly without requiring separate transpilation setup.
Key characteristics that matter for CMS integrations:
- Server-side route handlers execute exclusively on the server before rendering, fetching external API data while keeping credentials secure in server environment variables.
- Zero JavaScript by default means Fresh renders complete HTML without shipping the framework runtime to clients, allowing content-heavy pages to load fast and be fully crawlable by search engines.
- File-based routing with dynamic segments in the
routes/directory naturally maps to CMS content slugs, enabling patterns likeroutes/blog/[slug].tsx→/blog/:slug. - Native TypeScript support in Deno enables type-safe API consumption of Strapi's REST endpoints without requiring build configuration or transpilation steps.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Fresh with Strapi
Fresh handles the presentation layer. Strapi handles the content. Together, they give you a clean separation where each tool does what it's best at.
- Ship less JavaScript to users. Fresh's islands architecture renders Strapi content as static HTML. Only interactive components like search filters or comment forms ship client-side code. Your content API responses become lightweight HTML, not full React hydrations.
- Keep API credentials off the client. Fresh route handlers run exclusively on the server. Your Strapi API tokens stay in environment variables where they belong, never bundled into client-side code.
- Give content teams independence. Strapi's Content-Type Builder and Media Library let editors create, organize, and publish content without touching code or waiting on developer deployments.
- Skip the build configuration. Deno runs TypeScript natively, and Strapi 5 provides TypeScript-friendly APIs. You get end-to-end type safety without configuring transpilers, bundlers, or build pipelines.
- Scale each layer independently. The decoupled architecture means you can deploy Fresh to Deno Deploy's edge network while hosting Strapi wherever your database lives. Neither service depends on the other's infrastructure.
- Reuse content across channels. Strapi's REST and GraphQL APIs serve the same content to your Fresh frontend, a mobile app, or any other consumer. Content teams manage once, publish everywhere.
How to Integrate Fresh with Strapi
Prerequisites
Before starting, ensure you have:
- Deno installed — installation guide
- Node.js (v20, v22, or v24 — LTS versions only; odd-numbered versions are not supported) for running Strapi.
- npm, yarn, or pnpm for Strapi's package management.
- A terminal and code editor (VS Code is recommended, with the Deno extension optional for development).
- Basic familiarity with TypeScript and REST APIs.
Step 1: Create a Strapi 5 Project
Initialize a new Strapi project using the CLI:
npx create-strapi@latest my-strapi-backendThe interactive wizard prompts you for project naming, optional Strapi Cloud login, and database configuration. For local development, SQLite works fine.
Navigate into the project and start the development server:
cd my-strapi-backend
npm run developThe Strapi development server runs at http://localhost:1337/admin. When you start the development server with npm run develop (or yarn develop), you'll be prompted to create your first admin account upon initial access to the admin panel.
Step 2: Create a Collection Type in Strapi
Head to the Content-Type Builder in the admin panel and create a new Collection Type called Article with these fields:
title— Text (required)slug— Text (required, unique)content— Rich Text (Blocks)excerpt— Text (long text)coverImage— Media (single file)
Click Save. The new content type is registered in Strapi and available for use in the Content Manager.
Now navigate to Content Manager, create sample articles, and hit Publish on each one. You need published content for the API to return results.
Step 3: Configure API Permissions
By default, Strapi content types are private. You need to explicitly grant public read access through the Users & Permissions plugin.
Go to Settings → Users & Permissions → Roles and select the Public role. Then enable permissions for the Article content type by checking the boxes for the desired actions:
- ✓
find(list multiple entries) - ✓
findOne(retrieve a single entry)
Click Save. Test the endpoint in your browser:
To fetch articles from your Strapi v5 API, use the REST endpoint:
GET /api/articlesThis endpoint returns a collection of articles with the flattened v5 response format (using documentId instead of numeric id). Add query parameters to filter, sort, and paginate:
GET /api/articles?filters[title][$contains]=Strapi&sort=publishedAt:desc&pagination[page]=1&pagination[pageSize]=10For development, the full URL is typically http://localhost:1337/api/articles. For production, replace the host with your Strapi deployment URL (e.g., https://api.yoursite.com/api/articles). Require authentication by including your API token in the Authorization header for protected endpoints.
You should see a JSON response with the flattened v5 format:
{
"data": [
{
"documentId": "abc123xyz",
"title": "My First Article",
"slug": "my-first-article",
"content": "...",
"publishedAt": "2025-01-15T10:30:00.000Z"
}
],
"meta": {
"pagination": {
"page": 1,
"pageSize": 25,
"pageCount": 1,
"total": 3
}
}
}This response demonstrates Strapi v5's flattened response format. The critical changes from v4 include:
documentIdreplaces numericid: Content is identified bydocumentId(string) instead of numericid.- Flattened structure: Fields like
title,slug,content, andpublishedAtare directly on the data object, not nested under anattributeswrapper. - Pagination metadata: Access pagination information via
response.meta.paginationwith properties includingpage,pageSize,pageCount, andtotal.
According to Strapi v5 New Response Format documentation, this format represents a breaking change from v4 where responses used data[0].attributes.title — v5 requires updating to data[0].title access patterns.
Notice: no nested attributes object. In Strapi 5, fields sit directly on each data item. If you're coming from v4, update any response.data.attributes.title patterns to response.data.title.
Step 4: Create an API Token
For server-to-server communication, API tokens are more appropriate than JWT authentication. API tokens are designed for application-level access and are ideal for scenarios like static site generation without user-specific authentication. Create API tokens through the Strapi admin panel in the settings section, where you can generate tokens with specific permissions configured through role-based access control.
Configure it:
- Name: Fresh Frontend
- Token type: Read-only (or Custom with
findandfindOneon Collection types) - Token duration: Unlimited (for development)
Copy the generated token. You won't see it again.
Step 5: Set Up a Fresh Project
Open a new terminal and initialize a Fresh project:
deno run -Ar -n @fresh/init my-fresh-frontendThe wizard guides you through project naming, optional TailwindCSS integration, and optional VSCode editor setup. You can skip or enable any of these features based on your project needs.
Navigate into the project:
cd my-fresh-frontendCreate a .env file at the project root with your Strapi connection details:
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-api-token-from-step-4Step 6: Build a Strapi API Helper
Create a reusable utility for Strapi API calls. Add a new file at lib/strapi.ts:
const STRAPI_URL = Deno.env.get("STRAPI_URL") || "http://localhost:1337";
const STRAPI_API_TOKEN = Deno.env.get("STRAPI_API_TOKEN");
interface StrapiResponse<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,
params?: Record<string, string>
): Promise<StrapiResponse<T>> {
const url = new URL(`/api/${endpoint}`, STRAPI_URL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (STRAPI_API_TOKEN) {
headers["Authorization"] = `Bearer ${STRAPI_API_TOKEN}`;
}
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
throw new Error(
`Strapi API error: ${response.status} ${response.statusText}`
);
}
return response.json();
}
export async function fetchOneFromStrapi<T>(
endpoint: string,
params?: Record<string, string>
): Promise<StrapiSingleResponse<T>> {
const url = new URL(`/api/${endpoint}`, STRAPI_URL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (STRAPI_API_TOKEN) {
headers["Authorization"] = `Bearer ${STRAPI_API_TOKEN}`;
}
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
throw new Error(
`Strapi API error: ${response.status} ${response.statusText}`
);
}
return response.json();
}
export function getStrapiMediaUrl(path: string): string {
if (path.startsWith("http")) return path;
return `${STRAPI_URL}${path}`;
}A few things to note here. The response.ok check is critical because fetch() only throws on network failures, not on HTTP error status codes like 404 or 500. Without it, you'd silently parse error pages as JSON.
When consuming Strapi's media API, image URLs are returned directly in the response within the url field (for the original) and in formats with small, medium, and large variants. You can construct full image URLs by concatenating the Strapi base URL with these paths, handling both absolute URLs from cloud storage providers like S3 and relative paths from Strapi's default local uploads folder.
Step 7: Create the Articles List Route
Replace the contents of routes/index.tsx to display articles from Strapi:
import { Handlers, PageProps } from "$fresh/server.ts";
import { fetchFromStrapi, getStrapiMediaUrl } from "@/lib/strapi.ts";
interface Article {
documentId: string;
title: string;
slug: string;
excerpt: string;
publishedAt: string;
coverImage?: {
documentId: string;
url: string;
alternativeText: string;
formats?: {
small?: { url: string; width: number; height: number };
medium?: { url: string; width: number; height: number };
large?: { url: string; width: number; height: number };
};
};
}
export const handler: Handlers = {
async GET(_req, ctx) {
try {
const response = await fetchFromStrapi<Article>("articles", {
"populate": "coverImage",
"sort": "publishedAt:desc",
});
return ctx.render({ articles: response.data, error: null });
} catch (error) {
console.error("Failed to fetch articles:", error);
return ctx.render({ articles: [], error: "Could not load articles." });
}
},
};
export default function HomePage(
{ data }: PageProps<{ articles: Article[]; error: string | null }>
) {
if (data?.error) {
return <div class="p-8 text-red-600">{data.error}</div>;
}
return (
<main class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8">Articles</h1>
<div class="grid gap-6">
{data.articles.map((article) => (
<a
href={`/articles/${article.slug}`}
key={article.documentId}
class="block p-6 border rounded-lg hover:shadow-md transition-shadow"
>
{article.coverImage && (
<img
src={getStrapiMediaUrl(
article.coverImage.formats?.medium?.url ||
article.coverImage.url
)}
alt={article.coverImage.alternativeText || article.title}
class="w-full h-48 object-cover rounded mb-4"
loading="lazy"
/>
)}
<h2 class="text-xl font-semibold">{article.title}</h2>
{article.excerpt && (
<p class="text-gray-600 mt-2">{article.excerpt}</p>
)}
<time class="text-sm text-gray-400 mt-2 block">
{new Date(article.publishedAt).toLocaleDateString()}
</time>
</a>
))}
</div>
</main>
);
}The handler runs server-side, fetches articles with the populate parameter to include cover images, and passes everything to the page component. No JavaScript ships to the browser for this page.
Step 8: Create the Single Article Route
Add a dynamic route at routes/articles/[slug].tsx:
import { Handlers, PageProps } from "$fresh/server.ts";
import { fetchFromStrapi, getStrapiMediaUrl } from "@/lib/strapi.ts";
interface Article {
documentId: string;
title: string;
slug: string;
content: string;
publishedAt: string;
coverImage?: {
url: string;
alternativeText: string;
formats?: {
small?: { url: string; width: number; height: number };
medium?: { url: string; width: number; height: number };
large?: { url: string; width: number; height: number };
};
};
}
export const handler: Handlers = {
async GET(_req, ctx) {
const { slug } = ctx.params;
try {
const response = await fetchFromStrapi<Article>("articles", {
"filters[slug][$eq]": slug,
"populate": "coverImage",
});
if (!response.data.length) {
return ctx.renderNotFound();
}
return ctx.render({ article: response.data[0] });
} catch (error) {
console.error(`Failed to fetch article "${slug}":`, error);
return ctx.renderNotFound();
}
},
};
export default function ArticlePage(
{ data }: PageProps<{ article: Article }>
) {
const { article } = data;
return (
<main class="max-w-3xl mx-auto px-4 py-8">
<a href="/" class="text-blue-600 hover:underline mb-4 inline-block">
← Back to articles
</a>
<h1 class="text-4xl font-bold mt-4 mb-2">{article.title}</h1>
<time class="text-gray-500 block mb-6">
{new Date(article.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
{article.coverImage && (
<img
src={getStrapiMediaUrl(
article.coverImage.formats?.large?.url || article.coverImage.url
)}
srcset={article.coverImage.formats
? `
${getStrapiMediaUrl(article.coverImage.formats.small?.url || article.coverImage.url)} 500w,
${getStrapiMediaUrl(article.coverImage.formats.medium?.url || article.coverImage.url)} 750w,
${getStrapiMediaUrl(article.coverImage.formats.large?.url || article.coverImage.url)} 1000w
`
: undefined}
sizes="(max-width: 500px) 500px, (max-width: 750px) 750px, 1000px"
alt={article.coverImage.alternativeText || article.title}
class="w-full rounded-lg mb-8"
/>
)}
<article class="prose max-w-none">
<div>{article.content}</div>
</article>
</main>
);
}Step 9: Run Both Projects
Start Strapi in one terminal:
cd my-strapi-backend
npm run developStart Fresh in another:
cd my-fresh-frontend
deno task dev --env-file=.envOpen http://localhost:8000 to see your articles rendered from Strapi content. Click through to individual article pages. Pages are server-rendered as HTML, with client-side JavaScript hydration only for interactive island components.
Project Example: Developer Knowledge Base with Search
Let's extend the integration into something more practical: a developer knowledge base where articles are managed in Strapi and users can filter them client-side. This demonstrates Fresh's islands architecture in action, combining server-rendered content with targeted interactivity.
Content Model Setup
In your Strapi Content-Type Builder, create a new Collection Type called Guide with these fields:
title— Text (required)slug— Text (required, unique)summary— Text (long text)body— Rich Text (Blocks)category— Enumeration (tutorial, reference, concept, troubleshooting)difficulty— Enumeration (beginner, intermediate, advanced)tags— Text
After saving the content type and configuring public permissions for find and findOne, create five to ten sample guides covering different categories and difficulty levels.
Server-Side Data Fetching
Create the route handler at routes/guides/index.tsx:
import { Handlers, PageProps } from "$fresh/server.ts";
import { fetchFromStrapi } from "@/lib/strapi.ts";
import GuideSearch from "@/islands/GuideSearch.tsx";
interface Guide {
documentId: string;
title: string;
slug: string;
summary: string;
category: string;
tags: string;
difficulty: string;
}
export const handler: Handlers = {
async GET(req, ctx) {
const url = new URL(req.url);
const category = url.searchParams.get("category");
const params: Record<string, string> = {
"sort": "title:asc",
};
if (category) {
params["filters[category][$eq]"] = category;
}
try {
const response = await fetchFromStrapi<Guide>("guides", params);
return ctx.render({
guides: response.data,
activeCategory: category,
error: null,
});
} catch (error) {
console.error("Failed to fetch guides:", error);
return ctx.render({
guides: [],
activeCategory: null,
error: "Could not load guides.",
});
}
},
};
export default function GuidesPage(
{ data }: PageProps<{
guides: Guide[];
activeCategory: string | null;
error: string | null;
}>
) {
if (data.error) {
return <div class="p-8 text-red-600">{data.error}</div>;
}
return (
<main class="max-w-5xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-2">Developer Knowledge Base</h1>
<p class="text-gray-600 mb-8">
Browse guides by category or search by title and tags.
</p>
<nav class="flex gap-2 mb-6 flex-wrap">
{['all', 'tutorial', 'reference', 'concept', 'troubleshooting'].map(
(cat) => (
<a
href={cat === 'all' ? '/guides' : `/guides?category=${cat}`}
class={`px-4 py-2 rounded-full text-sm ${
(cat === 'all' && !data.activeCategory) ||
cat === data.activeCategory
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{cat.charAt(0).toUpperCase() + cat.slice(1)}
</a>
)
)}
</nav>
<GuideSearch guides={data.guides} />
</main>
);
}The category filtering happens server-side through Strapi's REST API filters, which means the page loads with the correct content already rendered. The text search, however, needs to be interactive.
The Search Island
This is where Fresh's islands architecture shines. Create islands/GuideSearch.tsx:
import { useSignal } from "@preact/signals";
interface Guide {
documentId: string;
title: string;
slug: string;
summary: string;
category: string;
tags: string;
difficulty: string;
}
interface Props {
guides: Guide[];
}
export default function GuideSearch({ guides }: Props) {
const searchTerm = useSignal("");
const filteredGuides = guides.filter((guide) => {
if (!searchTerm.value) return true;
const term = searchTerm.value.toLowerCase();
return (
guide.title.toLowerCase().includes(term) ||
guide.tags?.toLowerCase().includes(term) ||
guide.summary?.toLowerCase().includes(term)
);
});
const difficultyColor: Record<string, string> = {
beginner: "bg-green-100 text-green-800",
intermediate: "bg-yellow-100 text-yellow-800",
advanced: "bg-red-100 text-red-800",
};
return (
<div>
<input
type="text"
placeholder="Search guides by title or tags..."
value={searchTerm.value}
onInput={(e) =>
searchTerm.value = (e.target as HTMLInputElement).value}
class="w-full p-3 border rounded-lg mb-6 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p class="text-sm text-gray-500 mb-4">
{filteredGuides.length} guide{filteredGuides.length !== 1 ? 's' : ''}{' '}
found
</p>
<div class="grid gap-4 md:grid-cols-2">
{filteredGuides.map((guide) => (
<a
href={`/guides/${guide.slug}`}
key={guide.documentId}
class="block p-5 border rounded-lg hover:shadow-md transition-shadow"
>
<div class="flex items-center gap-2 mb-2">
<span class="text-xs px-2 py-1 rounded bg-gray-100 text-gray-600">
{guide.category}
</span>
<span
class={`text-xs px-2 py-1 rounded ${
difficultyColor[guide.difficulty] || "bg-gray-100"
}`}
>
{guide.difficulty}
</span>
</div>
<h2 class="text-lg font-semibold">{guide.title}</h2>
<p class="text-gray-600 text-sm mt-1">{guide.summary}</p>
</a>
))}
</div>
</div>
);
}In Fresh's islands architecture, components are strategically divided between server-rendered static HTML and interactive islands. According to Fresh Islands documentation, most page content renders as static HTML on the server, while interactive components placed in the islands/ directory receive client-side JavaScript for hydration.
For interactive features like real-time search or filtering, developers use Preact Signals for reactive state management within those island components, while the rest of the page—including headers, navigation, and static content—remains as lightweight HTML without any client-side JavaScript overhead.
This pattern is key to the integration: the route handler fetches all the data from Strapi's REST API server-side, and the island handles the client-side interactivity. Strapi manages the content. Fresh delivers it efficiently.
Individual Guide Route
Add the detail page at routes/guides/[slug].tsx:
import { Handlers, PageProps } from "$fresh/server.ts";
import { fetchFromStrapi } from "@/lib/strapi.ts";
interface Guide {
documentId: string;
title: string;
slug: string;
summary: string;
body: string;
category: string;
difficulty: string;
tags: string;
}
export const handler: Handlers = {
async GET(_req, ctx) {
const { slug } = ctx.params;
try {
const response = await fetchFromStrapi<Guide>("guides", {
"filters[slug][$eq]": slug,
});
if (!response.data.length) {
return ctx.renderNotFound();
}
return ctx.render({ guide: response.data[0] });
} catch (error) {
console.error(`Failed to fetch guide "${slug}":`, error);
return ctx.renderNotFound();
}
},
};
export default function GuidePage({ data }: PageProps<{ guide: Guide }>) {
const { guide } = data;
return (
<main class="max-w-3xl mx-auto px-4 py-8">
<a
href="/guides"
class="text-blue-600 hover:underline mb-4 inline-block"
>
← Back to guides
</a>
<div class="flex items-center gap-2 mt-4 mb-2">
<span class="text-sm px-3 py-1 rounded bg-gray-100">{guide.category}</span>
<span class="text-sm px-3 py-1 rounded bg-blue-50 text-blue-700">
{guide.difficulty}
</span>
</div>
<h1 class="text-4xl font-bold mb-6">{guide.title}</h1>
<article class="prose max-w-none">
<div>{guide.body}</div>
</article>
{guide.tags && (
<div class="mt-8 pt-4 border-t">
<h2 class="text-sm font-semibold text-gray-500 mb-2">Tags</h2>
<div class="flex gap-2 flex-wrap">
{guide.tags.split(",").map((tag) => (
<span
key={tag.trim()}
class="text-xs px-2 py-1 bg-gray-100 rounded"
>
{tag.trim()}
</span>
))}
</div>
</div>
)}
</main>
);
}This page is entirely server-rendered — islands are not needed since there's no interactive behavior. The guide content comes straight from Strapi's API, the page renders as HTML on the server, and the browser receives zero JavaScript. Content editors can update guides through Strapi's Content Manager and changes appear on the next page load without any rebuild.
The combination works well here: Strapi's role-based access control lets you grant different content teams different permissions, the Media Library handles any images embedded in guide content, and Fresh's file-based routing system—where the directory structure directly maps to URL paths—keeps the frontend code organized by URL structure. If you need to extend this to support internationalization, Strapi's locale query parameter and Fresh's routing can handle that without major refactoring.
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 Fresh 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.