Introduction
Most modern web frameworks ship 100KB+ of JavaScript to the browser before rendering a single pixel. React sends its virtual DOM, Vue sends its reactivity system, Svelte sends its compiler output. Fresh takes a radically different approach: it ships zero JavaScript by default and only hydrates interactive componentsβcalled islandsβwhen explicitly marked.
This zero-runtime philosophy makes Fresh one of the fastest web frameworks in existence. A Fresh page loads as pure HTML, renders instantly, and achieves perfect Core Web Vitals scores without any optimization. When you need interactivityβa search bar, a counter, a real-time widgetβyou create an island that hydrates independently, leaving the rest of the page as static server-rendered HTML.
Built on Deno and Preact, Fresh combines the simplicity of server-rendered pages with the component model developers love. It's the framework for developers who believe the web should be fast by default.
Understanding Fresh: Core Concepts
How Zero Runtime Works
When Fresh renders a page on the server, it converts Preact components to HTML strings using preact-render-to-string. This HTML is sent to the browser with no accompanying JavaScriptβunless the page contains islands.
The browser receives:
<!-- Pure HTML - no JavaScript -->
<!DOCTYPE html>
<html>
<head>
<title>My Page</title>
<!-- Fresh automatically adds critical CSS -->
<style>
.header { font-size: 2rem; font-weight: bold; }
.content { max-width: 800px; margin: 0 auto; }
</style>
</head>
<body>
<div>
<h1 class="header">Welcome</h1>
<p class="content">This page has zero JavaScript.</p>
</div>
</body>
</html>Compare this to a typical React SPA:
<!-- React SPA - 85KB+ of JavaScript before any content -->
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script src="/react.production.min.js"></script>
<script src="/react-dom.production.min.js"></script>
<script src="/app.bundle.js"></script>
<!-- Content appears only after JS loads and executes -->
</body>
</html>The Island Model
Islands are independent Preact components that hydrate on the client. Each island:
- Has its own JavaScript bundle (only includes what it needs)
- Hydrates independently (doesn't block other islands)
- Can communicate with the server through fetch/WebSocket
- Can share state with other islands via signals
Page Layout (server-rendered HTML, 0 JS)
βββ Header (static HTML)
βββ Navigation (static HTML)
βββ Content Area
β βββ Blog Post (static HTML)
β βββ Comment Form (ISLAND - ships JS)
β βββ Like Button (ISLAND - ships JS)
βββ Sidebar
β βββ Recent Posts (static HTML)
β βββ Search (ISLAND - ships JS)
βββ Footer (static HTML)
The total JavaScript shipped is only the sum of the island bundlesβtypically 5-20KB instead of 100KB+.
Preact as the Foundation
Fresh uses Preact, which provides:
- 3KB gzipped (vs. React's 42KB)
- Same JSX syntax and hooks API as React
- Signals for fine-grained reactivity without virtual DOM diffing
- Full TypeScript support with excellent type inference
// This Preact component looks identical to React
import { useState, useEffect } from "preact/hooks";
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => setSeconds((s) => s + 1), 1000);
return () => clearInterval(interval);
}, []);
return <p>Elapsed: {seconds}s</p>;
}Architecture and Design Patterns
Project Structure
A Fresh project follows a strict directory structure:
my-fresh-app/
βββ deno.json # Deno configuration
βββ dev.ts # Development server entry
βββ main.ts # Production server entry
βββ fresh.gen.ts # Auto-generated route manifest
βββ import_map.json # Import aliases
βββ components/ # Server-rendered components (no JS)
β βββ Header.tsx
β βββ Footer.tsx
β βββ Layout.tsx
βββ islands/ # Interactive components (ship JS)
β βββ Counter.tsx
β βββ SearchBar.tsx
β βββ ThemeToggle.tsx
βββ routes/ # File-based routing
β βββ index.tsx
β βββ about.tsx
β βββ blog/
β β βββ index.tsx
β β βββ [slug].tsx
β βββ api/
β βββ hello.ts
βββ static/ # Static assets
β βββ styles.css
β βββ images/
βββ utils/ # Shared utilities
βββ posts.ts
Route Handlers and Data Loading
Every route file can export a handler for server-side data loading:
// routes/blog/[slug].tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import { Head } from "$fresh/runtime.ts";
import { loadPost } from "../../utils/posts.ts";
import LikeButton from "../../islands/LikeButton.tsx";
interface Post {
title: string;
content: string;
date: string;
slug: string;
likes: number;
}
export const handler: Handlers<Post> = {
async GET(req, ctx) {
const post = await loadPost(ctx.params.slug);
if (!post) {
return new Response("Not Found", { status: 404 });
}
return ctx.render(post);
},
};
export default function BlogPost({ data }: PageProps<Post>) {
return (
<>
<Head>
<title>{data.title} | My Blog</title>
<meta property="og:title" content={data.title} />
<meta property="og:type" content="article" />
</Head>
<article class="max-w-3xl mx-auto p-6">
<header class="mb-8">
<h1 class="text-4xl font-bold">{data.title}</h1>
<time class="text-gray-500">{data.date}</time>
</header>
{/* Static content - no JS */}
<div
class="prose"
dangerouslySetInnerHTML={{ __html: data.content }}
/>
{/* Island - ships ~2KB of JS */}
<footer class="mt-8 pt-4 border-t">
<LikeButton slug={data.slug} initialLikes={data.likes} />
</footer>
</article>
</>
);
}Middleware Stack
Fresh supports middleware for cross-cutting concerns:
// routes/_middleware.ts
import { MiddlewareHandlerContext } from "$fresh/server.ts";
// Timing middleware
export async function handler(req: Request, ctx: MiddlewareHandlerContext) {
const start = performance.now();
const response = await ctx.next();
const duration = performance.now() - start;
response.headers.set("Server-Timing", `total;dur=${duration.toFixed(2)}`);
return response;
}
// routes/api/_middleware.ts
import { MiddlewareHandlerContext } from "$fresh/server.ts";
// CORS middleware for API routes
export async function handler(req: Request, ctx: MiddlewareHandlerContext) {
if (req.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
const response = await ctx.next();
response.headers.set("Access-Control-Allow-Origin", "*");
return response;
}Step-by-Step Implementation
Creating Your First Fresh App
# Generate a new Fresh project
deno run -A https://fresh.deno.dev my-app
# Start development server
cd my-app
deno task dev
# Open http://localhost:8000Building a Landing Page
// routes/index.tsx
import { Head } from "$fresh/runtime.ts";
import Counter from "../islands/Counter.tsx";
export default function Home() {
return (
<>
<Head>
<title>Fresh Landing Page</title>
<meta name="description" content="A blazing fast landing page" />
</Head>
{/* Hero section - pure HTML */}
<section class="bg-gradient-to-r from-blue-600 to-purple-600 text-white py-20">
<div class="max-w-4xl mx-auto text-center px-6">
<h1 class="text-5xl font-bold mb-6">
Build Fast Websites
</h1>
<p class="text-xl mb-8">
Fresh ships zero JavaScript by default. Your pages load instantly.
</p>
<a href="/docs" class="bg-white text-blue-600 px-8 py-3 rounded-lg font-semibold">
Get Started
</a>
</div>
</section>
{/* Features section - pure HTML */}
<section class="py-20">
<div class="max-w-4xl mx-auto px-6 grid md:grid-cols-3 gap-8">
<div class="text-center">
<h3 class="text-xl font-bold mb-2">Zero JS</h3>
<p>Pages ship no JavaScript by default. Only islands hydrate.</p>
</div>
<div class="text-center">
<h3 class="text-xl font-bold mb-2">Edge Ready</h3>
<p>Deploy to Deno Deploy for global edge performance.</p>
</div>
<div class="text-center">
<h3 class="text-xl font-bold mb-2">TypeScript First</h3>
<p>Native TypeScript support with no build step.</p>
</div>
</div>
</section>
{/* Interactive section - island */}
<section class="py-20 bg-gray-100">
<div class="max-w-md mx-auto text-center">
<h2 class="text-3xl font-bold mb-6">Try It Live</h2>
<Counter initial={0} />
</div>
</section>
</>
);
}The Counter Island
// islands/Counter.tsx
import { useState } from "preact/hooks";
interface CounterProps {
initial: number;
}
export default function Counter({ initial }: CounterProps) {
const [count, setCount] = useState(initial);
return (
<div class="flex items-center justify-center gap-4">
<button
class="w-12 h-12 bg-red-500 text-white rounded-full text-xl"
onClick={() => setCount(count - 1)}
>
-
</button>
<span class="text-4xl font-bold w-20 text-center">{count}</span>
<button
class="w-12 h-12 bg-green-500 text-white rounded-full text-xl"
onClick={() => setCount(count + 1)}
>
+
</button>
</div>
);
}Form Handling with Islands
// islands/ContactForm.tsx
import { useState } from "preact/hooks";
interface FormState {
name: string;
email: string;
message: string;
}
export default function ContactForm() {
const [form, setForm] = useState<FormState>({ name: "", email: "", message: "" });
const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
async function handleSubmit(e: Event) {
e.preventDefault();
setStatus("sending");
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok) {
setStatus("success");
setForm({ name: "", email: "", message: "" });
} else {
setStatus("error");
}
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit} class="space-y-4 max-w-lg mx-auto">
<input
type="text"
placeholder="Your name"
value={form.name}
onInput={(e) => setForm({ ...form, name: (e.target as HTMLInputElement).value })}
class="w-full p-3 border rounded"
required
/>
<input
type="email"
placeholder="Your email"
value={form.email}
onInput={(e) => setForm({ ...form, email: (e.target as HTMLInputElement).value })}
class="w-full p-3 border rounded"
required
/>
<textarea
placeholder="Your message"
value={form.message}
onInput={(e) => setForm({ ...form, message: (e.target as HTMLTextAreaElement).value })}
class="w-full p-3 border rounded h-32"
required
/>
<button
type="submit"
disabled={status === "sending"}
class="w-full p-3 bg-blue-600 text-white rounded disabled:opacity-50"
>
{status === "sending" ? "Sending..." : "Send Message"}
</button>
{status === "success" && <p class="text-green-600">Message sent!</p>}
{status === "error" && <p class="text-red-600">Failed to send. Try again.</p>}
</form>
);
}Real-World Use Cases
Use Case 1: Documentation Site
// routes/docs/[...slug].tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import TableOfContents from "../../islands/TableOfContents.tsx";
interface DocPage {
title: string;
content: string;
headings: Array<{ id: string; text: string; level: number }>;
}
export const handler: Handlers<DocPage> = {
async GET(req, ctx) {
const slug = ctx.params.slug || "index";
const doc = await loadDoc(slug);
if (!doc) return new Response("Not Found", { status: 404 });
return ctx.render(doc);
},
};
export default function DocPage({ data }: PageProps<DocPage>) {
return (
<div class="flex max-w-6xl mx-auto">
{/* Sidebar - static HTML */}
<nav class="w-64 shrink-0 p-6 border-r">
<a href="/docs/introduction" class="block py-2">Introduction</a>
<a href="/docs/getting-started" class="block py-2">Getting Started</a>
<a href="/docs/components" class="block py-2">Components</a>
</nav>
{/* Main content */}
<main class="flex-1 p-6">
<h1>{data.title}</h1>
<div dangerouslySetInnerHTML={{ __html: data.content }} />
</main>
{/* Table of contents - island */}
<aside class="w-48 shrink-0 p-6">
<TableOfContents headings={data.headings} />
</aside>
</div>
);
}Use Case 2: E-Commerce with Cart
// islands/ProductCard.tsx
import { useSignal } from "@preact/signals";
export default function ProductCard({ product }: { product: Product }) {
const quantity = useSignal(1);
const adding = useSignal(false);
async function addToCart() {
adding.value = true;
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId: product.id, quantity: quantity.value }),
});
adding.value = false;
// Trigger cart count update
window.dispatchEvent(new CustomEvent("cart-updated"));
}
return (
<div class="border rounded-lg overflow-hidden">
<img src={product.image} alt={product.name} />
<div class="p-4">
<h3>{product.name}</h3>
<p class="text-xl font-bold">${product.price}</p>
<div class="flex items-center gap-2 mt-2">
<button onClick={() => quantity.value = Math.max(1, quantity.value - 1)}>-</button>
<span>{quantity.value}</span>
<button onClick={() => quantity.value++}>+</button>
</div>
<button
onClick={addToCart}
disabled={adding.value}
class="mt-4 w-full bg-blue-600 text-white py-2 rounded"
>
{adding.value ? "Adding..." : "Add to Cart"}
</button>
</div>
</div>
);
}Use Case 3: Real-Time Dashboard
// islands/Dashboard.tsx
import { useEffect, useState } from "preact/hooks";
interface Metrics {
visitors: number;
pageViews: number;
bounceRate: number;
avgSession: number;
}
export default function Dashboard() {
const [metrics, setMetrics] = useState<Metrics | null>(null);
useEffect(() => {
const ws = new WebSocket(`wss://${location.host}/api/ws/metrics`);
ws.onmessage = (e) => {
setMetrics(JSON.parse(e.data));
};
return () => ws.close();
}, []);
if (!metrics) return <div class="animate-pulse">Loading...</div>;
return (
<div class="grid grid-cols-4 gap-4">
<MetricCard title="Visitors" value={metrics.visitors} />
<MetricCard title="Page Views" value={metrics.pageViews} />
<MetricCard title="Bounce Rate" value={`${metrics.bounceRate}%`} />
<MetricCard title="Avg Session" value={`${metrics.avgSession}s`} />
</div>
);
}
function MetricCard({ title, value }: { title: string; value: number | string }) {
return (
<div class="bg-white p-6 rounded-lg shadow">
<p class="text-gray-500 text-sm">{title}</p>
<p class="text-3xl font-bold">{value}</p>
</div>
);
}Best Practices for Production
-
Audit island count: Run
ls islands/regularly. Each island adds JavaScript to your page. If you have more than 5-7 islands on a single page, consider restructuring. -
Use signals for shared state: Preact signals provide fine-grained reactivity without re-rendering entire component trees. Use
useSignal()instead ofuseState()for frequently changing values. -
Lazy-load heavy islands: Use
preact/compat'slazy()to defer loading of islands that aren't immediately visible (below the fold). -
Implement proper error boundaries: Wrap islands in error boundaries so a client-side crash doesn't break the entire page.
-
Use
Headcomponent for SEO: Every page should have a unique<title>and<meta description>. Fresh renders these server-side, so search engines see them without JavaScript. -
Deploy to Deno Deploy: Fresh is optimized for Deno Deploy's edge runtime. The V8 isolate model provides sub-5ms cold starts.
-
Use Tailwind CSS: Fresh has built-in Tailwind support. Configure it in
deno.jsonand use utility classes for all styling. -
Test with JavaScript disabled: Open your site with JavaScript disabled in the browser. Everything should still work except island components.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Using browser APIs in components | SSR crashes | Guard with IS_BROWSER from $fresh/runtime.ts |
| Forgetting islands directory | Component renders but doesn't hydrate | Move interactive components to islands/ |
| Large island bundles | Defeats zero-JS purpose | Keep islands small; use dynamic imports |
| Not handling SSR data | Hydration mismatch | Ensure server and client render the same initial state |
Using dangerouslySetInnerHTML with user input | XSS vulnerability | Sanitize HTML with DOMPurify before rendering |
Missing Head component | Poor SEO | Always include <Head> with title and meta tags |
Performance Optimization
// Enable Tailwind CSS with Fresh
// deno.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
},
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.6.0/",
"preact": "https://esm.sh/preact@10.19.3",
"preact/": "https://esm.sh/preact@10.19.3/",
"preact-render-to-string": "https://esm.sh/preact-render-to-string@6.3.1",
"@preact/signals": "https://esm.sh/@preact/signals@1.2.2"
}
}// Optimize static asset caching
// main.ts
import { start } from "./server.ts";
start({
port: 8000,
// Cache static assets aggressively
assetCacheControl: {
"text/css": "public, max-age=31536000, immutable",
"application/javascript": "public, max-age=31536000, immutable",
"image/*": "public, max-age=86400",
},
});Comparison with Alternatives
| Feature | Fresh | Next.js | Astro | SvelteKit |
|---|---|---|---|---|
| Default JS | 0 bytes | ~85KB | 0 bytes | ~20KB |
| Hydration | Islands | RSC + Hydration | Islands | Full |
| Bundle Size (island) | ~3KB | ~45KB | ~3KB | ~15KB |
| SSR | Deno Deploy | Vercel/Node | Any | Node/Edge |
| TypeScript | Native | Config | Config | Config |
| CSS | Tailwind built-in | Any | Any | Scoped |
| Learning Curve | Low | Medium | Low | Medium |
Advanced Patterns
Dynamic Island Loading
// routes/index.tsx
import { lazy, Suspense } from "preact/compat";
// Only load the heavy chart island when needed
const Chart = lazy(() => import("../islands/Chart.tsx"));
export default function Home() {
return (
<div>
<h1>Dashboard</h1>
{/* Static content renders immediately */}
<p>Overview of your metrics</p>
{/* Chart loads asynchronously */}
<Suspense fallback={<div class="h-64 bg-gray-100 animate-pulse" />}>
<Chart />
</Suspense>
</div>
);
}Custom Error Pages
// routes/_500.tsx
import { ErrorPageProps } from "$fresh/server.ts";
export default function Error500({ error }: ErrorPageProps) {
return (
<main class="min-h-screen flex items-center justify-center">
<div class="text-center">
<h1 class="text-6xl font-bold text-red-600">500</h1>
<p class="text-xl mt-4">Something went wrong</p>
<p class="text-gray-500 mt-2">{error?.message}</p>
<a href="/" class="mt-6 inline-block text-blue-600 underline">
Go home
</a>
</div>
</main>
);
}Testing Strategies
// tests/handler.test.ts
import { assertEquals } from "https://deno.land/std/assert/mod.ts";
Deno.test("Home page returns 200", async () => {
const resp = await fetch("http://localhost:8000/");
assertEquals(resp.status, 200);
const html = await resp.text();
assertEquals(html.includes("Welcome"), true);
});
Deno.test("Blog post returns 404 for missing slug", async () => {
const resp = await fetch("http://localhost:8000/blog/nonexistent");
assertEquals(resp.status, 404);
});
Deno.test("API returns JSON", async () => {
const resp = await fetch("http://localhost:8000/api/hello");
assertEquals(resp.status, 200);
assertEquals(resp.headers.get("content-type"), "application/json");
const data = await resp.json();
assertEquals(data.message, "Hello from Fresh!");
});
// Run with: deno test --allow-netFuture Outlook
Fresh is evolving toward:
- Partial hydration: Automatic island detection based on interactivity analysis
- Server components: React Server Components-like patterns for data-heavy pages
- Streaming SSR: Progressive HTML delivery for faster perceived performance
- Built-in image optimization: Automatic responsive images with modern formats
- Enhanced routing: Parallel routes and route groups for complex applications
The framework is becoming the standard choice for Deno-based web development, with growing adoption for documentation sites, blogs, and content-driven applications.
Conclusion
Fresh proves that web frameworks don't need to ship megabytes of JavaScript to provide a great developer experience. By defaulting to zero runtime JavaScript and using islands for targeted interactivity, Fresh achieves performance levels that traditional SPA frameworks can't match.
Key takeaways:
- Zero JS by default means perfect Core Web Vitals without optimization
- Islands architecture provides interactivity only where needed
- Preact delivers React-like DX at 3KB
- Deno native means TypeScript, testing, and deployment all in one toolchain
- Edge-first deployment to Deno Deploy for global performance
For content-driven websites where performance matters, Fresh is the framework to beat.