Fresh 2.0: Deno's Web Framework Guide
PROGRAMMING LANGUAGES Jan. 14, 2026, 11:30 a.m.

Fresh 2.0: Deno's Web Framework Guide

Welcome to the Fresh 2.0 era, where Deno’s modern web framework blends the simplicity of server‑side rendering with the power of edge‑native JavaScript. In this guide we’ll walk through Fresh’s core concepts, spin up a real‑world app, and sprinkle in pro tips that help you squeeze every ounce of performance out of Deno’s runtime. Whether you’re a seasoned Node developer or brand new to the JavaScript ecosystem, Fresh 2.0 feels like a breath of fresh air—hence the name.

Why Fresh Stands Out

Fresh embraces the “no‑bundler” philosophy: your TypeScript files are served directly to the runtime, which means zero build steps and instant hot‑reloading during development. It also ships with first‑class support for islands architecture, allowing you to render static HTML on the server while hydrating only the interactive parts on the client. This approach reduces JavaScript payloads dramatically, especially on mobile networks.

Another differentiator is Deno’s built‑in security model. Fresh apps run in a sandboxed environment, and you explicitly grant permissions (like network or file access) when you start the server. This makes it easier to reason about security vulnerabilities compared to the “everything is allowed” mindset of traditional Node.js apps.

Getting Started: Project Scaffold

Fresh 2.0 ships with a convenient CLI that scaffolds a fully functional project in seconds. Open your terminal and run:

deno run -A -r https://fresh.deno.dev/init.ts my-fresh-app

The command creates a my-fresh-app directory containing routes, components, and a fresh.config.ts file. The -A flag grants the script all permissions needed for initialization; you’ll later tighten these permissions for production.

Navigate into the folder and start the development server:

cd my-fresh-app
deno task start

The server listens on http://localhost:8000. Open it in a browser and you’ll see a minimal landing page generated by Fresh’s default routes/index.tsx component.

Understanding the File Structure

Fresh’s convention‑over‑configuration layout is deliberately simple:

  • routes/ – Files map directly to URL paths. routes/about.tsx becomes /about.
  • components/ – Reusable UI pieces, often built as islands.
  • static/ – Public assets like images, fonts, or robots.txt.
  • fresh.config.ts – Global configuration (middleware, render options, etc.).

Each route file can export a handler for server‑side logic and a default component for rendering. This dual‑export pattern lets you keep API logic and UI side‑by‑side, reducing context switching.

Building Your First Route

Let’s replace the default homepage with a simple blog‑style list. Create routes/index.tsx (or edit the existing one) and add the following:

/** @jsx h */
import { h } from "preact";
import { HandlerContext } from "$fresh/server.ts";

type Post = {
  id: string;
  title: string;
  excerpt: string;
};

export const handler = async (_req: Request, ctx: HandlerContext) => {
  // Simulate fetching data from an external source
  const posts: Post[] = [
    { id: "1", title: "Getting Started with Fresh", excerpt: "A quick intro to the framework." },
    { id: "2", title: "Deploying to Deno Deploy", excerpt: "Zero‑config edge deployment." },
    { id: "3", title: "Island Architecture Explained", excerpt: "Why islands matter for performance." },
  ];
  // Pass data to the page component via context.state
  ctx.state.posts = posts;
  return await ctx.render();
};

export default function Home({ state }: { state: { posts: Post[] } }) {
  return (
    
    {state.posts.map((post) => (
  • {post.title}

    {post.excerpt}

  • ))}
); }

Notice the handler function runs on the server, fetches data, and stores it in ctx.state. The default export receives that state and renders the HTML. Fresh automatically streams the response, so the page appears instantly even before all posts are fully processed.

Dynamic Routes and Island Components

To display individual posts, create a dynamic route file routes/post/[id].tsx. The square‑bracket syntax tells Fresh to treat the segment as a URL parameter.

/** @jsx h */
import { h } from "preact";
import { HandlerContext } from "$fresh/server.ts";
import Counter from "../components/Counter.tsx";

type Post = {
  id: string;
  title: string;
  content: string;
};

export const handler = async (req: Request, ctx: HandlerContext) => {
  const { id } = ctx.params;
  // In a real app, replace this with a DB call
  const post: Post = {
    id,
    title: `Post #${id}`,
    content: `This is the full content for post ${id}. Fresh makes it easy to fetch data server‑side.`,
  };
  ctx.state.post = post;
  return await ctx.render();
};

export default function PostPage({ state }: { state: { post: Post } }) {
  const { post } = state;
  return (
    

{post.title}

{post.content}

{/* Island component – only this part hydrates on the client */}
); }

The Counter component is an island: it runs only in the browser, keeping the rest of the page static. Let’s implement it.

Creating an Island

Inside components/Counter.tsx add:

/** @jsx h */
import { h } from "preact";
import { useState } from "preact/hooks";

export default function Counter({ initial }: { initial: number }) {
  const [count, setCount] = useState(initial);
  return (
    

Current count: {count}

); }

Fresh automatically detects that Counter is imported from a route and bundles it as an island. No extra configuration required.

Pro Tip: Keep islands as small as possible. Even a 5 KB island can add noticeable latency on 3G networks. Split complex UI into multiple islands to let the browser load them in parallel.

Middleware: Adding Security and Logging

Fresh’s fresh.config.ts file lets you plug in middleware functions that run before every request. Let’s add a simple logger and a CSP header.

import { defineConfig } from "$fresh/server.ts";

export default defineConfig({
  middleware: [
    async (req, ctx) => {
      const start = Date.now();
      const resp = await ctx.next();
      const ms = Date.now() - start;
      console.log(`${req.method} ${req.url} – ${resp.status} (${ms}ms)`);
      return resp;
    },
    async (req, ctx) => {
      const resp = await ctx.next();
      resp.headers.set(
        "Content-Security-Policy",
        "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
      );
      return resp;
    },
  ],
});

The first middleware logs each request with response time, while the second injects a strict CSP header. Because middleware runs in the edge runtime, the overhead is negligible.

Persisting Data with Deno KV

Deno KV is a fast, globally distributed key‑value store that integrates seamlessly with Fresh. It’s perfect for small‑to‑medium data like session tokens, feature flags, or simple blog posts.

First, add the KV client to a utility module utils/kv.ts:

import { openKv } from "https://deno.land/x/kv/mod.ts";

let kv: Deno.Kv | null = null;

export async function getKv(): Promise {
  if (!kv) {
    kv = await openKv();
  }
  return kv;
}

Now modify the post handler to read from KV instead of the hard‑coded array.

import { getKv } from "../utils/kv.ts";

export const handler = async (_req: Request, ctx: HandlerContext) => {
  const kv = await getKv();
  const iterator = kv.list({ prefix: ["post"] });
  const posts = [];
  for await (const entry of iterator) {
    const [_, id] = entry.key;
    const { title, excerpt } = entry.value as { title: string; excerpt: string };
    posts.push({ id, title, excerpt });
  }
  ctx.state.posts = posts;
  return await ctx.render();
};

To seed the KV store, run a one‑off script:

deno run -A scripts/seed.ts

And scripts/seed.ts could look like:

import { getKv } from "../utils/kv.ts";

const kv = await getKv();

const samplePosts = [
  { id: "1", title: "Fresh 2.0 Overview", excerpt: "Why Fresh is a game‑changer." },
  { id: "2", title: "Edge‑Native Deployments", excerpt: "Deploy in seconds with Deno Deploy." },
];

for (const post of samplePosts) {
  await kv.set(["post", post.id], { title: post.title, excerpt: post.excerpt });
}

console.log("Seeded KV with", samplePosts.length, "posts.");

Now your blog reads data from a persistent store without any external database, keeping the deployment footprint tiny.

Deploying to Deno Deploy

Deno Deploy is a serverless platform built for the Deno runtime. It offers instant global distribution, automatic TLS, and built‑in metrics. Deploying a Fresh app is as simple as pushing your repository to GitHub and linking it.

Steps:

  1. Commit your code to a GitHub repo.
  2. Visit Deno Deploy dashboard and click “New Project”.
  3. Select “Import from GitHub” and choose your repository.
  4. Set the entry point to main.ts (generated by Fresh) and grant the minimal permissions required (usually --allow-net and --allow-read for static assets).
  5. Click “Deploy”. Deno Deploy builds the project on the fly and gives you a .deno.dev URL.

Because Fresh already runs without a bundler, the build step is lightning fast—typically under a minute. Once live, you can monitor latency, error rates, and KV usage directly from the dashboard.

Pro Tip: Enable “Edge Functions” for any custom API routes that need ultra‑low latency. Edge Functions run on the same global network as static assets, ensuring sub‑100 ms response times worldwide.

Real‑World Use Cases

1. Content‑Heavy Sites – News portals, documentation sites, and blogs benefit from Fresh’s static rendering combined with islands for interactive comment widgets or live search.

2. SaaS Dashboards – Use server‑side handlers to fetch analytics data, then hydrate charts as islands using lightweight libraries like Chart.js. The static portion loads instantly, while charts appear once the client JavaScript is ready.

3. Edge‑Optimized APIs – With Deno KV and edge functions, you can expose low‑latency endpoints for feature flags, A/B testing, or user preferences without a separate backend service.

Advanced Patterns

Streaming Data with Server‑Sent Events

Fresh supports the standard Fetch API, which means you can implement Server‑Sent Events (SSE) directly in a route handler. Here’s a minimal example that streams a timestamp every second:

export const handler = async (_req: Request, _ctx: HandlerContext) => {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        const data = `data: ${new Date().toISOString()}\n\n`;
        controller.enqueue(encoder.encode(data));
        await new Promise((r) => setTimeout(r, 1000));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
  });
};

On the client side you’d use new EventSource("/sse") to receive updates. This pattern is handy for real‑time dashboards or live notifications.

Custom Rendering Strategies

Fresh lets you switch rendering modes per route. By default, routes are rendered as static HTML, but you can opt into “partial hydration” or “full client‑side rendering” when needed. Add a export const config = { render: "client" }; object to a route file to force full client rendering.

Use this sparingly: full client rendering defeats the purpose of islands, but it’s useful for single‑page‑app sections that rely heavily on client‑side state management.

Testing Fresh Applications

Testing server‑side handlers is straightforward with Deno’s built‑in test runner. Create routes/__tests__/index_test.ts:

import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
import { handler } from "../index.tsx";
import { createHandlerContext } from "$fresh/server.ts";

Deno.test("Home page returns 200 and posts list", async () => {
  const req = new Request("http://localhost/");
  const ctx = await createHandlerContext(req);
  const resp = await handler(req, ctx);
  assertEquals(resp.status, 200);
  const text = await resp.text();
  assertEquals(text.includes("Fresh 2.0 Blog"), true);
});

Run the suite with deno test -A. For island components, you can use Pretend or any Preact testing library, but most logic lives on the server, keeping the test surface small.

Performance Benchmarks

Fresh 2.0 consistently scores under 50 ms Time‑to‑First‑Byte (TTFB) on Deno Deploy’s edge nodes for static pages, and under 120 ms for pages with a single island. In contrast, a comparable Next.js setup typically hits 150‑200 ms TTFB when cold‑started. The lack of a bundler also reduces build time from minutes to seconds during development.

Key performance knobs:

  • Cache static assets – Use the Cache-Control header in fresh.config.ts to let edge nodes serve files from the CDN.
  • Lazy‑load islands – Import islands dynamically with import() to defer loading until they enter the viewport.
  • Minimize KV reads – Batch KV calls or cache results in memory when possible.

Pro Tips & Gotchas

1. Permission Hygiene – When moving to production
Share this article