Pavan Rangani

HomeBlogDeno 2 Fresh Framework: Building Full-Stack Web Applications

Deno 2 Fresh Framework: Building Full-Stack Web Applications

By Pavan Rangani · March 26, 2026 · Web Development

Deno 2 Fresh Framework: Building Full-Stack Web Applications

Deno 2 Fresh Framework Full-Stack Guide

The Deno Fresh framework takes a radically different approach to web development. Instead of shipping megabytes of JavaScript to the browser, Fresh renders everything on the server and only hydrates interactive “islands” of UI. Combined with Deno 2’s native TypeScript support, npm compatibility, and zero-config deployment, Fresh offers one of the most productive full-stack development experiences available in 2026.

Specifically, this guide covers building a real application with Fresh from scratch — routing, layouts, islands, forms, database integration, and deployment. Along the way, you will see how Fresh eliminates the build step entirely while delivering strong performance out of the box, and, importantly, where its model genuinely does not fit.

Why Fresh in 2026

The JavaScript ecosystem has been steadily moving toward server-first rendering. Next.js has Server Components, Remix has loaders, and Astro has content-first rendering. Fresh takes this further by sending zero JavaScript by default. Every page is server-rendered HTML, and interactive components must be explicitly opted in as “islands,” which means you consciously choose what ships to the client rather than shipping the whole app and trimming later.

The result is dramatically smaller page weights. According to Fresh’s documentation, a typical static page ships no JavaScript at all, compared with the 200+ KB that a comparable React SPA commonly downloads before it can render anything. Moreover, Fresh uses Preact (roughly 3 KB) instead of React (roughly 40 KB) for its islands, so even interactive pages stay lightweight.

Deno Fresh framework web development
Fresh island architecture delivering zero-JS pages with selective hydration

Setting Up a Fresh Project

With Deno 2 installed, creating a new Fresh project takes a single command. Notably, there is no node_modules, no package.json, and no build configuration to maintain:

# Install Deno 2 (if not already installed)
curl -fsSL https://deno.land/install.sh | sh

# Create a new Fresh project
deno run -A -r https://fresh.deno.dev my-app
cd my-app

# Start the dev server (no build step!)
deno task start

Furthermore, the project structure follows file-system routing similar to Next.js:

my-app/
├── routes/
│   ├── index.tsx          # / route
│   ├── about.tsx          # /about route
│   ├── api/
│   │   └── posts.ts       # /api/posts API route
│   ├── blog/
│   │   ├── index.tsx      # /blog route
│   │   └── [slug].tsx     # /blog/:slug dynamic route
│   └── _layout.tsx        # Shared layout
├── islands/
│   ├── Counter.tsx        # Interactive island component
│   └── SearchBar.tsx      # Client-side search
├── components/
│   ├── Header.tsx         # Server-only component
│   └── Footer.tsx         # Server-only component
├── static/
│   └── styles.css
├── fresh.config.ts
└── deno.json

Routes and Server-Side Rendering

Every file in the routes/ directory becomes a route. The Fresh framework uses handler functions for data loading and component functions for rendering — conceptually similar to Remix loaders. Because the handler runs only on the server, you can talk to a database or read a secret directly, with no risk of leaking it to the client:

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

interface BlogPost {
  title: string;
  content: string;
  publishedAt: Date;
  author: string;
}

export const handler: Handlers = {
  async GET(_req, ctx) {
    const post = await db.query(
      "SELECT * FROM posts WHERE slug = $1",
      [ctx.params.slug]
    );

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

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

export default function BlogPostPage({ data }: PageProps) {
  return (
    

{data.title}

By {data.author} on {new Date(data.publishedAt).toLocaleDateString()}
); }

Middleware and Shared Layouts

Moreover, beyond individual routes, Fresh supports middleware via _middleware.ts files that wrap every route in a directory. This is the natural home for authentication, request logging, or attaching a request-scoped database connection to ctx.state. Because middleware composes by folder depth, you can scope an auth check to routes/admin/ without touching the public pages beside it:

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

export async function handler(req: Request, ctx: FreshContext) {
  const session = getSession(req);
  if (!session) {
    return new Response("", {
      status: 302,
      headers: { Location: "/login" },
    });
  }
  ctx.state.user = session.user;     // available to nested handlers
  return await ctx.next();
}

Similarly, a _layout.tsx wraps child routes in shared chrome — a header, footer, or container — while still rendering entirely on the server.

Islands: Selective Client-Side Interactivity

Islands are the core innovation of Fresh. Any component placed in the islands/ directory gets hydrated on the client; everything else stays as static HTML. This is how you add interactivity without shipping a full framework to the browser. Importantly, islands receive only JSON-serializable props, because those props are serialized into the page and rehydrated — so you cannot pass a function or a class instance across the boundary:

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

interface SearchResult {
  title: string;
  slug: string;
  excerpt: string;
}

export default function SearchBar() {
  const query = useSignal("");
  const results = useSignal([]);
  const isLoading = useSignal(false);

  const handleSearch = async (value: string) => {
    query.value = value;
    if (value.length < 2) {
      results.value = [];
      return;
    }

    isLoading.value = true;
    const res = await fetch(
      `/api/search?q=${encodeURIComponent(value)}`
    );
    results.value = await res.json();
    isLoading.value = false;
  };

  return (
    
handleSearch(e.currentTarget.value)} class="w-full px-4 py-2 border rounded-lg" /> {results.value.length > 0 && ( )}
); }

Then, use the island in any route by importing it like a regular component:

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

export default function Home() {
  return (
    
{/* Server-rendered, zero JS */} {/* Hydrated island, ships JS */} {/* Server-rendered, zero JS */}
); }

One subtlety worth internalizing: an island hydrates independently, so it cannot share Preact context with the static page around it. If two islands need to coordinate — say a cart button and a cart drawer — they must communicate through a shared signal module or the URL, not through a common parent component. Keeping islands small and self-contained avoids this friction entirely.

Full-stack web development with Deno
Building interactive islands within server-rendered Fresh pages

Forms and Mutations

Fresh handles form submissions server-side, much like traditional web frameworks. As a result, the happy path needs no client-side JavaScript at all, and the form still works if scripts fail to load — genuine progressive enhancement:

// routes/contact.tsx
import { Handlers, PageProps } from "$fresh/server.ts";

interface FormState {
  success?: boolean;
  error?: string;
}

export const handler: Handlers = {
  GET(_req, ctx) {
    return ctx.render({});
  },

  async POST(req, ctx) {
    const form = await req.formData();
    const name = form.get("name")?.toString();
    const email = form.get("email")?.toString();
    const message = form.get("message")?.toString();

    if (!name || !email || !message) {
      return ctx.render({ error: "All fields are required" });
    }

    await db.query(
      "INSERT INTO contacts (name, email, message) VALUES ($1, $2, $3)",
      [name, email, message]
    );

    return ctx.render({ success: true });
  },
};

export default function Contact({ data }: PageProps) {
  return (
    
{data.success &&

Message sent!

} {data.error &&

{data.error}

}