Skip to main content
← All Articles Β· Β· 13 min read

Fresh 2.0 + Deno: Build Fast Web Apps with Zero Config 2026

Complete guide to Fresh 2.0 and Deno in 2026: islands architecture, Preact components, file-based routing, Deno Deploy, middleware, and how Fresh compares to Next.js and Astro.

freshdenopreactjavascripttypescriptislands-architectureweb-frameworkdeno-deploy

Fresh is the web framework built for Deno β€” and it’s genuinely different from everything else in the JavaScript ecosystem. No build step, no client-side JavaScript by default, an islands architecture for selective interactivity, and first-class TypeScript everywhere.

Fresh 2.0 brought major improvements to the developer experience while keeping the zero-config philosophy intact. This guide walks you through everything from setup to deployment.


What is Fresh?

Fresh is a full-stack web framework that runs on Deno. It uses:

  • Preact for component rendering
  • Islands architecture for minimal client-side JavaScript
  • File-based routing like Next.js
  • No build step β€” TypeScript is transpiled just-in-time by Deno
  • Zero JavaScript by default β€” pages are pure HTML unless you add islands

The result is extremely fast page loads with full TypeScript support and no configuration.


Why Fresh + Deno?

Before diving in, understand what you’re getting with Deno:

Security by default:

# Deno requires explicit permissions
deno run --allow-net --allow-read server.ts

# Fresh does this for you via deno.json

TypeScript everywhere β€” no config:

// This just works in Deno. No tsconfig.json, no ts-loader.
import type { Handler } from "$fresh/server.ts";

URL imports (and npm compatibility):

// Import from npm (prefixed)
import { assertEquals } from "npm:chai@5";

// Import from JSR (new standard registry)
import { fresh } from "jsr:@fresh/core@2";

// Or use standard npm packages via npm:
import React from "npm:react@18";

Built-in tooling:

deno fmt       # Format code
deno lint      # Lint code
deno test      # Run tests
deno check     # Type check

Getting Started with Fresh 2.0

Prerequisites

# Install Deno
curl -fsSL https://deno.land/install.sh | sh

# Verify
deno --version

Create a Fresh Project

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

You’ll be prompted to select TypeScript or JavaScript, and whether to use Tailwind CSS.

Project Structure

my-app/
β”œβ”€β”€ components/          # Shared UI components (server-only by default)
β”‚   └── Button.tsx
β”œβ”€β”€ islands/             # Interactive components (run on client)
β”‚   └── Counter.tsx
β”œβ”€β”€ routes/              # File-based routing
β”‚   β”œβ”€β”€ _app.tsx         # Root layout
β”‚   β”œβ”€β”€ _layout.tsx      # Section layout
β”‚   β”œβ”€β”€ index.tsx        # Home page (/)
β”‚   β”œβ”€β”€ about.tsx        # /about
β”‚   β”œβ”€β”€ blog/
β”‚   β”‚   β”œβ”€β”€ index.tsx    # /blog
β”‚   β”‚   └── [slug].tsx   # /blog/:slug
β”‚   └── api/
β”‚       └── users.ts     # /api/users
β”œβ”€β”€ static/              # Static assets
β”œβ”€β”€ deno.json            # Deno config + tasks
└── main.ts              # Entry point

Development Server

deno task start
# Equivalent to: deno run -A --watch main.ts

Fresh starts instantly β€” there’s no compilation step. Changes reflect immediately.


Routing in Fresh

Basic Routes

File-based routing maps directly to URLs:

routes/index.tsx          β†’ /
routes/about.tsx          β†’ /about
routes/blog/index.tsx     β†’ /blog
routes/blog/[slug].tsx    β†’ /blog/:slug
routes/users/[id].tsx     β†’ /users/:id
routes/users/[...path].tsx β†’ /users/* (catch-all)

Page Component

// routes/index.tsx
import type { PageProps } from "$fresh/server.ts";

export default function HomePage({ data }: PageProps) {
  return (
    <main>
      <h1>Welcome to Fresh</h1>
      <p>The no-build JavaScript framework.</p>
    </main>
  );
}

Handler Functions

Routes can export a handler for server-side logic:

// routes/blog/[slug].tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import { db } from "../lib/db.ts";

interface Post {
  id: string;
  title: string;
  content: string;
  publishedAt: Date;
}

export const handler: Handlers<Post | null> = {
  async GET(req, ctx) {
    const post = await db.posts.findBySlug(ctx.params.slug);

    if (!post) {
      return ctx.renderNotFound();
    }

    return ctx.render(post);
  },
};

export default function BlogPost({ data: post }: PageProps<Post>) {
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{new Date(post.publishedAt).toLocaleDateString()}</time>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

API Routes

// routes/api/users.ts
import { Handlers } from "$fresh/server.ts";
import { db } from "../../lib/db.ts";

export const handler: Handlers = {
  async GET(req) {
    const users = await db.users.findMany();
    return Response.json(users);
  },

  async POST(req) {
    const body = await req.json();

    if (!body.name || !body.email) {
      return Response.json(
        { error: "name and email are required" },
        { status: 400 }
      );
    }

    const user = await db.users.create(body);
    return Response.json(user, { status: 201 });
  },
};

The Islands Architecture

This is Fresh’s most distinctive feature.

The problem it solves: Most web apps deliver a large JavaScript bundle to the browser, even for mostly-static pages. This slows initial load.

Islands solution: Pages are rendered as static HTML on the server. Only β€œislands” of interactivity β€” explicit opt-in components β€” ship JavaScript to the browser.

routes/blog/[slug].tsx
β”œβ”€β”€ BlogPost component     β†’ Pure HTML, zero JS
β”œβ”€β”€ BlogHeader component   β†’ Pure HTML, zero JS
β”œβ”€β”€ SocialShareBar         β†’ Pure HTML, zero JS
└── CommentSection (island) β†’ Preact component, interactive

Only CommentSection ships JavaScript. The rest is HTML.

Creating an Island

Islands live in the islands/ directory:

// islands/Counter.tsx
import { useSignal } from "@preact/signals";

export default function Counter() {
  const count = useSignal(0);

  return (
    <div class="counter">
      <button onClick={() => count.value--}>-</button>
      <span>{count}</span>
      <button onClick={() => count.value++}>+</button>
    </div>
  );
}
// routes/index.tsx
import Counter from "../islands/Counter.tsx";  // Import from islands/

export default function HomePage() {
  return (
    <main>
      <h1>Welcome</h1>
      {/* Counter is an island β€” it loads JS only for this component */}
      <Counter />
    </main>
  );
}

Passing Data to Islands

// islands/SearchBar.tsx
import { useSignal } from "@preact/signals";

interface SearchBarProps {
  placeholder?: string;
  initialQuery?: string;
}

export default function SearchBar({ placeholder = "Search...", initialQuery = "" }: SearchBarProps) {
  const query = useSignal(initialQuery);
  const results = useSignal<string[]>([]);

  async function search() {
    const res = await fetch(`/api/search?q=${query.value}`);
    results.value = await res.json();
  }

  return (
    <div>
      <input
        value={query.value}
        onInput={(e) => (query.value = (e.target as HTMLInputElement).value)}
        placeholder={placeholder}
      />
      <button onClick={search}>Search</button>
      <ul>
        {results.value.map((r) => <li key={r}>{r}</li>)}
      </ul>
    </div>
  );
}

// routes/index.tsx
import SearchBar from "../islands/SearchBar.tsx";

export default function Page() {
  return (
    <main>
      {/* Pass serializable props to the island */}
      <SearchBar placeholder="Search articles..." initialQuery="" />
    </main>
  );
}

Important: Island props must be serializable (JSON-compatible). You can’t pass functions, class instances, or React-style event handlers from the server to islands.


Preact Signals

Fresh uses Preact and Preact Signals for state management in islands:

import { signal, computed, effect } from "@preact/signals";

// Global signals (shared state across islands)
const cartItems = signal<CartItem[]>([]);
const cartCount = computed(() => cartItems.value.length);
const cartTotal = computed(() =>
  cartItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

// React to changes
effect(() => {
  console.log(`Cart has ${cartCount.value} items`);
});

// In a component
export default function CartButton() {
  return (
    <button>
      Cart ({cartCount})  {/* Auto-updates when cartCount changes */}
    </button>
  );
}

Signals update only the specific DOM nodes that read them β€” the same fine-grained reactivity as SolidJS, within the islands that need it.


Layouts and Middleware

Root App Layout

// routes/_app.tsx
import { AppProps } from "$fresh/server.ts";

export default function App({ Component }: AppProps) {
  return (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>My Fresh App</title>
        <link rel="stylesheet" href="/styles.css" />
      </head>
      <body>
        <Component />
      </body>
    </html>
  );
}

Section Layout

// routes/blog/_layout.tsx
import { LayoutProps } from "$fresh/server.ts";

export default function BlogLayout({ Component, state }: LayoutProps) {
  return (
    <div class="blog-layout">
      <nav>
        <a href="/blog">All Posts</a>
        <a href="/blog/tags">Tags</a>
      </nav>
      <main>
        <Component />
      </main>
      <aside>
        <h3>About</h3>
        <p>A Fresh blog.</p>
      </aside>
    </div>
  );
}

Middleware

// routes/_middleware.ts
import { FreshContext } from "$fresh/server.ts";

// Authentication middleware
export async function handler(req: Request, ctx: FreshContext) {
  const url = new URL(req.url);

  // Public routes don't need auth
  if (url.pathname.startsWith("/api/public") || url.pathname === "/login") {
    return ctx.next();
  }

  // Check auth token
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
  if (!token) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Verify and attach user to context state
  const user = await verifyToken(token);
  if (!user) {
    return Response.json({ error: "Invalid token" }, { status: 401 });
  }

  ctx.state.user = user;
  return ctx.next();
}

Styling in Fresh

Fresh has first-class Tailwind support:

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

export default defineConfig({
  plugins: [tailwind()],
});
// components/Button.tsx
interface ButtonProps {
  variant?: "primary" | "secondary";
  children: preact.ComponentChildren;
  onClick?: () => void;
}

export function Button({ variant = "primary", children, onClick }: ButtonProps) {
  const classes = variant === "primary"
    ? "bg-blue-600 hover:bg-blue-700 text-white"
    : "bg-gray-200 hover:bg-gray-300 text-gray-900";

  return (
    <button
      onClick={onClick}
      class={`px-4 py-2 rounded font-medium transition-colors ${classes}`}
    >
      {children}
    </button>
  );
}

CSS Modules

// components/Card.module.css
.card {
  border-radius: 8px;
  padding: 1.5rem;
  background: white;
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}

// components/Card.tsx
import styles from "./Card.module.css";

export function Card({ children }) {
  return <div class={styles.card}>{children}</div>;
}

Deno Deploy

Fresh apps deploy to Deno Deploy β€” a globally distributed edge runtime.

Deploy via GitHub

  1. Push your Fresh project to GitHub
  2. Connect your repo at dash.deno.com
  3. Set the entry point to main.ts
  4. Deno Deploy automatically deploys on every push

Environment Variables

# Set in Deno Deploy dashboard, or via CLI
deployctl env set DATABASE_URL=postgresql://...
// Access in your app
const dbUrl = Deno.env.get("DATABASE_URL")!;

Custom Domains

Deno Deploy provides a .deno.dev subdomain by default. Custom domains are configured in the dashboard with automatic TLS.


Fresh vs Next.js vs Astro

FeatureFresh 2.0Next.js 15Astro 5
Default JS shippedZeroFull bundleZero
Interactivity modelIslandsRSC + Client CompsIslands
Build stepNoneRequiredRequired
RuntimeDenoNode.jsNode.js
UI frameworkPreactReactAny (React, Vue, etc.)
TypeScript supportNativeVia configNative
Deployment targetDeno Deploy / EdgeVercel / anywhereAny
EcosystemSmallLargestGrowing
Learning curveMediumMediumLow
SSR/SSGSSR onlyBothBoth
Full-stack API routesYesYesYes (limited)

Choose Fresh when:

  • You want zero JavaScript by default with selective islands
  • You’re using Deno or want Deno’s security model
  • You want no build step in development
  • Edge deployment on Deno Deploy

Choose Next.js when:

  • You need the React ecosystem (libraries, team knowledge)
  • You need ISR, static generation, or advanced Next.js features
  • Vercel deployment is your target

Choose Astro when:

  • Content-heavy sites (blogs, docs, marketing pages)
  • You want to use multiple UI frameworks in one project
  • Maximum flexibility in deployment targets

Performance Advantages

Time to First Byte (TTFB)

Fresh renders on every request with no hydration needed for non-island pages:

ScenarioNext.js (SSR)Fresh
Static HTML page~60ms~25ms
Page with 1 island~80ms~30ms
JavaScript payload80–200KB5–30KB
Time to Interactive2–4s0.5–1s

Approximate values, Deno Deploy edge node. Network-dependent.

Fresh’s advantage comes from:

  1. No JavaScript runtime to download for static pages
  2. Edge execution (Deno Deploy runs close to users)
  3. No hydration step

Testing Fresh Apps

// tests/routes_test.ts
import { createHandler, ServeHandlerInfo } from "$fresh/server.ts";
import manifest from "../fresh.gen.ts";
import config from "../fresh.config.ts";

const CONN_INFO: ServeHandlerInfo = {
  remoteAddr: { hostname: "127.0.0.1", port: 53496, transport: "tcp" },
};

Deno.test("GET / returns 200", async () => {
  const handler = await createHandler(manifest, config);
  const response = await handler(
    new Request("http://localhost/"),
    CONN_INFO
  );

  assertEquals(response.status, 200);
});

Deno.test("GET /api/users returns JSON array", async () => {
  const handler = await createHandler(manifest, config);
  const response = await handler(
    new Request("http://localhost/api/users"),
    CONN_INFO
  );

  assertEquals(response.status, 200);
  assertEquals(response.headers.get("content-type"), "application/json");

  const data = await response.json();
  assertInstanceOf(data, Array);
});
deno test --allow-net --allow-read --allow-env

Fresh 2.0 Improvements

Fresh 2.0 (released 2024, stable in 2026) brought:

Better island composition: Islands can now accept non-serializable children via slots β€” enabling richer component patterns.

Improved routing: Parallel routes, intercepting routes, and improved layout nesting.

Plugin system: Tailwind, KV storage, and authentication plugins are now first-class.

Improved build output: Optional pre-rendering for faster cold starts on Deno Deploy.

TypeScript improvements: Better type inference for handlers and page props.


Developer Tools

  • JSON Formatter β€” Format API responses from Fresh handlers
  • URL Parser β€” Debug Fresh route matching and URL patterns
  • HTTP Headers β€” Inspect response headers from Fresh middleware
  • Base64 Encoder β€” Work with binary data in Deno’s native APIs

Summary

Fresh 2.0 + Deno is a compelling stack for teams who want:

  • Zero configuration β€” TypeScript, formatting, linting, and testing built into Deno
  • Minimal JavaScript β€” islands architecture delivers only what’s needed
  • Fast deployments β€” edge-first with Deno Deploy, no CDN configuration needed
  • Clean security model β€” Deno’s permission system prevents accidental data leaks

The main tradeoff is ecosystem: Deno and Fresh are smaller than the Node.js + React world. Many npm packages work via the npm: prefix, but some native Node.js modules and frameworks don’t have Deno-compatible equivalents yet.

For teams building content sites, APIs, or performance-critical apps with a lean stack, Fresh + Deno offers a genuinely better developer experience than most alternatives. For teams that need the full React/Node.js ecosystem, the migration cost isn’t worth it.

Start with deno run -A -r https://fresh.deno.dev my-app β€” you’ll be running in minutes.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.