If you're evaluating Fresh for a server-rendered app, the main tradeoff is pretty clear. You get a server-first model with zero client JavaScript by default, but you also accept a smaller ecosystem than larger JavaScript frameworks. Fresh takes the opposite approach from frameworks that ship most of the app to the browser first. It renders complete HTML on the server and sends zero JavaScript to the browser by default unless you explicitly opt in, component by component.
Fresh is Deno's full-stack web framework, built on Web Standards APIs, Preact (not React), and the islands architecture. Now at version 2.x, it powers deno.com itself and runs natively on the Deno runtime. The premise is straightforward: server-rendered by default, with client-side JavaScript as an intentional, per-component decision rather than an unavoidable baseline.
In brief:
- Fresh is a Deno-native full-stack framework that ships zero client JavaScript by default, using islands architecture for selective hydration.
- Routes render to complete HTML on the server. Only components placed in the islands/ directory send JavaScript to the browser.
- Fresh 2.x introduced a programmatic
App()API, added optional Vite integration for bundling, and cut boot time. - The framework includes built-in plugins for CORS, CSRF, and Content Security Policy (CSP), plus native support for partials and view transitions.
What Is Fresh?
Fresh is a full-stack web framework native to the Deno runtime. It uses Preact as its rendering library, supports JSX/TSX out of the box, and builds on Web Standards APIs like Request and Response throughout.
Fresh uses Preact rather than React, and in Fresh 2.x React/Preact aliasing that previously required manual esm.sh configuration now happens automatically with Vite integration, as described in the Fresh and Vite announcement. The result is a framework where the UI library itself contributes minimal overhead to the client bundle, reinforcing Fresh's zero-JS-by-default philosophy.
There's no framework-specific HTTP abstraction layer. A route handler receives a native Request object and returns a native Response object, the same interfaces defined in the Fetch API specification. If you've written a Cloudflare Worker or a Deno service, the handler signature is familiar. There's no Express-like req.query or res.send() to learn. This commitment to Web Standards means code written for Fresh route handlers is portable knowledge, not framework-locked syntax.
Fresh also inherits Deno's security model. Scripts must be explicitly granted access to the network, file system, and environment variables. A Fresh application doesn't silently read your .env files or make outbound HTTP requests without permission flags. If you've dealt with accidental access patterns in other runtimes, that's a meaningful difference.
The framework ships zero client JavaScript by default. Every page renders to complete HTML on the server. Interactive components opt into client-side hydration individually through the islands architecture. The official docs are direct about scope: "if you want to build a Single-Page-App (=SPA), then Fresh is not the right framework."
Fresh powers deno.com, Deno Deploy, and production e-commerce sites like deco.cx.
The jump from Fresh 1.x to 2.x was a substantial rewrite. File-based routing gave way to a programmatic App() API, though file routing remains available as a plugin. Vite replaced esbuild as the optional bundler, and React/Preact aliasing that previously required manual esm.sh configuration now happens automatically.
Boot time for the Fresh website dropped from 86ms to 8ms. Import paths changed from $fresh/server.ts to "fresh", and the entry point consolidated from dev.ts plus a generated manifest into a single main.ts. In practice, that means less setup to keep in your head: one entry file to understand, one import source to remember, and no generated manifest to manage or accidentally commit.
How Fresh Works: The Islands Architecture
Server-First Rendering
Every request to a Fresh application hits a server-side route handler. The handler fetches data, runs business logic, and renders JSX to full HTML. The browser receives complete markup on the first response. No blank loading screens, no spinner waiting for a JavaScript bundle to parse and execute, and no full-page hydration pass. If you're unfamiliar with how server-side rendering compares to client-side rendering, Fresh sits firmly on the server-rendered end of the spectrum.
Here's a route that renders server-side with no client JavaScript involved:
// routes/index.tsx
export default function HomePage() {
const time = new Date().toLocaleString();
return (
<p>Freshly server-rendered {time}</p>
);
}That <p> tag arrives as HTML in the initial response. The browser renders it immediately. Compare this to a traditional Single-Page Application (SPA), where the same content would require downloading, parsing, and executing a JavaScript bundle before anything appears on screen. Fresh sidesteps that hydration cost entirely for static content.
The performance implications are concrete:
- Time to First Contentful Paint (FCP) improves because the browser doesn't wait for a JavaScript bundle to download, parse, and execute before displaying content.
- The HTML is renderable the moment it arrives.
- This model is particularly useful on slow connections or low-powered devices, where JavaScript parse time can add noticeable delay before anything becomes visible.
- Search engine crawlers receive complete HTML without needing to execute JavaScript, which benefits SEO directly.
Google's crawlers can execute JavaScript, but pre-rendered HTML removes ambiguity about whether content will be indexed correctly.
Islands: Selective Client-Side Hydration
The islands architecture is the mechanism that makes zero-JS-by-default practical. Only components placed in the islands/ directory ship JavaScript to the browser. Everything else stays as static HTML with no associated runtime.
Consider a page with a static article body and one interactive counter. Fresh renders the entire page to HTML on the server, but only the counter's JavaScript is bundled and sent to the client. The article body has zero JS overhead.
// routes/index.tsx
import Counter from "../islands/Counter.tsx";
export default function Page() {
return (
<div>
<h1>My Article</h1>
<p>This paragraph is pure HTML. No JavaScript.</p>
<Counter /> {/* Only this component ships JS */}
</div>
);
}The mechanism is file placement. No annotation, no decorator, and no configuration beyond putting your component in islands/ or a (_islands) folder inside routes/. Fresh's build system scans those directories and generates client bundles automatically. The (_islands) convention is useful for co-locating interactive components alongside the routes that use them, keeping related files in the same directory rather than splitting them across the project.
One constraint to know: props passed to islands must be serializable. Props survive a server-to-HTML-to-client round-trip, so functions and class instances with behavior won't work. Primitives, plain objects, arrays, Date, URL, Map, Set, Preact Signals, and even circular references are supported.
Islands can also be nested within other islands. In that scenario, nested islands act like normal Preact components but still receive serialized props if any were present. Each island hydrates independently and asynchronously, so a failure in one island doesn't block or affect others on the page.
A useful comparison here is annotation-based client boundaries versus Fresh's file-placement model. Fresh decides the boundary through file placement, which makes it visible in your file system rather than in import chains.
This is the core performance advantage. SPA hydration cost grows with total app complexity. Islands hydration cost grows only with interactive surface area. For a deeper look at how different rendering models affect performance and SEO, see this breakdown of SSR, CSR, and SSG approaches.
Core Fresh Features
Routing and Middleware
Fresh 2.x routes are registered with App using chainable HTTP-method-specific methods:
import { App } from "fresh";
const app = new App()
.get("/", () => new Response("hello"))
.post("/upload", () => new Response("upload"))
.get("/books/:id", (ctx) => {
return new Response(`Book id: ${ctx.params.id}`);
});URLPattern syntax is supported, and static routes always take precedence over dynamic ones. Dynamic route parameters such as :id and :slug work as you'd expect, which helps when route matching starts getting more complex.
File-based routing remains available through the .fsRoutes() plugin, where routes/blog/[slug].ts maps to /blog/:slug. Route groups use parenthesized folder names. For example, routes/(marketing)/about.tsx maps to /about, with the (marketing) segment affecting layout inheritance but not the URL path.
Middleware follows the onion pattern with ctx.next():
app.use(async (ctx) => {
console.log("before handler");
const res = await ctx.next();
res.headers.set("server", "fresh server");
return res;
});Scoped middleware restricts execution to route subsets. Protecting an admin section is a single line:
app.use("/admin/", async (ctx) => {
if (!ctx.state.user?.isAdmin) return new Response("Forbidden", { status: 403 });
return ctx.next();
});Data Fetching and Context
Server-side data fetching happens inside route handlers before rendering. There's no useEffect waterfall. The ctx object carries the incoming Request, parsed URL, route parameters, and a typed state object that flows through middleware and into components:
export const handler = define.handlers({
GET(ctx) {
return { data: { foo: "Deno" } };
},
});
export default define.page<typeof handler>((props) => {
return <h1>Hello, {props.data.foo}</h1>;
});The ctx.state object is useful for cross-cutting concerns. Because it's typed and flows through the entire middleware chain, you can attach a user object in authentication middleware and make it available in downstream handlers and page components without prop drilling. That's the idiomatic pattern for auth state, request IDs, feature flags, and similar per-request data.
For reactive client-side state within islands, Fresh integrates Preact Signals via @preact/signals. Signals serialize across the server-client boundary. When the same signal object is passed to multiple islands, Fresh preserves the reference so they stay synchronized. That means two separate islands on the same page can share reactive state, and updating a signal in one island immediately reflects in the other without a global store or event bus.
Layouts, Partials, and View Transitions
Layouts inherit from parent directories. A routes/_layout.tsx wraps all routes at that level and below, giving you consistent headers, footers, and navigation without repeating markup.
Partials enable swapping page sections with fresh server-rendered content without full browser reloads. Add f-client-nav to an ancestor element, and link clicks within it trigger partial requests instead of full navigation. Form submissions within f-client-nav containers also use partial updates, which is useful for CRUD interfaces when you want responsive interactions without writing client-side fetch logic:
<body f-client-nav>
<Partial name="main-content">
<Component />
</Partial>
</body>Fresh also integrates the browser's native View Transitions API. Add f-view-transition alongside f-client-nav, and DOM updates during client-side navigation are wrapped in document.startViewTransition().
This enables smooth animated transitions between pages, including fades, slides, and morphing elements, using standard CSS animations on ::view-transition-old and ::view-transition-new pseudo-elements. Browsers without View Transitions API support fall back gracefully to standard partial behavior with no visible error. Together, these features give Fresh SPA-like fluidity while maintaining server rendering.
Built-in Plugins
Fresh ships first-party plugins for common needs: CORS headers, CSRF protection via Sec-Fetch-Site / Origin header verification, CSP headers with optional nonce injection, static file serving, and trailing slash handling. These aren't third-party middleware you need to vet. They're importable directly from "fresh".
Fresh in Context
The differences start at the runtime layer. Fresh runs on Deno with its deny-by-default security model and built-in TypeScript support. It uses Preact with a smaller bundle baseline than many larger JavaScript frameworks.
The JavaScript shipping model is the sharpest distinction. Fresh ships zero JS by default. Islands opt in explicitly. The mental model is straightforward: Fresh requires opt-in to ship JavaScript rather than treating a client runtime as the baseline.
Fresh supports on-demand SSR only. The tradeoff is clear. You get a leaner, server-first setup with less configuration overhead, but you give up some of the rendering modes and ecosystem breadth available elsewhere.
The build and configuration story reinforces that difference. Some frameworks require a build step for every deployment. Fresh does not by default, since Deno compiles TypeScript natively at runtime. Projects also stay lighter on configuration, with a single deno.json rather than several framework-specific config files.
Comparison Table
| Dimension | Fresh | Other full-stack frameworks | Static-first frameworks |
|---|---|---|---|
| Runtime | Deno | Varies | Varies |
| UI Library | Preact only | Varies | Often multi-framework |
| Default JS Shipped | Zero (islands opt-in) | Often a baseline client runtime | Often zero or selective |
| Routing Model | File-based + programmatic | Usually file-based or hybrid | Usually file-based |
| SSR / SSG / ISR | SSR only (on-demand) | Often multiple rendering modes | Often SSG-first, SSR optional |
| Islands Architecture | Yes (Preact-only) | Varies | Often yes |
| Build Step Required | No | Often yes | Often yes |
| Primary Deployment | Deno Deploy (edge) | Varies | Varies |
When to Choose Fresh
Fresh is a strong fit for server-rendered applications, content sites, e-commerce frontends, and CRUD apps, especially if your team already uses Deno. It's not the right choice for pure SPAs, projects that depend heavily on ecosystem libraries outside its Preact and Deno focus, or teams that need SSG/ISR capabilities.
Building with Fresh: Quick Start
Project Setup
Scaffold a new project with:
deno run -Ar jsr:@fresh/initThe setup wizard prompts for project name, Tailwind CSS preference, and VS Code configuration. Start the dev server with deno task dev.
The resulting structure:
my-fresh-app/
├── islands/ # Components that ship client JS
├── routes/ # File-system routing
├── static/ # CSS, images, static assets
├── components/ # Shared non-island components
├── main.ts # Server entry point
├── client.ts # Client entry point
├── deno.json # Dependencies and tasks
└── vite.config.ts # Vite configurationNo node_modules. No package.json. Dependencies are declared in deno.json using JSR specifiers, and the @/ path alias is pre-configured for clean imports.
Creating Routes and Islands
Add a route programmatically in main.ts:
import { App } from "fresh";
const app = new App()
.get("/about", (ctx) => ctx.render(
<main>
<h1>About</h1>
<p>Server-rendered, no client JS.</p>
</main>
));
app.listen();Create an interactive island in islands/Counter.tsx:
// islands/Counter.tsx
import { useSignal } from "@preact/signals";
export default function Counter() {
const count = useSignal(0);
return (
<div>
<button onClick={() => (count.value -= 1)}>-</button>
<span>{count}</span>
<button onClick={() => (count.value += 1)}>+</button>
</div>
);
}Use the island inside a route, and only the counter's JavaScript reaches the browser:
// routes/index.tsx
import Counter from "../islands/Counter.tsx";
export default function Page() {
return (
<div>
<h1>Welcome</h1>
<p>This is static HTML.</p>
<Counter />
</div>
);
}Adding Middleware and API Routes
Auth middleware scoped to /admin/*:
app.use("/admin/", async (ctx) => {
const user = ctx.state.user;
if (!user?.isAdmin) return new Response("Forbidden", { status: 403 });
return ctx.next();
});A JSON API endpoint returning Response.json():
app.get("/api/posts", async () => {
const posts = await db.posts.list();
return Response.json(posts);
});Middleware and API routes coexist with page routes in the same App() chain. The onion pattern means middleware wraps handlers cleanly without separate configuration files.
Deploying Fresh Applications
Deno Deploy
Deno Deploy connects to GitHub. Select your repository, and Fresh auto-detection is indicated by a 🍋 icon. Install and build commands populate automatically. Every push to main triggers deployment, and pull requests get preview URLs. No build configuration is required beyond what the scaffold provides.
Docker and Self-Hosting
Build the production assets, then containerize:
FROM denoland/deno:latest
ARG GIT_REVISION
ENV DENO_DEPLOYMENT_ID=${GIT_REVISION}
WORKDIR /app
COPY . .
RUN deno install --allow-scripts
RUN deno task build
EXPOSE 8000
CMD ["deno", "serve", "-A", "_fresh/server.js"]docker build --build-arg GIT_REVISION=$(git rev-parse HEAD) -t my-fresh-app .
docker run -p 8000:8000 my-fresh-appThis runs on any cloud provider that supports Docker: AWS ECS, Google Cloud, or a traditional VPS.
deno compile
deno task build
deno compile --output my-app --include _fresh -A _fresh/compiled-entry.jsThe --include _fresh flag embeds all built assets into the binary. The result runs anywhere without Deno installed on the target system. This is useful for air-gapped deployments, CLI tools with embedded servers, or environments where installing a runtime isn't practical.
Fresh's trade-off is clear: you get zero-JS-by-default performance and Deno-native tooling in exchange for a smaller ecosystem than larger JavaScript frameworks. For teams building server-rendered applications where page speed and minimal client overhead matter, it's one of the most direct paths in the Deno ecosystem. If you're exploring how a headless CMS pairs with server-rendered frontends, Deno's ecosystem already has working patterns worth examining.
Fresh and Strapi: Headless CMS Integration
Fresh's server-first model pairs naturally with a headless Content Management System (CMS) like Strapi. Content fetching happens in a route handler using native fetch() against Strapi's REST API. No special module or SDK is needed.
Set up Strapi v5 as your content backend, then fetch articles in a Fresh route:
// routes/articles/index.tsx
const STRAPI_URL = Deno.env.get("STRAPI_URL") ?? "http://localhost:1337";
const STRAPI_API_TOKEN = Deno.env.get("STRAPI_API_TOKEN") ?? "";
export const handler = define.handlers({
async GET(ctx) {
const response = await fetch(`${STRAPI_URL}/api/articles?populate=*`, {
headers: {
"Authorization": `Bearer ${STRAPI_API_TOKEN}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
return new Response("Failed to fetch articles", { status: response.status });
}
const body = await response.json();
// Strapi v5: fields are directly on body.data, not body.data.attributes
return ctx.render({ articles: body.data });
},
});
export default function ArticlesPage(props: { data: { articles: any[] } }) {
return (
<main>
<h1>Articles</h1>
<ul>
{props.data.articles.map((article) => (
<li key={article.documentId}>
<a href={`/articles/${article.documentId}`}>{article.title}</a>
</li>
))}
</ul>
</main>
);
}Note the Strapi v5 change: the response format is flattened. Attributes are directly on the data object (data.title), not nested under data.attributes as in v4. Strapi v5 also introduces documentId as the stable identifier for querying individual documents.
Fresh's server-first model means Strapi content arrives pre-rendered as full HTML in the initial response. When a crawler requests /articles/my-post, it receives complete markup without executing JavaScript. No extra SSR configuration is required. This is how Fresh works by default.
You can protect routes with Strapi JWT authentication via Fresh middleware. Store the JWT from /api/auth/local in an httpOnly cookie, then verify it on each request by calling Strapi's /api/users/me endpoint inside a scoped _middleware.ts file:
// routes/admin/_middleware.ts
const STRAPI_URL = Deno.env.get("STRAPI_URL") ?? "http://localhost:1337";
export default async function handler(req: Request, ctx) {
const cookie = ctx.req.headers.get("cookie") ?? "";
const jwt = parseCookie(cookie, "strapi_jwt");
if (!jwt) {
return new Response("Unauthorized", { status: 401 });
}
const userRes = await fetch(`${STRAPI_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${jwt}` },
});
if (!userRes.ok) {
return new Response("Unauthorized", { status: 401 });
}
ctx.state.user = await userRes.json();
return ctx.next();
}
function parseCookie(cookieHeader: string, name: string): string | null {
const cookies = cookieHeader.split(";").map((c) => c.trim());
for (const cookie of cookies) {
const [key, value] = cookie.split("=");
if (key.trim() === name) return decodeURIComponent(value ?? "");
}
return null;
}The JWT is obtained by posting user credentials to Strapi's /api/auth/local endpoint, which returns a token. Store that token in an httpOnly cookie (preventing XSS-based token theft). On each subsequent request, the middleware extracts the cookie, verifies it by calling /api/users/me, and attaches the verified user object to ctx.state. Downstream handlers and page components access the user through ctx.state.user, keeping authentication logic separated from page rendering.
Fresh's trade-off is clear: you get zero-JS-by-default performance and Deno-native tooling in exchange for a smaller ecosystem than React and Next.js. For teams building server-rendered applications where page speed and minimal client overhead matter, it's the most direct path in the Deno ecosystem.
Explore the Fresh documentation for the full API reference, or check out the Strapi and Deno integration guide to start connecting your content backend. If you're evaluating frameworks for your next project, Fresh is worth a serious look for anything that doesn't need to be an SPA.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.