Créer une application web full-stack avec Deno 2 et le framework Fresh

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

Ce que vous allez construire

Dans ce tutoriel, vous allez créer une application de gestion de tâches complète avec Deno 2 et le framework Fresh. À la fin, vous aurez une application fonctionnelle avec :

  • Des pages rendues côté serveur sans JavaScript par défaut
  • Des îlots interactifs (Islands) pour les composants dynamiques
  • Des routes API RESTful pour les opérations CRUD
  • Deno KV pour le stockage persistant des données
  • TypeScript partout — aucune configuration nécessaire

Temps requis : 45-60 minutes


Prérequis

Avant de commencer, assurez-vous d'avoir :

  1. Deno 2 installé — Exécutez deno --version pour vérifier (doit être 2.x+)
  2. Connaissances de base en TypeScript/JavaScript
  3. Familiarité avec HTML et CSS
  4. Un éditeur de code (VS Code avec l'extension Deno recommandé)

Si Deno n'est pas installé :

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

Pourquoi Deno 2 + Fresh ?

Qu'est-ce que Deno 2 ?

Deno 2 est le runtime JavaScript et TypeScript de nouvelle génération créé par Ryan Dahl — le créateur original de Node.js. Il corrige de nombreuses erreurs de conception de Node tout en ajoutant :

  • Support TypeScript natif — pas besoin de tsconfig.json ni d'étape de build
  • Sécurisé par défaut — permissions explicites pour les fichiers, le réseau et l'environnement
  • Compatibilité npm — utilisez n'importe quel paquet npm avec le spécificateur npm:
  • Outillage intégré — formateur, linter, lanceur de tests et benchmarker
  • Deno KV — une base de données clé-valeur intégrée

Qu'est-ce que Fresh ?

Fresh est le framework web full-stack le plus populaire pour Deno. Il se distingue par :

  • L'architecture Islands — n'envoie du JavaScript que pour les composants interactifs
  • Pas d'étape de build — le code s'exécute directement, permettant des déploiements instantanés
  • Rendu côté serveur — les pages sont pré-rendues sur le serveur pour un chargement rapide
  • Routage basé sur les fichiers — les routes correspondent directement aux chemins de fichiers
  • Preact sous le capot — bibliothèque UI légère compatible avec React

Étape 1 : Créer un nouveau projet Fresh

Ouvrez votre terminal et créez un nouveau projet Fresh :

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

Lorsqu'on vous demande :

  • Souhaitez-vous utiliser une bibliothèque de style ? → Sélectionnez Tailwind CSS
  • Souhaitez-vous utiliser VS Code ? → Sélectionnez Yes (si vous utilisez VS Code)

Naviguez dans le projet :

cd my-task-manager

Lancez le serveur de développement pour vérifier :

deno task dev

Ouvrez http://localhost:8000 dans votre navigateur. Vous devriez voir la page d'accueil Fresh.

Structure du projet

Voici ce que Fresh a généré :

my-task-manager/
├── components/       # Composants UI partagés
├── islands/          # Composants interactifs côté client
├── routes/           # Routes basées sur les fichiers et endpoints API
│   ├── _app.tsx      # Wrapper de l'application (layout)
│   ├── index.tsx     # Page d'accueil
│   └── api/          # Routes API
├── static/           # Fichiers statiques (CSS, images)
├── deno.json         # Configuration Deno
├── dev.ts            # Point d'entrée développement
├── main.ts           # Point d'entrée production
└── fresh.gen.ts      # Manifeste auto-généré

Concept clé : Les fichiers dans routes/ sont rendus côté serveur par défaut. Les fichiers dans islands/ sont hydratés côté client avec JavaScript. C'est l'architecture Islands — seules les parties interactives envoient du JS au navigateur.


Étape 2 : Définir le modèle de données

Créez un nouveau fichier pour les types de tâches et les opérations de données.

Créez utils/db.ts :

// utils/db.ts
 
export interface Task {
  id: string;
  title: string;
  description: string;
  completed: boolean;
  createdAt: string;
  updatedAt: string;
}
 
// Ouvrir une instance de base de données Deno KV
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);
  }
 
  // Trier par date de création, le plus récent en premier
  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;
}

Pourquoi Deno KV ? C'est un magasin clé-valeur intégré qui ne nécessite aucune configuration. Les données persistent après le redémarrage du serveur localement, et sur Deno Deploy, elles deviennent une base de données distribuée mondialement.


Étape 3 : Créer les routes API

Fresh utilise le routage basé sur les fichiers pour les endpoints API également.

Lister et créer des tâches

Créez 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 — lister toutes les tâches
  async GET(_req, _ctx) {
    const tasks = await getAllTasks();
    return new Response(JSON.stringify(tasks), {
      headers: { "Content-Type": "application/json" },
    });
  },
 
  // POST /api/tasks — créer une nouvelle tâche
  async POST(req, _ctx) {
    const body = await req.json();
    const { title, description } = body;
 
    if (!title || typeof title !== "string") {
      return new Response(
        JSON.stringify({ error: "Le titre est requis" }),
        { 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" },
    });
  },
};

Mettre à jour et supprimer une tâche

Créez 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: "Tâche non trouvée" }),
        { 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: "Tâche non trouvée" }),
        { 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: "Tâche non trouvée" }),
        { status: 404, headers: { "Content-Type": "application/json" } }
      );
    }
 
    return new Response(JSON.stringify({ ok: true }), {
      headers: { "Content-Type": "application/json" },
    });
  },
};

Étape 4 : Créer l'îlot de la liste de tâches

Les îlots (Islands) sont la manière dont Fresh ajoute de l'interactivité. Seuls les composants Islands envoient du JavaScript au navigateur — tout le reste reste du HTML statique.

Créez 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("");
 
  // Charger les tâches au montage
  useEffect(() => {
    fetchTasks();
  }, []);
 
  async function fetchTasks() {
    loading.value = true;
    try {
      const res = await fetch("/api/tasks");
      tasks.value = await res.json();
    } catch (e) {
      error.value = "Échec du chargement des tâches";
    } 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 = "Échec de l'ajout de la tâche";
    }
  }
 
  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 = "Échec de la mise à jour";
    }
  }
 
  async function removeTask(id: string) {
    try {
      await fetch(`/api/tasks/${id}`, { method: "DELETE" });
      await fetchTasks();
    } catch (e) {
      error.value = "Échec de la suppression";
    }
  }
 
  return (
    <div class="max-w-2xl mx-auto p-4">
      {/* Formulaire d'ajout */}
      <form onSubmit={addTask} class="mb-8 bg-white rounded-lg shadow p-6">
        <h2 class="text-xl font-bold mb-4 text-gray-800">Ajouter une tâche</h2>
 
        <input
          type="text"
          placeholder="Titre de la tâche..."
          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 (optionnel)..."
          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"
        >
          Ajouter la tâche
        </button>
      </form>
 
      {/* Message d'erreur */}
      {error.value && (
        <div class="bg-red-100 text-red-700 p-3 rounded-lg mb-4">
          {error.value}
        </div>
      )}
 
      {/* État de chargement */}
      {loading.value && (
        <p class="text-center text-gray-500">Chargement des tâches...</p>
      )}
 
      {/* Liste des tâches */}
      {!loading.value && tasks.value.length === 0 && (
        <p class="text-center text-gray-500 py-8">
          Aucune tâche pour le moment. Ajoutez votre première tâche !
        </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("fr-FR")}
              </p>
            </div>
 
            <button
              onClick={() => removeTask(task.id)}
              class="text-red-400 hover:text-red-600 flex-shrink-0 text-lg"
              title="Supprimer la tâche"
            >
              ×
            </button>
          </div>
        ))}
      </div>
    </div>
  );
}

Points clés :

  • useSignal de Preact Signals fournit un état réactif — plus performant que useState
  • Ce composant vit dans islands/ donc il envoie du JavaScript au client
  • Tout le reste de la page reste du HTML statique

Étape 5 : Créer la page principale

Connectez maintenant l'îlot à une page rendue côté serveur.

Remplacez routes/index.tsx :

// routes/index.tsx
import { Head } from "$fresh/runtime.ts";
import { Handlers, PageProps } from "$fresh/server.ts";
import { getAllTasks } 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>Gestionnaire de tâches — Deno 2 & Fresh</title>
      </Head>
 
      <div class="min-h-screen bg-gray-100">
        {/* En-tête */}
        <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">
              📋 Gestionnaire de tâches
            </h1>
            <p class="text-gray-500 mt-1">
              Construit avec Deno 2 & Fresh — Rendu serveur avec des îlots interactifs
            </p>
          </div>
        </header>
 
        {/* Barre de statistiques — Rendu serveur, pas de 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">
                Terminées : <strong class="text-green-600">{completedTasks}</strong>
              </span>
              <span class="text-gray-600">
                Restantes :{" "}
                <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>
 
        {/* Contenu principal */}
        <main class="py-6">
          <TaskList />
        </main>
 
        {/* Pied de page */}
        <footer class="text-center py-6 text-gray-400 text-sm">
          <p>
            Propulsé par{" "}
            <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>
    </>
  );
}

Étape 6 : Ajouter un middleware de journalisation

Fresh prend en charge les middlewares pour les préoccupations transversales.

Créez 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);
 
  // Traiter la requête
  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;
}

Étape 7 : Exécuter et tester

Lancez le serveur de développement :

deno task dev

Ouvrez http://localhost:8000 et testez :

  1. Ajouter une tâche — remplissez le titre et la description, cliquez sur "Ajouter la tâche"
  2. Basculer la complétion — cliquez sur la case à cocher à côté d'une tâche
  3. Supprimer une tâche — cliquez sur le bouton ×
  4. Rafraîchir la page — les tâches persistent grâce à Deno KV

Tester l'API directement

# Lister les tâches
curl http://localhost:8000/api/tasks
 
# Créer une tâche
curl -X POST http://localhost:8000/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Apprendre Deno 2", "description": "Compléter le tutoriel Fresh"}'
 
# Basculer une tâche (remplacez TASK_ID)
curl -X PATCH http://localhost:8000/api/tasks/TASK_ID \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'
 
# Supprimer une tâche
curl -X DELETE http://localhost:8000/api/tasks/TASK_ID

Étape 8 : Déployer sur Deno Deploy

Deno Deploy est la plateforme d'hébergement naturelle pour les applications Fresh. Le déploiement est quasi instantané.

Option A : Via l'intégration GitHub

  1. Poussez votre projet sur GitHub
  2. Allez sur dash.deno.com
  3. Créez un nouveau projet
  4. Liez votre dépôt GitHub
  5. Définissez le point d'entrée sur main.ts
  6. Déployez — cela prend quelques secondes

Option B : Via la CLI

# Installer deployctl
deno install -Arf jsr:@deno/deployctl
 
# Déployer
deployctl deploy --project=my-task-manager --entrypoint=main.ts

Deno KV sur Deploy : Lors du déploiement sur Deno Deploy, vos données KV sont automatiquement distribuées mondialement — aucune configuration de base de données ni chaîne de connexion nécessaire.


Dépannage

Erreurs "Permission refusée"

Deno est sécurisé par défaut. Si vous voyez des erreurs de permission, assurez-vous d'utiliser les flags --allow-* ou de lancer avec -A pendant le développement :

deno run -A main.ts

"Module non trouvé" pour les paquets npm

Utilisez le spécificateur npm: dans vos imports :

import express from "npm:express@4";

Les données Deno KV ne persistent pas

Par défaut, les données KV sont stockées dans un fichier local. Vérifiez les permissions d'écriture. Pour un chemin personnalisé :

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

Ce que vous avez appris

Dans ce tutoriel, vous avez construit une application complète et appris :

  • La structure d'un projet Fresh — routes, îlots, composants et utilitaires
  • L'architecture Islands — envoyer du JavaScript uniquement où c'est nécessaire
  • Le rendu côté serveur — pages rendues sur le serveur pour un chargement instantané
  • Les routes API — endpoints RESTful avec routage basé sur les fichiers
  • Deno KV — stockage persistant sans configuration
  • Les middlewares — préoccupations transversales comme la journalisation
  • Le déploiement — mise en production avec Deno Deploy

Prochaines étapes

  • Ajouter l'authentification — utilisez Deno KV OAuth pour la connexion GitHub/Google
  • Ajouter des catégories — étendez le modèle de données avec des libellés et des filtres
  • Ajouter des mises à jour en temps réel — utilisez les Server-Sent Events (SSE)
  • Explorer les plugins Fresh — consultez fresh.deno.dev/docs/concepts/plugins
  • Lire la documentation Deno — explorez docs.deno.com

Conclusion

Deno 2 et Fresh offrent une approche simple et rafraîchissante du développement web full-stack. L'architecture Islands garantit que vos applications sont rapides par défaut, TypeScript fonctionne sans configuration, et Deno KV élimine le besoin d'une base de données externe. Si vous êtes fatigué des chaînes d'outils complexes et des frameworks lourds, Fresh mérite une considération sérieuse pour votre prochain projet.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Maîtriser les statistiques : Des bases descriptives à la régression avancée et aux tests d'hypothèses.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes