Connect Fresh, Deno's full-stack web framework, with Strapi 5 to build content-driven applications that ship zero JavaScript by default, fetch data server-side, and give content teams a powerful admin panel

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:
routes/ directory naturally maps to CMS content slugs, enabling patterns like routes/blog/[slug].tsx → /blog/:slug.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.
Before starting, ensure you have:
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.
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.
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:
documentId replaces numeric id: Content is identified by documentId (string) instead of numeric id.title, slug, content, and publishedAt are directly on the data object, not nested under an attributes wrapper.response.meta.pagination with properties including page, pageSize, pageCount, and total.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.
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:
find and findOne on Collection types)Copy the generated token. You won't see it again.
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-4Create 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.
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.
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>
);
}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.
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.
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 — TextAfter saving the content type and configuring public permissions for find and findOne, create five to ten sample guides covering different categories and difficulty levels.
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.
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.
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.
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.
Yes, but GraphQL isn't included by default in Strapi 5. Install the GraphQL plugin with npm install @strapi/plugin-graphql or yarn add @strapi/plugin-graphql in your Strapi project. Once enabled, the /graphql endpoint accepts queries using documentId instead of numeric id. From Fresh, you send POST requests with your GraphQL query string from route handlers using the standard fetch API. REST is simpler for most use cases since it requires no additional setup.
Strapi's Media Library supports responsive image variants when configured. You can customize breakpoints through configuration (typically small, medium, and large sizes, though these are configurable). Include image data in API responses by adding ?populate=coverImage to your requests. Image URLs returned by Strapi are relative paths like /uploads/image.jpg, so prepend your Strapi host URL. When Strapi generates multiple size variants based on your breakpoint configuration, use srcset attributes with the different format URLs to serve appropriately sized images based on the user's viewport.
Fresh is production-ready and designed for deployment on Deno Deploy's global edge network, which provides low-latency responses worldwide. Strapi v5 supports production deployment on any Node.js hosting environment (including AWS EC2, DigitalOcean, Heroku, or Kubernetes) or through Strapi Cloud's managed hosting service. The decoupled architecture means you can scale each independently. For production, generate strong JWT_SECRET and API_TOKEN_SALT values, use read-only API tokens, and store all credentials as environment variables rather than in code.
Fresh route handlers execute exclusively on the server before rendering, so API tokens stored via Deno.env.get() never reach the browser. Avoid the FRESH_PUBLIC_ prefix for secrets since those values get inlined into client bundles at build time. If an island component needs additional data after page load, create a proxy route under routes/api/ that attaches credentials server-side, then call that internal endpoint from the island instead of calling Strapi directly.
Content-heavy pages managed through Strapi are mostly static text and images that don't require client-side JavaScript. Fresh renders these as pure HTML, shipping zero JavaScript by default. Only genuinely interactive components, like search filters, comment forms, or dynamic navigation, get hydrated as islands with their own scoped JavaScript. This approach reduces page weight, improves Core Web Vitals, speeds up Time to Interactive, and benefits SEO since crawlers receive complete HTML content on the initial response.