Introduction
Fresh is a next-generation web framework built for Deno that ships zero JavaScript to the browser by default. Unlike React, Vue, or Svelte applications that send megabytes of framework code to the client, Fresh renders everything on the server and sends pure HTMLβunless you explicitly opt into client-side interactivity through its island architecture.
This design philosophy makes Fresh one of the fastest web frameworks available. Pages load instantly because there's no hydration step, no framework bootstrap code, and no virtual DOM diffing on the client. When you do need interactivityβa counter widget, a search autocomplete, a real-time chartβyou isolate it as an "island" that hydrates independently, leaving the rest of the page as static HTML.
Fresh was created by Luca Casonato, a core Deno team member, and has been used to build the Deno documentation site, the Deno Deploy dashboard, and numerous production applications. It combines the simplicity of server-rendered PHP with the component model of React and the performance of static site generators.
Understanding Fresh: Core Concepts
The Island Architecture
The island architecture, popularized by Fresh and Astro, solves a fundamental problem in modern web development: frameworks send too much JavaScript to the client. A typical React SPA might send 200KB+ of framework code before rendering a single pixel. Fresh takes the opposite approach.
The core idea is simple: the page is rendered entirely on the server as HTML. Interactive componentsβislandsβare embedded within this HTML and hydrate independently on the client. Each island is a self-contained unit with its own state and lifecycle, completely isolated from other islands on the page.
βββββββββββββββββββββββββββββββββββββββββββββββ
β Server-Rendered HTML (no JS) β
β βββββββββββββββ βββββββββββββββββββββββ β
β β Island A β β Server Component β β
β β (hydrates) β β (static HTML) β β
β β Counter β β Blog post content β β
β βββββββββββββββ βββββββββββββββββββββββ β
β βββββββββββββββββββ β
β β Island B β Navigation, Footer β
β β (hydrates) β (static HTML) β
β β Search widget β β
β βββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββ
Zero JavaScript by Default
When you create a Fresh page without any islands, the browser receives pure HTML and CSS. The JavaScript payload is literally zero bytes. This means:
- First Contentful Paint is near-instant (no JS parsing/execution delay)
- Largest Contentful Paint happens on the first frame (server-rendered HTML)
- Cumulative Layout Shift is zero (no dynamic content shifting)
- Time to Interactive equals First Contentful Paint (everything is immediately usable)
You only pay the JavaScript cost for components that actually need client-side interactivity.
Preact Under the Hood
Fresh uses Preactβa 3KB alternative to Reactβfor its component model. Preact provides the same JSX syntax and hooks API as React but with a fraction of the bundle size. When an island hydrates, it uses Preact's lightweight runtime to manage state and re-rendering.
The key difference from React: Fresh components don't hydrate by default. A Preact component in a Fresh page is rendered to HTML on the server and then discarded on the clientβunless it's explicitly marked as an island.
Architecture and Design Patterns
File-Based Routing
Fresh uses file-system routing, similar to Next.js. The routes/ directory maps directly to URL paths:
routes/
βββ index.tsx β /
βββ about.tsx β /about
βββ blog/
β βββ index.tsx β /blog
β βββ [slug].tsx β /blog/:slug
βββ api/
β βββ users.ts β /api/users
βββ _app.tsx β Layout wrapper
Each route file exports a default component and optionally a handler for data loading:
// routes/blog/[slug].tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import { Head } from "$fresh/runtime.ts";
interface Post {
title: string;
content: string;
date: string;
}
export const handler: Handlers<Post | null> = {
async GET(req, ctx) {
const { slug } = ctx.params;
const post = await loadPost(slug);
if (!post) return ctx.render(null);
return ctx.render(post);
},
};
export default function BlogPost({ data }: PageProps<Post | null>) {
if (!data) {
return (
<>
<Head>
<title>Post Not Found</title>
</Head>
<h1>404 - Post not found</h1>
</>
);
}
return (
<>
<Head>
<title>{data.title}</title>
<meta name="description" content={data.content.slice(0, 160)} />
</Head>
<article>
<h1>{data.title}</h1>
<time>{data.date}</time>
<div dangerouslySetInnerHTML={{ __html: data.content }} />
</article>
</>
);
}Data Loading Pattern
Fresh provides two data loading mechanisms:
- Route Handlers:
export const handlerfor server-side data fetching before rendering - Async Components: Components can be
asyncand fetch data directly during server rendering
// Async component pattern (simpler)
export default async function UserList() {
const users = await fetch("https://api.example.com/users");
const data = await users.json();
return (
<ul>
{data.map((user: { id: string; name: string }) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Island Component Design
Islands are Preact components that hydrate on the client. They're placed in the islands/ directory:
// islands/Counter.tsx
import { useState } from "preact/hooks";
export default function Counter({ initial = 0 }: { initial?: number }) {
const [count, setCount] = useState(initial);
return (
<div class="counter">
<p>Count: {count}</p>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}When used in a route:
// routes/index.tsx
import Counter from "../islands/Counter.tsx";
export default function Home() {
return (
<div>
<h1>My Fresh App</h1>
{/* This section is static HTML - no JS */}
<p>Welcome to my website. This text is server-rendered.</p>
{/* This island hydrates on the client - ships JS */}
<Counter initial={0} />
{/* More static HTML */}
<footer>Built with Fresh</footer>
</div>
);
}Step-by-Step Implementation
Creating a Fresh Project
# Create a new Fresh project
deno run -A https://fresh.deno.dev my-fresh-app
# Project structure
my-fresh-app/
βββ deno.json
βββ dev.ts
βββ main.ts
βββ fresh.gen.ts
βββ import_map.json
βββ static/
β βββ favicon.ico
β βββ logo.svg
βββ islands/
β βββ Counter.tsx
βββ routes/
βββ index.tsx
βββ api/
βββ joke.tsBuilding a Blog with Fresh
// routes/index.tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import { Head } from "$fresh/runtime.ts";
interface BlogPost {
slug: string;
title: string;
excerpt: string;
date: string;
}
export const handler: Handlers<BlogPost[]> = {
async GET(req, ctx) {
const posts = await loadAllPosts();
return ctx.render(posts);
},
};
export default function BlogIndex({ data }: PageProps<BlogPost[]>) {
return (
<>
<Head>
<title>My Blog</title>
<meta name="description" content="A blog built with Fresh" />
</Head>
<main class="max-w-4xl mx-auto p-6">
<h1 class="text-4xl font-bold mb-8">Blog Posts</h1>
<div class="space-y-8">
{data.map((post) => (
<article key={post.slug} class="border-b pb-6">
<h2 class="text-2xl font-semibold">
<a href={`/blog/${post.slug}`} class="hover:text-blue-600">
{post.title}
</a>
</h2>
<time class="text-gray-500 text-sm">{post.date}</time>
<p class="mt-2 text-gray-700">{post.excerpt}</p>
</article>
))}
</div>
</main>
</>
);
}
async function loadAllPosts(): Promise<BlogPost[]> {
// Load from filesystem, database, or API
return [
{
slug: "getting-started-with-fresh",
title: "Getting Started with Fresh",
excerpt: "Learn how to build zero-JS web applications with Deno Fresh.",
date: "2024-01-15",
},
// More posts...
];
}Adding Interactive Islands
// islands/SearchAutocomplete.tsx
import { useState, useEffect } from "preact/hooks";
interface SearchResult {
title: string;
url: string;
}
export default function SearchAutocomplete() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (query.length < 2) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
setLoading(true);
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
setResults(data.results);
setLoading(false);
}, 300); // Debounce 300ms
return () => clearTimeout(timer);
}, [query]);
return (
<div class="relative">
<input
type="text"
value={query}
onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
placeholder="Search posts..."
class="w-full p-3 border rounded-lg"
/>
{loading && <div class="absolute right-3 top-3">Loading...</div>}
{results.length > 0 && (
<ul class="absolute z-10 w-full bg-white border rounded-lg mt-1 shadow-lg">
{results.map((result) => (
<li key={result.url}>
<a
href={result.url}
class="block p-3 hover:bg-gray-100"
>
{result.title}
</a>
</li>
))}
</ul>
)}
</div>
);
}API Routes
// routes/api/search.ts
import { Handlers } from "$fresh/server.ts";
export const handler: Handlers = {
async GET(req) {
const url = new URL(req.url);
const query = url.searchParams.get("q") ?? "";
if (query.length < 2) {
return Response.json({ results: [] });
}
// Search logic
const results = await searchPosts(query);
return Response.json({ results });
},
};
async function searchPosts(query: string) {
const posts = await loadAllPosts();
return posts.filter(
(post) =>
post.title.toLowerCase().includes(query.toLowerCase()) ||
post.content.toLowerCase().includes(query.toLowerCase())
);
}Real-World Use Cases
Use Case 1: E-Commerce Product Catalog
// routes/products/index.tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import AddToCart from "../../islands/AddToCart.tsx";
interface Product {
id: string;
name: string;
price: number;
image: string;
description: string;
}
export const handler: Handlers<Product[]> = {
async GET(req, ctx) {
const products = await fetchProducts();
return ctx.render(products);
},
};
export default function Products({ data }: PageProps<Product[]>) {
return (
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 p-6">
{data.map((product) => (
<div key={product.id} class="border rounded-lg overflow-hidden">
<img src={product.image} alt={product.name} class="w-full h-48 object-cover" />
<div class="p-4">
<h2 class="text-xl font-semibold">{product.name}</h2>
<p class="text-gray-600">${product.price.toFixed(2)}</p>
<p class="mt-2 text-sm">{product.description}</p>
{/* Island: only this button ships JS */}
<AddToCart productId={product.id} />
</div>
</div>
))}
</div>
);
}// islands/AddToCart.tsx
import { useState } from "preact/hooks";
export default function AddToCart({ productId }: { productId: string }) {
const [adding, setAdding] = useState(false);
const [added, setAdded] = useState(false);
async function handleClick() {
setAdding(true);
await fetch("/api/cart", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ productId, quantity: 1 }),
});
setAdding(false);
setAdded(true);
setTimeout(() => setAdded(false), 2000);
}
return (
<button
onClick={handleClick}
disabled={adding}
class="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{adding ? "Adding..." : added ? "Added!" : "Add to Cart"}
</button>
);
}Use Case 2: Dashboard with Real-Time Charts
// islands/LiveChart.tsx
import { useState, useEffect, useRef } from "preact/hooks";
interface DataPoint {
timestamp: number;
value: number;
}
export default function LiveChart() {
const [data, setData] = useState<DataPoint[]>([]);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const interval = setInterval(async () => {
const res = await fetch("/api/metrics");
const point = await res.json();
setData((prev) => [...prev.slice(-50), point]);
}, 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || data.length === 0) return;
const ctx = canvas.getContext("2d")!;
const width = canvas.width;
const height = canvas.height;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = "#3b82f6";
ctx.lineWidth = 2;
ctx.beginPath();
const maxVal = Math.max(...data.map((d) => d.value));
data.forEach((point, i) => {
const x = (i / data.length) * width;
const y = height - (point.value / maxVal) * height;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
}, [data]);
return <canvas ref={canvasRef} width={600} height={300} class="w-full" />;
}Use Case 3: Authentication with Islands
// islands/LoginForm.tsx
import { useState } from "preact/hooks";
export default function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
async function handleSubmit(e: Event) {
e.preventDefault();
setError("");
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (res.ok) {
window.location.href = "/dashboard";
} else {
const data = await res.json();
setError(data.error ?? "Login failed");
}
}
return (
<form onSubmit={handleSubmit} class="max-w-md mx-auto p-6">
<h2 class="text-2xl font-bold mb-6">Login</h2>
{error && <div class="bg-red-100 text-red-700 p-3 rounded mb-4">{error}</div>}
<input
type="email"
value={email}
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
placeholder="Email"
class="w-full p-3 border rounded mb-4"
required
/>
<input
type="password"
value={password}
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
placeholder="Password"
class="w-full p-3 border rounded mb-4"
required
/>
<button type="submit" class="w-full p-3 bg-blue-600 text-white rounded">
Sign In
</button>
</form>
);
}Best Practices for Production
-
Default to server rendering: Only create islands for components that genuinely need client-side interactivity. Every island adds JavaScript to the page. Static content like blog posts, documentation, and product descriptions should remain server-rendered HTML.
-
Keep islands small and focused: Each island should manage a single piece of interactive UIβa counter, a form, a dropdown. Don't create monolithic islands that manage entire page state.
-
Use Tailwind CSS for styling: Fresh has built-in Tailwind CSS support. Enable it in
deno.jsonand use utility classes for styling. This eliminates the need for CSS-in-JS and keeps your styles in the HTML. -
Implement proper SEO: Use the
<Head>component to set<title>,<meta>tags, and Open Graph properties. Since Fresh renders HTML on the server, search engines can index your content without JavaScript execution. -
Cache aggressively: Use Deno KV or in-memory caching for expensive data fetches. Fresh supports streaming HTML responses, which lets you send the page shell immediately while data loads.
-
Use
preact-render-to-stringfor emails: The same Preact components you use for web pages can render to HTML strings for email templates. -
Deploy to Deno Deploy: Fresh is optimized for Deno Deploy. The edge rendering model ensures fast response times globally.
-
Use TypeScript throughout: Fresh's TypeScript integration is first-class. Use strict types for props, handlers, and data loading to catch errors at compile time.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Creating too many islands | Large JavaScript payload | Audit islands; merge related interactive components |
Using useState in non-island components | Silent failure (no hydration) | Move interactive components to islands/ directory |
| Not handling loading states | Poor UX during data fetching | Use Suspense boundaries or loading indicators in islands |
| Assuming browser APIs in server code | Runtime errors | Check typeof window !== "undefined" before using browser APIs |
Forgetting Head component | Missing meta tags for SEO | Always include <Head> with title and description |
| Large server-rendered HTML | Slow initial response | Use streaming with ReadableStream for large pages |
Performance Optimization
// Use streaming for large pages
// routes/products/index.tsx
import { defineRoute } from "$fresh/server.ts";
export default defineRoute(async (req, ctx) => {
// Start streaming the page immediately
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Send page shell
controller.enqueue(encoder.encode(`<!DOCTYPE html>
<html>
<head><title>Products</title></head>
<body>
<div id="app"><h1>Products</h1><div id="product-list">`));
// Stream products as they load
for await (const product of fetchProductsStream()) {
controller.enqueue(encoder.encode(`
<div class="product">
<h2>${product.name}</h2>
<p>$${product.price}</p>
</div>
`));
}
controller.enqueue(encoder.encode(`</div></div></body></html>`));
controller.close();
},
});
return new Response(stream, {
headers: { "content-type": "text/html" },
});
});// Optimize island loading with dynamic imports
// routes/index.tsx
import { lazy, Suspense } from "preact/compat";
// Only load the chart island when the user scrolls to it
const LiveChart = lazy(() => import("../islands/LiveChart.tsx"));
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Static content loads immediately */}
<p>Welcome to your dashboard</p>
{/* Chart loads lazily */}
<Suspense fallback={<div>Loading chart...</div>}>
<LiveChart />
</Suspense>
</div>
);
}Comparison with Alternatives
| Feature | Fresh | Next.js | Astro | Remix |
|---|---|---|---|---|
| Default JS Shipped | 0 bytes | ~85KB+ | 0 bytes | ~100KB+ |
| Island Architecture | Native | Partial (RSC) | Native | No |
| Runtime | Deno | Node.js | Any | Node.js |
| TypeScript | Native | Via config | Via config | Via config |
| Styling | Tailwind (built-in) | Any CSS | Any CSS | Any CSS |
| SSR/SSG | Both | Both | Both | SSR-focused |
| Edge Deployment | Deno Deploy | Vercel Edge | Cloudflare | Any |
| Learning Curve | Low | Medium | Low | Medium |
Advanced Patterns
Shared State Between Islands
// islands/Counter.tsx
import { useState } from "preact/hooks";
import { IS_BROWSER } from "$fresh/runtime.ts";
// Use signals for shared state across islands
import { signal } from "@preact/signals";
const globalCount = signal(0);
export default function Counter() {
return (
<div>
<p>Count: {globalCount.value}</p>
<button onClick={() => globalCount.value++}>Increment</button>
</div>
);
}
// Another island can read the same signal
// islands/Display.tsx
export default function Display() {
return <p>Global count: {globalCount.value}</p>;
}Middleware Pattern
// routes/_middleware.ts
import { MiddlewareHandlerContext } from "$fresh/server.ts";
export async function handler(req: Request, ctx: MiddlewareHandlerContext) {
const start = Date.now();
// Add security headers
const response = await ctx.next();
response.headers.set("X-Response-Time", `${Date.now() - start}ms`);
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-Content-Type-Options", "nosniff");
return response;
}Testing Strategies
// tests/blog.test.ts
import { assertEquals, assertExists } from "https://deno.land/std/assert/mod.ts";
import { serve } from "https://deno.land/std/http/server.ts";
Deno.test("Blog index returns HTML", async () => {
// Import the handler directly
const { handler } = await import("../routes/index.tsx");
const req = new Request("http://localhost/");
const ctx = {
render: (data: unknown) => new Response(JSON.stringify(data), {
headers: { "content-type": "application/json" },
}),
params: {},
};
const response = await handler.GET!(req, ctx as any);
assertEquals(response.status, 200);
});
Deno.test("Search API returns filtered results", async () => {
const { handler } = await import("../routes/api/search.ts");
const req = new Request("http://localhost/api/search?q=fresh");
const ctx = {
render: (data: unknown) => Response.json(data),
};
const response = await handler.GET!(req, ctx as any);
const data = await response.json();
assertExists(data.results);
});
// Run with: deno test --allow-read --allow-netFuture Outlook
Fresh continues to evolve with new features:
- Partial hydration: Automatically determine which components need client-side JS
- Server components: React Server Components-like patterns for data fetching
- Streaming SSR: Progressive HTML rendering for faster Time to First Byte
- Built-in image optimization: Automatic responsive images with WebP/AVIF
- Enhanced island communication: Shared state between islands without signals
The framework is positioning itself as the go-to choice for content-heavy websites that need occasional interactivityβblogs, documentation sites, e-commerce catalogs, and marketing pages.
Conclusion
Fresh represents a paradigm shift in web framework design. By shipping zero JavaScript by default and using islands for targeted interactivity, it achieves performance levels that traditional SPA frameworks can't match. Combined with Deno's TypeScript-first toolchain and edge deployment capabilities, Fresh offers a compelling alternative for developers who value performance and simplicity.
Key takeaways:
- Zero JS by default means faster page loads and better Core Web Vitals scores
- Island architecture provides client-side interactivity only where needed
- Preact components offer React-like DX with a fraction of the bundle size
- File-based routing simplifies project structure and navigation
- Deno Deploy integration enables global edge deployment with zero configuration
If you're building a content-driven website and care about performance, Fresh deserves a serious look. The combination of server rendering, island architecture, and edge deployment creates a development experience that's both productive and performant.