18 juillet 2026 · Nuxt · Prisma · Fullstack

Une app fullstack avec Nuxt : server routes Nitro et Prisma

Construire l'API dans le même projet que le front : les routes serveur de Nitro, Prisma pour la base de données, et useFetch côté client. Un seul dépôt, une seule langue.

L'un des atouts les plus sous-estimés de Nuxt, c'est qu'il n'a pas besoin d'un backend séparé pour être fullstack. Le serveur est déjà là, sous le nom de Nitro. Ajoutez Prisma pour parler à une base de données, et vous tenez une application complète dans un seul dépôt, en TypeScript du front jusqu'à la requête SQL. Voici comment les pièces s'emboîtent.

Le dossier server/

Tout le code serveur vit dans server/, à la racine du projet (hors de app/, car il ne tourne jamais dans le navigateur). La convention la plus utile est server/api/ : chaque fichier y devient un point d'accès HTTP.

  • server/api/articles.get.ts répond à GET /api/articles
  • server/api/articles.post.ts répond à POST /api/articles
  • server/api/articles/[id].get.ts répond à GET /api/articles/42

Le suffixe (.get, .post) fixe la méthode HTTP, et l'arborescence fixe l'URL. Comme pour les pages : le disque décrit l'API.

// server/api/hello.get.ts
export default defineEventHandler((event) => {
  const { nom } = getQuery(event)
  return { message: `Bonjour ${nom ?? 'le monde'}` }
})

defineEventHandler reçoit un event (la requête), et ce que vous retournez devient la réponse JSON. Pas de res.json(), pas de sérialisation manuelle. Les utilitaires comme getQuery, readBody ou getRouterParam sont auto-importés.

Brancher Prisma

Prisma est un ORM TypeScript : vous décrivez vos tables dans un schéma, il génère un client typé et les migrations. Installation et schéma minimal :

npm install -D prisma
npm install @prisma/client
npx prisma init --datasource-provider sqlite
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Article {
  id      Int      @id @default(autoincrement())
  titre   String
  contenu String
  publie  Boolean  @default(false)
  cree_le DateTime @default(now())
}

On applique le schéma à la base et on génère le client :

npx prisma migrate dev --name init

Le client Prisma en singleton

Piège classique : instancier new PrismaClient() dans chaque route ouvre une nouvelle connexion à chaque appel et épuise la base. On l'instancie une seule fois, dans un utilitaire serveur réutilisable :

// server/utils/prisma.ts
import { PrismaClient } from '@prisma/client'

export const prisma = new PrismaClient()

Placé dans server/utils/, il est auto-importé dans toutes les routes serveur. Une instance, une réserve de connexions, partagée proprement.

Une vraie route qui lit la base

// server/api/articles.get.ts
export default defineEventHandler(async () => {
  return await prisma.article.findMany({
    where: { publie: true },
    orderBy: { cree_le: 'desc' }
  })
})

Et une route qui écrit, avec validation de l'entrée :

// server/api/articles.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  if (!body?.titre || !body?.contenu) {
    throw createError({ statusCode: 400, message: 'Titre et contenu requis' })
  }

  return await prisma.article.create({
    data: { titre: body.titre, contenu: body.contenu }
  })
})

createError renvoie une vraie erreur HTTP, que le client saura interpréter plutôt que de recevoir un 200 avec un objet d'erreur déguisé.

Le front consomme sa propre API

Côté page, on ne fait pas d'appel réseau exotique : la même application appelle sa route avec useFetch. L'URL est relative, il n'y a pas de problème de CORS, et le rendu serveur pré-remplit déjà les données.

<script setup lang="ts">
const { data: articles, refresh } = await useFetch('/api/articles')

async function publier(nouvel: { titre: string; contenu: string }) {
  await $fetch('/api/articles', { method: 'POST', body: nouvel })
  await refresh()
}
</script>

<template>
  <article v-for="a in articles" :key="a.id">
    <h2>{{ a.titre }}</h2>
    <p>{{ a.contenu }}</p>
  </article>
</template>

useFetch pour lire au rendu (avec cache et hydratation), $fetch pour les actions ponctuelles comme un POST. La boucle est complète : la page lit une route Nitro, qui interroge Prisma, qui parle à la base, et tout est typé du même bout à l'autre.

Ce que ça change au déploiement

Attention : dès qu'il y a des routes serveur et une base, on quitte le monde purement statique. Il faut une cible capable d'exécuter du Node (un VPS, une plateforme applicative, une fonction serverless), et non plus un simple hébergement de fichiers. Nitro sait produire la bonne sortie pour chacune de ces cibles, mais le choix d'hébergement, lui, n'est plus le même que pour un site statique. C'est le prix, assumé, du fullstack.

La leçon

Nuxt fullstack, ce n'est pas « Nuxt plus un backend » : c'est un seul projet, un seul langage, une seule chaîne de types, du composant Vue jusqu'à la table SQL. Nitro fournit le serveur, Prisma la base, et useFetch relie les deux sans couture. Pour un produit mené en petite équipe, cette unité est un avantage décisif : moins de code de plomberie, moins de contrats d'API à maintenir, et le remboursement se fait sur chaque fonctionnalité.

← Tous les articles