Build a Full-Stack Web App with Deno 2 and Fresh Framework

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

What You'll Build

In this tutorial, you'll build a full-stack task manager application using Deno 2 and the Fresh framework. By the end, you'll have a working app with:

  • Server-side rendered pages with zero JavaScript by default
  • Interactive islands for dynamic UI components
  • RESTful API routes for CRUD operations
  • Deno KV for persistent data storage
  • TypeScript throughout — no configuration needed

Time required: 45-60 minutes


Prerequisites

Before starting, make sure you have:

  1. Deno 2 installed — Run deno --version to verify (should be 2.x+)
  2. Basic TypeScript/JavaScript knowledge
  3. Familiarity with HTML and CSS
  4. A code editor (VS Code with the Deno extension recommended)

If you don't have Deno installed:

# macOS / Linux
curl -fsSL https://deno.land/install.sh | sh
 
# Windows (PowerShell)
irm https://deno.land/install.ps1 | iex

Why Deno 2 + Fresh?

What is Deno 2?

Deno 2 is the next-generation JavaScript and TypeScript runtime created by Ryan Dahl — the original creator of Node.js. It fixes many of Node's design regrets while adding:

  • First-class TypeScript support — no tsconfig.json or build step needed
  • Secure by default — explicit permissions for file, network, and environment access
  • npm compatibility — use any npm package with npm: specifiers
  • Built-in tooling — formatter, linter, test runner, and benchmarker
  • Deno KV — a built-in key-value database

What is Fresh?

Fresh is the most popular full-stack web framework for Deno. It stands out with:

  • Islands architecture — only ships JavaScript for interactive components
  • No build step — code runs directly, enabling instant deployments
  • Server-side rendering — pages are pre-rendered on the server for fast loads
  • File-based routing — routes map directly to file paths
  • Preact under the hood — lightweight, React-compatible UI library

Step 1: Create a New Fresh Project

Open your terminal and scaffold a new Fresh project:

deno run -A -r https://fresh.deno.dev my-task-manager

When prompted:

  • Would you like to use a styling library? → Select Tailwind CSS
  • Would you like to use VS Code? → Select Yes (if using VS Code)

Navigate into the project:

cd my-task-manager

Start the development server to verify everything works:

deno task dev

Open http://localhost:8000 in your browser. You should see the Fresh welcome page.

Project Structure

Here's what Fresh generated:

my-task-manager/
├── components/       # Shared UI components
├── islands/          # Interactive client-side components
├── routes/           # File-based routes and API endpoints
│   ├── _app.tsx      # App wrapper (layout)
│   ├── index.tsx     # Home page
│   └── api/          # API routes
├── static/           # Static assets (CSS, images)
├── deno.json         # Deno configuration
├── dev.ts            # Development entry point
├── main.ts           # Production entry point
└── fresh.gen.ts      # Auto-generated manifest

Key concept: Files in routes/ are server-rendered by default. Files in islands/ are hydrated on the client with JavaScript. This is the islands architecture — only interactive parts ship JS to the browser.


Step 2: Define the Task Data Model

Create a new file for your task types and data operations.

Create utils/db.ts:

// utils/db.ts
 
export interface Task {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  createdAt: string;
  updatedAt: string;
}
 
// Open a Deno KV database instance
const kv = await Deno.openKv();
 
export async function getAllTasks(): Promise<Task[]> {
  const tasks: Task[] = [];
  const entries = kv.list<Task>({ prefix: ["tasks"] });
 
  for await (const entry of entries) {
    tasks.push(entry.value);
  }
 
  // Sort by creation date, newest first
  return tasks.sort(
    (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
  );
}
 
export async function getTask(id: string): Promise<Task | null> {
  const entry = await kv.get<Task>(["tasks", id]);
  return entry.value;
}
 
export async function createTask(
  title: string,
  description: string
): Promise<Task> {
  const id = crypto.randomUUID();
  const now = new Date().toISOString();
 
  const task: Task = {
    id,
    title,
    description,
    completed: false,
    createdAt: now,
    updatedAt: now,
  };
 
  await kv.set(["tasks", id], task);
  return task;
}
 
export async function updateTask(
  id: string,
  updates: Partial<Pick<Task, "title" | "description" | "completed">>
): Promise<Task | null> {
  const existing = await getTask(id);
  if (!existing) return null;
 
  const updated: Task = {
    ...existing,
    ...updates,
    updatedAt: new Date().toISOString(),
  };
 
  await kv.set(["tasks", id], updated);
  return updated;
}
 
export async function deleteTask(id: string): Promise<boolean> {
  const existing = await getTask(id);
  if (!existing) return false;
 
  await kv.delete(["tasks", id]);
  return true;
}

Why Deno KV? It's a built-in key-value store that requires zero setup. Data persists across server restarts locally, and when deployed to Deno Deploy, it becomes a globally distributed database.


Step 3: Build the API Routes

Fresh uses file-based routing for API endpoints too. Create RESTful endpoints for task management.

List & Create Tasks

Create routes/api/tasks.ts:

// routes/api/tasks.ts
import { Handlers } from "$fresh/server.ts";
import { createTask, getAllTasks } from "../../utils/db.ts";
 
export const handler: Handlers = {
  // GET /api/tasks — list all tasks
  async GET(_req, _ctx) {
    const tasks = await getAllTasks();
    return new Response(JSON.stringify(tasks), {
      headers: { "Content-Type": "application/json" },
    });
  },
 
  // POST /api/tasks — create a new task
  async POST(req, _ctx) {
    const body = await req.json();
    const { title, description } = body;
 
    if (!title || typeof title !== "string") {
      return new Response(
        JSON.stringify({ error: "Title is required" }),
        { status: 400, headers: { "Content-Type": "application/json" } }
      );
    }
 
    const task = await createTask(title, description || "");
    return new Response(JSON.stringify(task), {
      status: 201,
      headers: { "Content-Type": "application/json" },
    });
  },
};

Update & Delete a Single Task

Create routes/api/tasks/[id].ts:

// routes/api/tasks/[id].ts
import { Handlers } from "$fresh/server.ts";
import { deleteTask, getTask, updateTask } from "../../../utils/db.ts";
 
export const handler: Handlers = {
  // GET /api/tasks/:id
  async GET(_req, ctx) {
    const task = await getTask(ctx.params.id);
    if (!task) {
      return new Response(
        JSON.stringify({ error: "Task not found" }),
        { status: 404, headers: { "Content-Type": "application/json" } }
      );
    }
    return new Response(JSON.stringify(task), {
      headers: { "Content-Type": "application/json" },
    });
  },
 
  // PATCH /api/tasks/:id
  async PATCH(req, ctx) {
    const body = await req.json();
    const task = await updateTask(ctx.params.id, body);
 
    if (!task) {
      return new Response(
        JSON.stringify({ error: "Task not found" }),
        { status: 404, headers: { "Content-Type": "application/json" } }
      );
    }
 
    return new Response(JSON.stringify(task), {
      headers: { "Content-Type": "application/json" },
    });
  },
 
  // DELETE /api/tasks/:id
  async DELETE(_req, ctx) {
    const success = await deleteTask(ctx.params.id);
 
    if (!success) {
      return new Response(
        JSON.stringify({ error: "Task not found" }),
        { status: 404, headers: { "Content-Type": "application/json" } }
      );
    }
 
    return new Response(JSON.stringify({ ok: true }), {
      headers: { "Content-Type": "application/json" },
    });
  },
};

Step 4: Create the Task List Island

Islands are Fresh's way of adding interactivity. Only island components ship JavaScript to the browser — everything else is static HTML.

Create islands/TaskList.tsx:

// islands/TaskList.tsx
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
 
interface Task {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  createdAt: string;
}
 
export default function TaskList() {
  const tasks = useSignal<Task[]>([]);
  const newTitle = useSignal("");
  const newDescription = useSignal("");
  const loading = useSignal(true);
  const error = useSignal("");
 
  // Fetch tasks on mount
  useEffect(() => {
    fetchTasks();
  }, []);
 
  async function fetchTasks() {
    loading.value = true;
    try {
      const res = await fetch("/api/tasks");
      tasks.value = await res.json();
    } catch (e) {
      error.value = "Failed to load tasks";
    } finally {
      loading.value = false;
    }
  }
 
  async function addTask(e: Event) {
    e.preventDefault();
    if (!newTitle.value.trim()) return;
 
    try {
      const res = await fetch("/api/tasks", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          title: newTitle.value,
          description: newDescription.value,
        }),
      });
 
      if (res.ok) {
        newTitle.value = "";
        newDescription.value = "";
        await fetchTasks();
      }
    } catch (e) {
      error.value = "Failed to add task";
    }
  }
 
  async function toggleTask(id: string, completed: boolean) {
    try {
      await fetch(`/api/tasks/${id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ completed: !completed }),
      });
      await fetchTasks();
    } catch (e) {
      error.value = "Failed to update task";
    }
  }
 
  async function removeTask(id: string) {
    try {
      await fetch(`/api/tasks/${id}`, { method: "DELETE" });
      await fetchTasks();
    } catch (e) {
      error.value = "Failed to delete task";
    }
  }
 
  return (
    <div class="max-w-2xl mx-auto p-4">
      {/* Add Task Form */}
      <form onSubmit={addTask} class="mb-8 bg-white rounded-lg shadow p-6">
        <h2 class="text-xl font-bold mb-4 text-gray-800">Add New Task</h2>
 
        <input
          type="text"
          placeholder="Task title..."
          value={newTitle.value}
          onInput={(e) => newTitle.value = (e.target as HTMLInputElement).value}
          class="w-full p-3 border border-gray-300 rounded-lg mb-3 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          required
        />
 
        <textarea
          placeholder="Description (optional)..."
          value={newDescription.value}
          onInput={(e) => newDescription.value = (e.target as HTMLTextAreaElement).value}
          class="w-full p-3 border border-gray-300 rounded-lg mb-3 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          rows={2}
        />
 
        <button
          type="submit"
          class="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
        >
          Add Task
        </button>
      </form>
 
      {/* Error Message */}
      {error.value && (
        <div class="bg-red-100 text-red-700 p-3 rounded-lg mb-4">
          {error.value}
        </div>
      )}
 
      {/* Loading State */}
      {loading.value && (
        <p class="text-center text-gray-500">Loading tasks...</p>
      )}
 
      {/* Task List */}
      {!loading.value && tasks.value.length === 0 && (
        <p class="text-center text-gray-500 py-8">
          No tasks yet. Add your first task above!
        </p>
      )}
 
      <div class="space-y-3">
        {tasks.value.map((task) => (
          <div
            key={task.id}
            class={`bg-white rounded-lg shadow p-4 flex items-start gap-3 transition-opacity ${
              task.completed ? "opacity-60" : ""
            }`}
          >
            <button
              onClick={() => toggleTask(task.id, task.completed)}
              class={`mt-1 w-5 h-5 rounded border-2 flex-shrink-0 flex items-center justify-center ${
                task.completed
                  ? "bg-green-500 border-green-500 text-white"
                  : "border-gray-300 hover:border-blue-500"
              }`}
            >
              {task.completed && "✓"}
            </button>
 
            <div class="flex-1 min-w-0">
              <h3
                class={`font-semibold text-gray-800 ${
                  task.completed ? "line-through" : ""
                }`}
              >
                {task.title}
              </h3>
              {task.description && (
                <p class="text-gray-500 text-sm mt-1">{task.description}</p>
              )}
              <p class="text-gray-400 text-xs mt-1">
                {new Date(task.createdAt).toLocaleDateString()}
              </p>
            </div>
 
            <button
              onClick={() => removeTask(task.id)}
              class="text-red-400 hover:text-red-600 flex-shrink-0 text-lg"
              title="Delete task"
            >
              ×
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

Key points:

  • useSignal from Preact Signals provides reactive state — more performant than useState
  • This component lives in islands/ so it ships JavaScript to the client
  • Everything else on the page remains as static HTML

Step 5: Create the Main Page

Now wire the island into a server-rendered page.

Replace routes/index.tsx:

// routes/index.tsx
import { Head } from "$fresh/runtime.ts";
import TaskList from "../islands/TaskList.tsx";
 
export default function Home() {
  return (
    <>
      <Head>
        <title>Task Manager — Built with Deno 2 & Fresh</title>
        <meta
          name="description"
          content="A full-stack task manager built with Deno 2 and Fresh framework"
        />
      </Head>
 
      <div class="min-h-screen bg-gray-100">
        {/* Header */}
        <header class="bg-white shadow-sm">
          <div class="max-w-2xl mx-auto px-4 py-6">
            <h1 class="text-3xl font-bold text-gray-900">
              📋 Task Manager
            </h1>
            <p class="text-gray-500 mt-1">
              Built with Deno 2 & Fresh — Server-rendered with interactive islands
            </p>
          </div>
        </header>
 
        {/* Main Content */}
        <main class="py-8">
          <TaskList />
        </main>
 
        {/* Footer */}
        <footer class="text-center py-6 text-gray-400 text-sm">
          <p>
            Powered by{" "}
            <a href="https://deno.com" class="text-blue-500 hover:underline">
              Deno 2
            </a>{" "}
            &{" "}
            <a href="https://fresh.deno.dev" class="text-blue-500 hover:underline">
              Fresh
            </a>
          </p>
        </footer>
      </div>
    </>
  );
}

Notice: The <TaskList /> island is the only interactive part. The header, footer, and layout are pure server-rendered HTML — no JavaScript sent for those.


Step 6: Add Task Statistics with Server Data

One of Fresh's strengths is mixing server-side data fetching with islands. Let's add a stats bar that loads on the server.

Update routes/index.tsx:

// routes/index.tsx
import { Head } from "$fresh/runtime.ts";
import { Handlers, PageProps } from "$fresh/server.ts";
import { getAllTasks, Task } from "../utils/db.ts";
import TaskList from "../islands/TaskList.tsx";
 
interface PageData {
  totalTasks: number;
  completedTasks: number;
}
 
export const handler: Handlers<PageData> = {
  async GET(_req, ctx) {
    const tasks = await getAllTasks();
    return ctx.render({
      totalTasks: tasks.length,
      completedTasks: tasks.filter((t) => t.completed).length,
    });
  },
};
 
export default function Home({ data }: PageProps<PageData>) {
  const { totalTasks, completedTasks } = data;
  const progress = totalTasks > 0
    ? Math.round((completedTasks / totalTasks) * 100)
    : 0;
 
  return (
    <>
      <Head>
        <title>Task Manager — Built with Deno 2 & Fresh</title>
        <meta
          name="description"
          content="A full-stack task manager built with Deno 2 and Fresh framework"
        />
      </Head>
 
      <div class="min-h-screen bg-gray-100">
        {/* Header */}
        <header class="bg-white shadow-sm">
          <div class="max-w-2xl mx-auto px-4 py-6">
            <h1 class="text-3xl font-bold text-gray-900">
              📋 Task Manager
            </h1>
            <p class="text-gray-500 mt-1">
              Built with Deno 2 & Fresh — Server-rendered with interactive islands
            </p>
          </div>
        </header>
 
        {/* Stats Bar — Server rendered, no JS */}
        <div class="max-w-2xl mx-auto px-4 mt-6">
          <div class="bg-white rounded-lg shadow p-4 flex items-center justify-between">
            <div class="flex gap-6 text-sm">
              <span class="text-gray-600">
                Total: <strong class="text-gray-900">{totalTasks}</strong>
              </span>
              <span class="text-gray-600">
                Done: <strong class="text-green-600">{completedTasks}</strong>
              </span>
              <span class="text-gray-600">
                Remaining:{" "}
                <strong class="text-blue-600">
                  {totalTasks - completedTasks}
                </strong>
              </span>
            </div>
            <div class="flex items-center gap-2">
              <div class="w-24 bg-gray-200 rounded-full h-2">
                <div
                  class="bg-green-500 h-2 rounded-full transition-all"
                  style={{ width: `${progress}%` }}
                />
              </div>
              <span class="text-xs text-gray-500">{progress}%</span>
            </div>
          </div>
        </div>
 
        {/* Main Content */}
        <main class="py-6">
          <TaskList />
        </main>
 
        {/* Footer */}
        <footer class="text-center py-6 text-gray-400 text-sm">
          <p>
            Powered by{" "}
            <a href="https://deno.com" class="text-blue-500 hover:underline">
              Deno 2
            </a>{" "}
            &{" "}
            <a href="https://fresh.deno.dev" class="text-blue-500 hover:underline">
              Fresh
            </a>
          </p>
        </footer>
      </div>
    </>
  );
}

The stats bar is completely server-rendered — zero JavaScript for that section. Only TaskList gets client-side hydration.


Step 7: Add Middleware for Logging

Fresh supports middleware for cross-cutting concerns. Let's add request logging.

Create routes/_middleware.ts:

// routes/_middleware.ts
import { FreshContext } from "$fresh/server.ts";
 
export async function handler(req: Request, ctx: FreshContext) {
  const start = Date.now();
  const url = new URL(req.url);
 
  // Process the request
  const resp = await ctx.next();
 
  const duration = Date.now() - start;
  const status = resp.status;
 
  console.log(
    `${req.method} ${url.pathname} — ${status} (${duration}ms)`
  );
 
  return resp;
}

Now every request is logged with method, path, status, and duration.


Step 8: Run and Test

Start the development server:

deno task dev

Open http://localhost:8000 and test the following:

  1. Add a task — fill in the title and description, click "Add Task"
  2. Toggle completion — click the checkbox next to a task
  3. Delete a task — click the × button
  4. Refresh the page — tasks persist thanks to Deno KV

Test the API directly

# List tasks
curl http://localhost:8000/api/tasks
 
# Create a task
curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Deno 2", "description": "Complete the Fresh tutorial"}'
 
# Toggle a task (replace TASK_ID)
curl -X PATCH http://localhost:8000/api/tasks/TASK_ID \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'
 
# Delete a task
curl -X DELETE http://localhost:8000/api/tasks/TASK_ID

Step 9: Deploy to Deno Deploy

Deno Deploy is the natural hosting platform for Fresh apps. Deployment is near-instant.

Option A: Via GitHub Integration

  1. Push your project to GitHub
  2. Go to dash.deno.com
  3. Create a new project
  4. Link your GitHub repository
  5. Set the entry point to main.ts
  6. Deploy — it takes seconds

Option B: Via CLI

# Install deployctl
deno install -Arf jsr:@deno/deployctl
 
# Deploy
deployctl deploy --project=my-task-manager --entrypoint=main.ts

Deno KV on Deploy: When deployed to Deno Deploy, your KV data is automatically distributed globally — no database setup or connection strings needed.


Troubleshooting

"Permission denied" errors

Deno is secure by default. If you see permission errors, ensure you're using the --allow-* flags or running with -A during development:

deno run -A main.ts

"Module not found" for npm packages

Use the npm: specifier in your imports:

import express from "npm:express@4";

Fresh types not recognized

Make sure your deno.json has the correct Fresh import map:

{
  "imports": {
    "$fresh/": "https://deno.land/x/fresh@1.7.3/",
    "@preact/signals": "https://esm.sh/*@preact/signals@1.3.1"
  }
}

Deno KV data not persisting

By default, KV data is stored in a local file. Check that you have write permissions to the directory. For a custom path:

const kv = await Deno.openKv("./data/kv.db");

What You Learned

In this tutorial, you built a complete full-stack application and learned:

  • Fresh project structure — routes, islands, components, and utilities
  • Islands architecture — shipping JavaScript only where it's needed
  • Server-side rendering — pages rendered on the server for instant loads
  • API routes — RESTful endpoints with file-based routing
  • Deno KV — zero-config persistent data storage
  • Middleware — cross-cutting concerns like logging
  • Deployment — pushing to production with Deno Deploy

Next Steps

  • Add authentication — use Deno KV OAuth for GitHub/Google login
  • Add task categories — extend the data model with labels and filters
  • Add real-time updates — use Server-Sent Events (SSE) for live task syncing
  • Explore Fresh plugins — check fresh.deno.dev/docs/concepts/plugins for extending functionality
  • Read the Deno docs — explore docs.deno.com for deeper runtime features

Conclusion

Deno 2 and Fresh offer a refreshingly simple approach to full-stack web development. The islands architecture ensures your apps are fast by default, TypeScript works without configuration, and Deno KV eliminates the need for external database setup. If you're tired of complex build toolchains and heavyweight frameworks, Fresh is worth serious consideration for your next project.


Want to read more tutorials? Check out our latest tutorial on 3 Laravel 11 Basics: Middleware.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles