Fresh

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

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 like routes/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.

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-backend

The 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 develop

The 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/articles

This 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]=10

For 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.
  • Flattened structure: Fields like title, slug, content, and publishedAt are directly on the data object, not nested under an attributes wrapper.
  • Pagination metadata: Access pagination information via 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.

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 find and findOne on 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-frontend

The 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-frontend

Create 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-4

Step 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 develop

Start Fresh in another:

cd my-fresh-frontend
deno task dev --env-file=.env

Open 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.

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.

Frequently Answered Questions

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.