0%
Projet complet Next.js - Application de A à Z

Projet complet

Créez une application blog complète avec authentification et API

10-15 min

Projet complet Next.js - Application de A à Z

Dans ce tutoriel final, nous allons créer une application blog complète avec Next.js en utilisant tous les concepts appris dans les tutoriels précédents. Notre application aura une authentification, une API complète, une base de données et sera déployée en production.

Vue d’ensemble du projet

Fonctionnalités de l’application

Notre blog aura les fonctionnalités suivantes :

  • Authentification : Inscription, connexion, gestion des sessions
  • Gestion des articles : CRUD complet (Create, Read, Update, Delete)
  • Commentaires : Système de commentaires pour les articles
  • Catégories : Organisation des articles par catégories
  • Recherche : Recherche d’articles par titre et contenu
  • Interface admin : Dashboard pour gérer le contenu
  • API REST : API complète avec documentation Swagger
  • Responsive : Design adaptatif pour tous les écrans

Stack technique

  • Frontend : Next.js 14, React, TypeScript, Tailwind CSS
  • Backend : API Routes Next.js
  • Base de données : PostgreSQL avec Prisma ORM
  • Authentification : NextAuth.js avec JWT
  • Documentation : Swagger UI
  • Déploiement : Vercel
  • Stockage : Cloudinary pour les images

Phase 1 : Configuration du projet

Initialisation du projet

npx create-next-app@latest blog-nextjs --typescript --tailwind --eslint --app
cd blog-nextjs

Installation des dépendances

# Base de données et ORM
npm install prisma @prisma/client

# Authentification
npm install next-auth @next-auth/prisma-adapter

# Validation et utilitaires
npm install zod bcryptjs jsonwebtoken
npm install @types/bcryptjs @types/jsonwebtoken

# Documentation API
npm install swagger-ui-react swagger-jsdoc

# Upload d'images
npm install cloudinary multer
npm install @types/multer

# Interface utilisateur
npm install @headlessui/react @heroicons/react

# Développement
npm install --save-dev @types/node

Configuration de la base de données

Créez le fichier prisma/schema.prisma :

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

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

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  password  String
  role      Role     @default(USER)
  avatar    String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  posts    Post[]
  comments Comment[]

  @@map("users")
}

model Category {
  id          String   @id @default(cuid())
  name        String   @unique
  slug        String   @unique
  description String?
  color       String   @default("#3B82F6")
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  posts Post[]

  @@map("categories")
}

model Post {
  id          String   @id @default(cuid())
  title       String
  slug        String   @unique
  content     String
  excerpt     String?
  coverImage  String?
  published   Boolean  @default(false)
  featured    Boolean  @default(false)
  views       Int      @default(0)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  authorId   String
  author     User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  categoryId String
  category   Category @relation(fields: [categoryId], references: [id])

  comments Comment[]
  tags     Tag[]

  @@map("posts")
}

model Comment {
  id        String   @id @default(cuid())
  content   String
  approved  Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  authorId String
  author   User   @relation(fields: [authorId], references: [id], onDelete: Cascade)
  postId   String
  post     Post   @relation(fields: [postId], references: [id], onDelete: Cascade)

  @@map("comments")
}

model Tag {
  id    String @id @default(cuid())
  name  String @unique
  slug  String @unique
  color String @default("#10B981")

  posts Post[]

  @@map("tags")
}

enum Role {
  USER
  ADMIN
}

Variables d’environnement

Créez le fichier .env.local :

# Base de données
DATABASE_URL="postgresql://username:password@localhost:5432/blog_nextjs"

# NextAuth
NEXTAUTH_SECRET="your-secret-key-here"
NEXTAUTH_URL="http://localhost:3000"

# Cloudinary (pour les images)
CLOUDINARY_CLOUD_NAME="your-cloud-name"
CLOUDINARY_API_KEY="your-api-key"
CLOUDINARY_API_SECRET="your-api-secret"

# JWT
JWT_SECRET="your-jwt-secret"

Phase 2 : Configuration de l’authentification

Configuration NextAuth.js

Créez lib/auth.ts :

import { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import { prisma } from './prisma'
import bcrypt from 'bcryptjs'

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),
  providers: [
    CredentialsProvider({
      name: 'credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' }
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null
        }

        const user = await prisma.user.findUnique({
          where: { email: credentials.email }
        })

        if (!user) {
          return null
        }

        const isPasswordValid = await bcrypt.compare(
          credentials.password,
          user.password
        )

        if (!isPasswordValid) {
          return null
        }

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        }
      }
    })
  ],
  session: {
    strategy: 'jwt'
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = user.role
      }
      return token
    },
    async session({ session, token }) {
      if (token) {
        session.user.id = token.sub
        session.user.role = token.role
      }
      return session
    }
  },
  pages: {
    signIn: '/auth/signin',
    signUp: '/auth/signup'
  }
}

API d’authentification

Créez app/api/auth/[...nextauth]/route.ts :

import NextAuth from 'next-auth'
import { authOptions } from '@/lib/auth'

const handler = NextAuth(authOptions)

export { handler as GET, handler as POST }

Créez app/api/auth/register/route.ts :

import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'

const registerSchema = z.object({
  name: z.string().min(2, 'Le nom doit contenir au moins 2 caractères'),
  email: z.string().email('Email invalide'),
  password: z.string().min(6, 'Le mot de passe doit contenir au moins 6 caractères')
})

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const { name, email, password } = registerSchema.parse(body)

    // Vérifier si l'utilisateur existe déjà
    const existingUser = await prisma.user.findUnique({
      where: { email }
    })

    if (existingUser) {
      return NextResponse.json(
        { error: 'Un utilisateur avec cet email existe déjà' },
        { status: 400 }
      )
    }

    // Hasher le mot de passe
    const hashedPassword = await bcrypt.hash(password, 12)

    // Créer l'utilisateur
    const user = await prisma.user.create({
      data: {
        name,
        email,
        password: hashedPassword
      }
    })

    // Retourner l'utilisateur sans le mot de passe
    const { password: _, ...userWithoutPassword } = user

    return NextResponse.json(
      { user: userWithoutPassword },
      { status: 201 }
    )
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: 'Données invalides', details: error.errors },
        { status: 400 }
      )
    }

    return NextResponse.json(
      { error: 'Erreur interne du serveur' },
      { status: 500 }
    )
  }
}

Phase 3 : API Routes pour les articles

API CRUD pour les posts

Créez app/api/posts/route.ts :

import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'

const postSchema = z.object({
  title: z.string().min(5, 'Le titre doit contenir au moins 5 caractères'),
  content: z.string().min(10, 'Le contenu doit contenir au moins 10 caractères'),
  excerpt: z.string().optional(),
  categoryId: z.string(),
  published: z.boolean().default(false),
  featured: z.boolean().default(false),
  coverImage: z.string().optional()
})

/**
 * @swagger
 * /api/posts:
 *   get:
 *     summary: Récupère la liste des articles
 *     tags: [Posts]
 *     parameters:
 *       - in: query
 *         name: page
 *         schema:
 *           type: integer
 *           default: 1
 *       - in: query
 *         name: limit
 *         schema:
 *           type: integer
 *           default: 10
 *       - in: query
 *         name: search
 *         schema:
 *           type: string
 *       - in: query
 *         name: category
 *         schema:
 *           type: string
 *     responses:
 *       200:
 *         description: Liste des articles
 */
export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url)
    const page = parseInt(searchParams.get('page') || '1')
    const limit = parseInt(searchParams.get('limit') || '10')
    const search = searchParams.get('search')
    const category = searchParams.get('category')

    const where: any = { published: true }

    if (search) {
      where.OR = [
        { title: { contains: search, mode: 'insensitive' } },
        { content: { contains: search, mode: 'insensitive' } }
      ]
    }

    if (category) {
      where.category = { slug: category }
    }

    const [posts, total] = await Promise.all([
      prisma.post.findMany({
        where,
        include: {
          author: {
            select: { id: true, name: true, avatar: true }
          },
          category: true,
          _count: {
            select: { comments: true }
          }
        },
        orderBy: { createdAt: 'desc' },
        skip: (page - 1) * limit,
        take: limit
      }),
      prisma.post.count({ where })
    ])

    return NextResponse.json({
      posts,
      pagination: {
        page,
        limit,
        total,
        pages: Math.ceil(total / limit)
      }
    })
  } catch (error) {
    return NextResponse.json(
      { error: 'Erreur lors de la récupération des articles' },
      { status: 500 }
    )
  }
}

/**
 * @swagger
 * /api/posts:
 *   post:
 *     summary: Crée un nouvel article
 *     tags: [Posts]
 *     security:
 *       - bearerAuth: []
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: '#/components/schemas/CreatePost'
 *     responses:
 *       201:
 *         description: Article créé avec succès
 */
export async function POST(request: NextRequest) {
  try {
    const session = await getServerSession(authOptions)

    if (!session?.user) {
      return NextResponse.json(
        { error: 'Non autorisé' },
        { status: 401 }
      )
    }

    const body = await request.json()
    const data = postSchema.parse(body)

    // Générer le slug à partir du titre
    const slug = data.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '')

    const post = await prisma.post.create({
      data: {
        ...data,
        slug,
        authorId: session.user.id
      },
      include: {
        author: {
          select: { id: true, name: true, avatar: true }
        },
        category: true
      }
    })

    return NextResponse.json(post, { status: 201 })
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: 'Données invalides', details: error.errors },
        { status: 400 }
      )
    }

    return NextResponse.json(
      { error: 'Erreur lors de la création de l\'article' },
      { status: 500 }
    )
  }
}

API pour un article spécifique

Créez app/api/posts/[slug]/route.ts :

import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'

interface Params {
  slug: string
}

/**
 * @swagger
 * /api/posts/{slug}:
 *   get:
 *     summary: Récupère un article par son slug
 *     tags: [Posts]
 *     parameters:
 *       - in: path
 *         name: slug
 *         required: true
 *         schema:
 *           type: string
 *     responses:
 *       200:
 *         description: Article trouvé
 *       404:
 *         description: Article non trouvé
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Params }
) {
  try {
    const post = await prisma.post.findUnique({
      where: { slug: params.slug },
      include: {
        author: {
          select: { id: true, name: true, avatar: true }
        },
        category: true,
        comments: {
          where: { approved: true },
          include: {
            author: {
              select: { id: true, name: true, avatar: true }
            }
          },
          orderBy: { createdAt: 'desc' }
        },
        _count: {
          select: { comments: true }
        }
      }
    })

    if (!post) {
      return NextResponse.json(
        { error: 'Article non trouvé' },
        { status: 404 }
      )
    }

    // Incrémenter le nombre de vues
    await prisma.post.update({
      where: { id: post.id },
      data: { views: { increment: 1 } }
    })

    return NextResponse.json(post)
  } catch (error) {
    return NextResponse.json(
      { error: 'Erreur lors de la récupération de l\'article' },
      { status: 500 }
    )
  }
}

export async function PUT(
  request: NextRequest,
  { params }: { params: Params }
) {
  try {
    const session = await getServerSession(authOptions)

    if (!session?.user) {
      return NextResponse.json(
        { error: 'Non autorisé' },
        { status: 401 }
      )
    }

    const post = await prisma.post.findUnique({
      where: { slug: params.slug }
    })

    if (!post) {
      return NextResponse.json(
        { error: 'Article non trouvé' },
        { status: 404 }
      )
    }

    // Vérifier que l'utilisateur est l'auteur ou admin
    if (post.authorId !== session.user.id && session.user.role !== 'ADMIN') {
      return NextResponse.json(
        { error: 'Non autorisé' },
        { status: 403 }
      )
    }

    const body = await request.json()
    
    const updatedPost = await prisma.post.update({
      where: { slug: params.slug },
      data: body,
      include: {
        author: {
          select: { id: true, name: true, avatar: true }
        },
        category: true
      }
    })

    return NextResponse.json(updatedPost)
  } catch (error) {
    return NextResponse.json(
      { error: 'Erreur lors de la mise à jour de l\'article' },
      { status: 500 }
    )
  }
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: Params }
) {
  try {
    const session = await getServerSession(authOptions)

    if (!session?.user) {
      return NextResponse.json(
        { error: 'Non autorisé' },
        { status: 401 }
      )
    }

    const post = await prisma.post.findUnique({
      where: { slug: params.slug }
    })

    if (!post) {
      return NextResponse.json(
        { error: 'Article non trouvé' },
        { status: 404 }
      )
    }

    // Vérifier que l'utilisateur est l'auteur ou admin
    if (post.authorId !== session.user.id && session.user.role !== 'ADMIN') {
      return NextResponse.json(
        { error: 'Non autorisé' },
        { status: 403 }
      )
    }

    await prisma.post.delete({
      where: { slug: params.slug }
    })

    return NextResponse.json(
      { message: 'Article supprimé avec succès' },
      { status: 200 }
    )
  } catch (error) {
    return NextResponse.json(
      { error: 'Erreur lors de la suppression de l\'article' },
      { status: 500 }
    )
  }
}

Phase 4 : Interface utilisateur

Page d’accueil du blog

Créez app/page.tsx :

import { Suspense } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { prisma } from '@/lib/prisma'

async function getFeaturedPosts() {
  return await prisma.post.findMany({
    where: { published: true, featured: true },
    include: {
      author: {
        select: { name: true, avatar: true }
      },
      category: true,
      _count: {
        select: { comments: true }
      }
    },
    orderBy: { createdAt: 'desc' },
    take: 3
  })
}

async function getRecentPosts() {
  return await prisma.post.findMany({
    where: { published: true },
    include: {
      author: {
        select: { name: true, avatar: true }
      },
      category: true,
      _count: {
        select: { comments: true }
      }
    },
    orderBy: { createdAt: 'desc' },
    take: 6
  })
}

function PostCard({ post }: { post: any }) {
  return (
    <article className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
      {post.coverImage && (
        <div className="relative h-48">
          <Image
            src={post.coverImage}
            alt={post.title}
            fill
            className="object-cover"
          />
        </div>
      )}
      <div className="p-6">
        <div className="flex items-center mb-2">
          <span 
            className="px-2 py-1 text-xs font-medium rounded-full text-white"
            style={{ backgroundColor: post.category.color }}
          >
            {post.category.name}
          </span>
          <span className="ml-2 text-sm text-gray-500">
            {new Date(post.createdAt).toLocaleDateString('fr-FR')}
          </span>
        </div>
        <h2 className="text-xl font-bold mb-2 hover:text-blue-600">
          <Link href={`/posts/${post.slug}`}>
            {post.title}
          </Link>
        </h2>
        {post.excerpt && (
          <p className="text-gray-600 mb-4 line-clamp-3">
            {post.excerpt}
          </p>
        )}
        <div className="flex items-center justify-between">
          <div className="flex items-center">
            {post.author.avatar && (
              <Image
                src={post.author.avatar}
                alt={post.author.name}
                width={32}
                height={32}
                className="rounded-full mr-2"
              />
            )}
            <span className="text-sm text-gray-700">
              {post.author.name}
            </span>
          </div>
          <div className="flex items-center text-sm text-gray-500">
            <span>{post.views} vues</span>
            <span className="mx-2"></span>
            <span>{post._count.comments} commentaires</span>
          </div>
        </div>
      </div>
    </article>
  )
}

export default async function HomePage() {
  const [featuredPosts, recentPosts] = await Promise.all([
    getFeaturedPosts(),
    getRecentPosts()
  ])

  return (
    <div className="min-h-screen bg-gray-50">
      {/* Hero Section */}
      <section className="bg-gradient-to-r from-blue-600 to-purple-600 text-white py-20">
        <div className="container mx-auto px-4 text-center">
          <h1 className="text-5xl font-bold mb-4">
            Bienvenue sur notre Blog
          </h1>
          <p className="text-xl mb-8 max-w-2xl mx-auto">
            Découvrez nos articles sur le développement web, les technologies modernes 
            et les meilleures pratiques de programmation.
          </p>
          <Link
            href="/posts"
            className="bg-white text-blue-600 px-8 py-3 rounded-lg font-semibold hover:bg-gray-100 transition-colors"
          >
            Voir tous les articles
          </Link>
        </div>
      </section>

      {/* Featured Posts */}
      {featuredPosts.length > 0 && (
        <section className="py-16">
          <div className="container mx-auto px-4">
            <h2 className="text-3xl font-bold text-center mb-12">
              Articles en vedette
            </h2>
            <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
              {featuredPosts.map((post) => (
                <PostCard key={post.id} post={post} />
              ))}
            </div>
          </div>
        </section>
      )}

      {/* Recent Posts */}
      <section className="py-16 bg-white">
        <div className="container mx-auto px-4">
          <h2 className="text-3xl font-bold text-center mb-12">
            Articles récents
          </h2>
          <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
            {recentPosts.map((post) => (
              <PostCard key={post.id} post={post} />
            ))}
          </div>
          <div className="text-center mt-12">
            <Link
              href="/posts"
              className="bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
            >
              Voir tous les articles
            </Link>
          </div>
        </div>
      </section>
    </div>
  )
}

Page de détail d’un article

Créez app/posts/[slug]/page.tsx :

import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
import { CommentForm } from '@/components/CommentForm'
import { CommentList } from '@/components/CommentList'

interface PageProps {
  params: {
    slug: string
  }
}

async function getPost(slug: string) {
  const post = await prisma.post.findUnique({
    where: { slug },
    include: {
      author: {
        select: { id: true, name: true, avatar: true }
      },
      category: true,
      comments: {
        where: { approved: true },
        include: {
          author: {
            select: { id: true, name: true, avatar: true }
          }
        },
        orderBy: { createdAt: 'desc' }
      },
      _count: {
        select: { comments: true }
      }
    }
  })

  if (!post || !post.published) {
    return null
  }

  // Incrémenter les vues
  await prisma.post.update({
    where: { id: post.id },
    data: { views: { increment: 1 } }
  })

  return post
}

export default async function PostPage({ params }: PageProps) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound()
  }

  return (
    <div className="min-h-screen bg-gray-50">
      <article className="max-w-4xl mx-auto py-8 px-4">
        {/* Header */}
        <header className="mb-8">
          <div className="flex items-center mb-4">
            <Link
              href={`/categories/${post.category.slug}`}
              className="px-3 py-1 text-sm font-medium rounded-full text-white hover:opacity-80"
              style={{ backgroundColor: post.category.color }}
            >
              {post.category.name}
            </Link>
            <span className="ml-4 text-gray-500">
              {new Date(post.createdAt).toLocaleDateString('fr-FR', {
                year: 'numeric',
                month: 'long',
                day: 'numeric'
              })}
            </span>
          </div>
          
          <h1 className="text-4xl font-bold text-gray-900 mb-4">
            {post.title}
          </h1>
          
          <div className="flex items-center justify-between">
            <div className="flex items-center">
              {post.author.avatar && (
                <Image
                  src={post.author.avatar}
                  alt={post.author.name}
                  width={48}
                  height={48}
                  className="rounded-full mr-3"
                />
              )}
              <div>
                <p className="font-medium text-gray-900">
                  {post.author.name}
                </p>
                <p className="text-sm text-gray-500">
                  Auteur
                </p>
              </div>
            </div>
            
            <div className="flex items-center text-sm text-gray-500">
              <span>{post.views} vues</span>
              <span className="mx-2"></span>
              <span>{post._count.comments} commentaires</span>
            </div>
          </div>
        </header>

        {/* Cover Image */}
        {post.coverImage && (
          <div className="relative h-96 mb-8 rounded-lg overflow-hidden">
            <Image
              src={post.coverImage}
              alt={post.title}
              fill
              className="object-cover"
            />
          </div>
        )}

        {/* Content */}
        <div className="bg-white rounded-lg shadow-sm p-8 mb-8">
          <div 
            className="prose prose-lg max-w-none"
            dangerouslySetInnerHTML={{ __html: post.content }}
          />
        </div>

        {/* Comments Section */}
        <div className="bg-white rounded-lg shadow-sm p-8">
          <h2 className="text-2xl font-bold mb-6">
            Commentaires ({post._count.comments})
          </h2>
          
          <CommentForm postId={post.id} />
          
          {post.comments.length > 0 && (
            <CommentList comments={post.comments} />
          )}
        </div>
      </article>
    </div>
  )
}

Phase 5 : Dashboard administrateur

Interface d’administration

Créez app/admin/page.tsx :

import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { AdminStats } from '@/components/admin/AdminStats'
import { RecentPosts } from '@/components/admin/RecentPosts'
import { RecentComments } from '@/components/admin/RecentComments'

async function getAdminStats() {
  const [
    totalPosts,
    publishedPosts,
    totalComments,
    pendingComments,
    totalUsers
  ] = await Promise.all([
    prisma.post.count(),
    prisma.post.count({ where: { published: true } }),
    prisma.comment.count(),
    prisma.comment.count({ where: { approved: false } }),
    prisma.user.count()
  ])

  return {
    totalPosts,
    publishedPosts,
    totalComments,
    pendingComments,
    totalUsers
  }
}

export default async function AdminDashboard() {
  const session = await getServerSession(authOptions)

  if (!session?.user || session.user.role !== 'ADMIN') {
    redirect('/auth/signin')
  }

  const stats = await getAdminStats()

  return (
    <div className="min-h-screen bg-gray-50">
      <div className="max-w-7xl mx-auto py-8 px-4">
        <div className="mb-8">
          <h1 className="text-3xl font-bold text-gray-900">
            Dashboard Administrateur
          </h1>
          <p className="text-gray-600">
            Gérez votre blog et surveillez les statistiques
          </p>
        </div>

        <AdminStats stats={stats} />

        <div className="grid lg:grid-cols-2 gap-8 mt-8">
          <RecentPosts />
          <RecentComments />
        </div>
      </div>
    </div>
  )
}

Phase 6 : Déploiement

Configuration pour Vercel

Créez vercel.json :

{
  "build": {
    "env": {
      "PRISMA_GENERATE_DATAPROXY": "true"
    }
  },
  "functions": {
    "app/api/**/*.ts": {
      "maxDuration": 30
    }
  }
}

Scripts de déploiement

Ajoutez dans package.json :

{
  "scripts": {
    "build": "prisma generate && next build",
    "postinstall": "prisma generate",
    "db:migrate": "prisma migrate deploy",
    "db:seed": "tsx prisma/seed.ts"
  }
}

Seed de la base de données

Créez prisma/seed.ts :

import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'

const prisma = new PrismaClient()

async function main() {
  // Créer un utilisateur admin
  const hashedPassword = await bcrypt.hash('admin123', 12)
  
  const admin = await prisma.user.upsert({
    where: { email: 'admin@blog.com' },
    update: {},
    create: {
      email: 'admin@blog.com',
      name: 'Administrateur',
      password: hashedPassword,
      role: 'ADMIN'
    }
  })

  // Créer des catégories
  const categories = await Promise.all([
    prisma.category.upsert({
      where: { slug: 'web-development' },
      update: {},
      create: {
        name: 'Développement Web',
        slug: 'web-development',
        description: 'Articles sur le développement web moderne',
        color: '#3B82F6'
      }
    }),
    prisma.category.upsert({
      where: { slug: 'javascript' },
      update: {},
      create: {
        name: 'JavaScript',
        slug: 'javascript',
        description: 'Tout sur JavaScript et ses frameworks',
        color: '#F59E0B'
      }
    }),
    prisma.category.upsert({
      where: { slug: 'nextjs' },
      update: {},
      create: {
        name: 'Next.js',
        slug: 'nextjs',
        description: 'Framework React pour la production',
        color: '#10B981'
      }
    })
  ])

  // Créer des articles d'exemple
  await prisma.post.create({
    data: {
      title: 'Bienvenue sur notre blog Next.js',
      slug: 'bienvenue-blog-nextjs',
      content: `
        <h2>Bienvenue sur notre blog !</h2>
        <p>Ce blog a été créé avec Next.js, une application complète de A à Z.</p>
        <p>Vous trouverez ici des articles sur le développement web, JavaScript, React, Next.js et bien plus encore.</p>
        <h3>Fonctionnalités du blog :</h3>
        <ul>
          <li>Authentification complète</li>
          <li>Gestion des articles et commentaires</li>
          <li>Interface d'administration</li>
          <li>API REST documentée avec Swagger</li>
          <li>Design responsive</li>
        </ul>
      `,
      excerpt: 'Découvrez notre nouveau blog créé avec Next.js et toutes ses fonctionnalités modernes.',
      published: true,
      featured: true,
      authorId: admin.id,
      categoryId: categories[2].id // Next.js
    }
  })

  console.log('Base de données initialisée avec succès !')
}

main()
  .catch((e) => {
    console.error(e)
    process.exit(1)
  })
  .finally(async () => {
    await prisma.$disconnect()
  })

Conclusion

Félicitations ! Vous avez créé une application blog complète avec Next.js qui inclut :

✅ Fonctionnalités implémentées

  • Authentification complète avec NextAuth.js
  • API REST avec toutes les opérations CRUD
  • Base de données PostgreSQL avec Prisma ORM
  • Interface utilisateur moderne et responsive
  • Dashboard administrateur pour la gestion
  • Système de commentaires avec modération
  • Upload d’images avec Cloudinary
  • Documentation API avec Swagger
  • Déploiement sur Vercel

🚀 Prochaines étapes

Pour aller plus loin, vous pourriez ajouter :

  • Recherche avancée avec Elasticsearch
  • Newsletter avec intégration email
  • SEO avancé avec métadonnées dynamiques
  • Analytics avec Google Analytics
  • Cache avec Redis
  • Tests avec Jest et Cypress
  • Monitoring avec Sentry

Ce projet vous a permis de mettre en pratique tous les concepts Next.js appris dans les tutoriels précédents. Vous avez maintenant une base solide pour créer des applications web modernes et complètes !

Ressources utiles

Commentaires

Les commentaires sont alimentés par GitHub Discussions

Connectez-vous avec GitHub pour participer à la discussion

Lien copié !