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:
App() API, added optional Vite integration for bundling, and cut boot time. 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.
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:
1// routes/index.tsx
2export default function HomePage() {
3 const time = new Date().toLocaleString();
4 return (
5 <p>Freshly server-rendered {time}</p>
6 );
7}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:
Google's crawlers can execute JavaScript, but pre-rendered HTML removes ambiguity about whether content will be indexed correctly.
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.
1// routes/index.tsx
2import Counter from "../islands/Counter.tsx";
3
4export default function Page() {
5 return (
6 <div>
7 <h1>My Article</h1>
8 <p>This paragraph is pure HTML. No JavaScript.</p>
9 <Counter /> {/* Only this component ships JS */}
10 </div>
11 );
12}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.
Fresh 2.x routes are registered with App using chainable HTTP-method-specific methods:
1import { App } from "fresh";
2
3const app = new App()
4 .get("/", () => new Response("hello"))
5 .post("/upload", () => new Response("upload"))
6 .get("/books/:id", (ctx) => {
7 return new Response(`Book id: ${ctx.params.id}`);
8 });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():
1app.use(async (ctx) => {
2 console.log("before handler");
3 const res = await ctx.next();
4 res.headers.set("server", "fresh server");
5 return res;
6});Scoped middleware restricts execution to route subsets. Protecting an admin section is a single line:
1app.use("/admin/", async (ctx) => {
2 if (!ctx.state.user?.isAdmin) return new Response("Forbidden", { status: 403 });
3 return ctx.next();
4});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:
1export const handler = define.handlers({
2 GET(ctx) {
3 return { data: { foo: "Deno" } };
4 },
5});
6
7export default define.page<typeof handler>((props) => {
8 return <h1>Hello, {props.data.foo}</h1>;
9});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 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:
1<body f-client-nav>
2 <Partial name="main-content">
3 <Component />
4 </Partial>
5</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.
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".
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.
| 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 |
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.
Scaffold a new project with:
1deno 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:
1my-fresh-app/
2├── islands/ # Components that ship client JS
3├── routes/ # File-system routing
4├── static/ # CSS, images, static assets
5├── components/ # Shared non-island components
6├── main.ts # Server entry point
7├── client.ts # Client entry point
8├── deno.json # Dependencies and tasks
9└── 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.
Add a route programmatically in main.ts:
1import { App } from "fresh";
2
3const app = new App()
4 .get("/about", (ctx) => ctx.render(
5 <main>
6 <h1>About</h1>
7 <p>Server-rendered, no client JS.</p>
8 </main>
9 ));
10
11app.listen();Create an interactive island in islands/Counter.tsx:
1// islands/Counter.tsx
2import { useSignal } from "@preact/signals";
3
4export default function Counter() {
5 const count = useSignal(0);
6 return (
7 <div>
8 <button onClick={() => (count.value -= 1)}>-</button>
9 <span>{count}</span>
10 <button onClick={() => (count.value += 1)}>+</button>
11 </div>
12 );
13}Use the island inside a route, and only the counter's JavaScript reaches the browser:
1// routes/index.tsx
2import Counter from "../islands/Counter.tsx";
3
4export default function Page() {
5 return (
6 <div>
7 <h1>Welcome</h1>
8 <p>This is static HTML.</p>
9 <Counter />
10 </div>
11 );
12}Auth middleware scoped to /admin/*:
1app.use("/admin/", async (ctx) => {
2 const user = ctx.state.user;
3 if (!user?.isAdmin) return new Response("Forbidden", { status: 403 });
4 return ctx.next();
5});A JSON API endpoint returning Response.json():
1app.get("/api/posts", async () => {
2 const posts = await db.posts.list();
3 return Response.json(posts);
4});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.
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.
Build the production assets, then containerize:
1FROM denoland/deno:latest
2ARG GIT_REVISION
3ENV DENO_DEPLOYMENT_ID=${GIT_REVISION}
4WORKDIR /app
5COPY . .
6RUN deno install --allow-scripts
7RUN deno task build
8EXPOSE 8000
9CMD ["deno", "serve", "-A", "_fresh/server.js"]1docker build --build-arg GIT_REVISION=$(git rev-parse HEAD) -t my-fresh-app .
2docker run -p 8000:8000 my-fresh-appThis runs on any cloud provider that supports Docker: AWS ECS, Google Cloud, or a traditional VPS.
1deno task build
2deno 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'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:
1// routes/articles/index.tsx
2const STRAPI_URL = Deno.env.get("STRAPI_URL") ?? "http://localhost:1337";
3const STRAPI_API_TOKEN = Deno.env.get("STRAPI_API_TOKEN") ?? "";
4
5export const handler = define.handlers({
6 async GET(ctx) {
7 const response = await fetch(`${STRAPI_URL}/api/articles?populate=*`, {
8 headers: {
9 "Authorization": `Bearer ${STRAPI_API_TOKEN}`,
10 "Content-Type": "application/json",
11 },
12 });
13
14 if (!response.ok) {
15 return new Response("Failed to fetch articles", { status: response.status });
16 }
17
18 const body = await response.json();
19 // Strapi v5: fields are directly on body.data, not body.data.attributes
20 return ctx.render({ articles: body.data });
21 },
22});
23
24export default function ArticlesPage(props: { data: { articles: any[] } }) {
25 return (
26 <main>
27 <h1>Articles</h1>
28 <ul>
29 {props.data.articles.map((article) => (
30 <li key={article.documentId}>
31 <a href={`/articles/${article.documentId}`}>{article.title}</a>
32 </li>
33 ))}
34 </ul>
35 </main>
36 );
37}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:
1// routes/admin/_middleware.ts
2const STRAPI_URL = Deno.env.get("STRAPI_URL") ?? "http://localhost:1337";
3
4export default async function handler(req: Request, ctx) {
5 const cookie = ctx.req.headers.get("cookie") ?? "";
6 const jwt = parseCookie(cookie, "strapi_jwt");
7
8 if (!jwt) {
9 return new Response("Unauthorized", { status: 401 });
10 }
11
12 const userRes = await fetch(`${STRAPI_URL}/api/users/me`, {
13 headers: { Authorization: `Bearer ${jwt}` },
14 });
15
16 if (!userRes.ok) {
17 return new Response("Unauthorized", { status: 401 });
18 }
19
20 ctx.state.user = await userRes.json();
21 return ctx.next();
22}
23
24function parseCookie(cookieHeader: string, name: string): string | null {
25 const cookies = cookieHeader.split(";").map((c) => c.trim());
26 for (const cookie of cookies) {
27 const [key, value] = cookie.split("=");
28 if (key.trim() === name) return decodeURIComponent(value ?? "");
29 }
30 return null;
31}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.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.