Aller au contenu principal

Architecture Moderne : Next.js, TypeScript et PostgreSQL

Rayan Sekkat
7 min de lecture

Architecture Moderne : Next.js, TypeScript et PostgreSQL

Introduction

Choisir une architecture technique n'est jamais anodin. Chaque décision a des conséquences à long terme sur la maintenabilité, la performance, et la scalabilité du projet. Dans cet article, je partage les choix architecturaux faits pour ce portfolio et le raisonnement derrière chaque décision - un exercice de réflexion qui s'applique à tout projet moderne.

Le Framework de Décision

Avant de plonger dans les choix spécifiques, voici les critères qui ont guidé mes décisions :

1. Les 3 Piliers de l'Architecture

Performance ⚡

  • Temps de chargement < 2s
  • Score Lighthouse 100/100
  • Build time < 5s

Developer Experience 🛠️

  • Type safety complète
  • Hot reload < 100ms
  • Documentation claire

Maintenabilité 🔧

  • Code lisible et testable
  • Évolutivité simple
  • Faible dette technique

2. Le Contexte du Projet

// Contraintes et objectifs
interface ProjectContext {
  type: 'Portfolio & Blog';
  team: 'Solo developer';
  timeline: '2 semaines';
  traffic: 'Moyen (1-10k visites/mois)';
  budget: 'Minimal (hosting gratuit)';
  evolution: 'Ajout régulier de contenu';
}

Choix #1 : Next.js 16 App Router

Alternatives Considérées

Option A : Next.js Pages Router

  • ✅ Mature et stable
  • ✅ Documentation extensive
  • ❌ Patterns anciens
  • ❌ Moins performant

Option B : Remix

  • ✅ Excellent DX
  • ✅ Nested routing natif
  • ❌ Écosystème plus petit
  • ❌ Moins d'exemples

Option C : Astro

  • ✅ Performance extrême
  • ✅ Partial hydration
  • ❌ Interactivité limitée
  • ❌ Courbe d'apprentissage

✅ Choix Final : Next.js 16 App Router

Justification

// Ce qui a fait pencher la balance
const reasons = {
  performance: {
    serverComponents: 'Réduction de 40% du JS côté client',
    streaming: 'TTFB < 200ms',
    turbopack: 'Builds 10x plus rapides'
  },
  developer: {
    typescript: 'Support natif excellent',
    routing: 'File-based, intuitif',
    deployment: 'Vercel zero-config'
  },
  ecosystem: {
    community: '4M+ développeurs',
    plugins: 'Bibliothèque riche',
    hiring: 'Compétences répandues'
  }
};

Implémentation

// Structure App Router optimale
app/
├── (marketing)/          // Groupe de routes
│   ├── page.tsx         // Homepage
│   └── layout.tsx       // Layout partagé
├── blog/
│   ├── page.tsx         // Liste des articles
│   └── [slug]/
│       └── page.tsx     // Article individuel
├── api/
│   └── contact/
│       └── route.ts     // API Route
└── layout.tsx           // Root layout

// Avantages:
// - Colocation des routes et composants
// - Layouts imbriqués automatiques
// - Loading states intégrés
// - Error boundaries par route

Choix #2 : TypeScript en Mode Strict

Le Débat TypeScript vs JavaScript

JavaScript avec JSDoc

  • ✅ Pas de compilation
  • ✅ Adoption graduelle
  • ❌ Type safety partielle
  • ❌ Refactoring risqué

✅ TypeScript Strict

  • ✅ Erreurs détectées au build
  • ✅ Refactoring sûr
  • ✅ Autocomplete puissant
  • ❌ Configuration initiale

Configuration Optimale

// tsconfig.json - Mode strict activé
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true
  }
}

Bénéfices Réels

// Avant TypeScript : bugs en production
function getBlogPost(id) {
  const post = posts.find(p => p.id === id);
  return post.title; // ❌ Crash si post undefined
}

// Avec TypeScript : erreurs au build
function getBlogPost(id: string): BlogPost | undefined {
  const post = posts.find(p => p.id === id);
  return post?.title; // ✅ Gestion explicite
}

// Type safety bout-en-bout
const post = await prisma.blogPost.findUnique({
  where: { id }
}); // Type inféré automatiquement

Résultat : 0 bugs liés aux types en production sur 6 mois.

Choix #3 : PostgreSQL + Prisma

Comparaison des Options BDD

MongoDB

  • ✅ Schemaless flexible
  • ✅ Scaling horizontal simple
  • ❌ Pas de relations fortes
  • ❌ Transactions limitées

MySQL

  • ✅ Mature et stable
  • ✅ Performance éprouvée
  • ❌ JSON support limité
  • ❌ Less modern features

✅ PostgreSQL

  • ✅ Relationnel moderne
  • ✅ JSON natif (JSONB)
  • ✅ Full-text search
  • ✅ Extensions riches

Pourquoi Prisma ?

// Alternatives d'ORM
interface ORMComparison {
  typeorm: {
    pros: ['Decorators', 'Active Record'],
    cons: ['Type safety partielle', 'Magic']
  },
  drizzle: {
    pros: ['Performance', 'SQL-like'],
    cons: ['Jeune', 'Moins d'outils']
  },
  prisma: {
    pros: ['Type safety', 'DX', 'Migrations'],
    cons: ['Bundle size', 'Courbe apprentissage']
  }
}

Schéma Prisma : Le Cœur de l'Architecture

// prisma/schema.prisma
model BlogPost {
  id        String   @id @default(cuid())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  
  // Multilingual support avec JSON
  title   Json // { fr: "...", en: "...", kr: "..." }
  slug    Json
  content Json
  
  // Metadata
  coverImage  String
  category    String
  tags        String[]
  
  // SEO
  seoMetaDescription Json
  seoKeywords        String[]
  
  // Publishing
  isPublished         Boolean  @default(false)
  publishedAt         DateTime?
  readingTimeMinutes  Int
  viewCount           Int      @default(0)
  
  // Indexes pour performance
  @@index([isPublished, publishedAt])
  @@index([category])
}

Avantages en Production

// Type safety complète de la DB au frontend
async function getPublishedPosts() {
  const posts = await prisma.blogPost.findMany({
    where: { 
      isPublished: true 
    },
    orderBy: { 
      publishedAt: 'desc' 
    },
    select: {
      id: true,
      title: true,
      slug: true,
      excerpt: true
    }
  });
  
  // Type inféré : BlogPost[]
  return posts;
}

// Migrations versionnées
// npx prisma migrate dev --name add_view_count
// ✅ Historique complet des changements
// ✅ Rollback facile
// ✅ CI/CD intégré

Choix #4 : Architecture des Composants

Server vs Client Components

Règle de décision :

// Flowchart de décision
function shouldBeClientComponent(component: Component): boolean {
  const checks = {
    needsState: component.uses(['useState', 'useReducer']),
    needsEffects: component.uses(['useEffect', 'useLayoutEffect']),
    needsEvents: component.has('onClick|onChange|onSubmit'),
    needsBrowser: component.uses(['window', 'localStorage', 'navigator']),
  };
  
  return Object.values(checks).some(Boolean);
}

// 80% des composants = Server Components
// 20% des composants = Client Components

Exemple Concret

// ❌ Tout en Client (ancien pattern)
'use client';
export default function BlogSection() {
  const [posts, setPosts] = useState([]);
  
  useEffect(() => {
    fetch('/api/blog').then(r => r.json()).then(setPosts);
  }, []);
  
  return <BlogList posts={posts} />;
}

// ✅ Séparation Server/Client
// Server Component (default)
export default async function BlogSection() {
  const posts = await prisma.blogPost.findMany({
    where: { isPublished: true }
  });
  
  return <BlogList posts={posts} />; // Client component
}

// Résultat : 
// - Pas de fetch côté client
// - Pas d'état de loading
// - SEO optimal
// - -40KB de JavaScript

Choix #5 : Stratégie de Styling

Options Évaluées

Tailwind CSS

  • Utility-first
  • Purge automatique
  • Design system cohérent
  • Bundle: 8KB

CSS Modules

  • Scoped styles
  • Zero runtime
  • Courbe apprentissage
  • Verbeux

Styled Components

  • CSS-in-JS
  • Dynamique
  • Runtime overhead
  • Bundle +30KB

Configuration Tailwind Optimale

// tailwind.config.js
module.exports = {
  content: [
    './app/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#eff6ff',
          // ... palette complète
          950: '#172554',
        },
      },
      animation: {
        'fade-in': 'fadeIn 0.5s ease-out',
      },
    },
  },
  plugins: [
    require('@tailwindcss/typography'),
    require('@tailwindcss/forms'),
  ],
};

// Résultat : 
// - Classes purifiées : 8KB final
// - Design tokens cohérents
// - Dark mode intégré
// - Responsive par défaut

Architecture de Déploiement

Vercel : Le Choix Évident pour Next.js

// Configuration zero-downtime
export const config = {
  runtime: 'edge', // Déployé sur Edge Network
  regions: ['iad1'], // US East (proche Europe)
};

// Fonctionnalités utilisées:
const vercelFeatures = {
  edgeFunctions: 'Latence < 50ms globalement',
  imageOptimization: 'WebP/AVIF automatique',
  analytics: 'Core Web Vitals tracking',
  preview: 'Deploy preview par PR',
  rollback: 'One-click rollback'
};

CI/CD Pipeline

# .github/workflows/ci.yml
name: CI/CD
on: [push, pull_request]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      
      - name: Install
        run: npm ci
      
      - name: Lint
        run: npm run lint
      
      - name: Type Check
        run: npx tsc --noEmit
      
      - name: Test
        run: npm test
      
      - name: Build
        run: npm run build
      
      - name: Lighthouse
        run: npx lhci autorun

  deploy:
    needs: quality
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to Vercel
        run: vercel deploy --prod

Trade-offs et Leçons Apprises

Ce qui a Bien Fonctionné ✅

  1. Next.js App Router : Performance spectaculaire, DX excellent
  2. TypeScript Strict : 0 bugs de typage en production
  3. Prisma : Migrations fluides, type safety parfaite
  4. Tailwind : Développement rapide, maintenance simple
  5. Vercel : Déploiement sans friction

Ce que je Ferais Différemment 🔄

  1. State Management : J'aurais pu ajouter Zustand pour l'état global (mais finalement pas nécessaire)
  2. Testing : Aurait dû configurer Playwright dès le début
  3. Monitoring : Sentry aurait dû être ajouté jour 1
  4. Documentation : JSDoc plus systématique sur les fonctions complexes

Compromis Assumés ⚖️

// Exemples de compromis
const tradeoffs = {
  multilingual: {
    chosen: 'JSON fields in database',
    alternative: 'Separate tables per language',
    reason: 'Simplicité > Normalisation pour 3 langues',
    cost: 'Queries légèrement plus complexes'
  },
  images: {
    chosen: 'Vercel Image Optimization',
    alternative: 'Cloudinary CDN',
    reason: 'Intégration native, coût nul',
    cost: 'Vendor lock-in partiel'
  },
  auth: {
    chosen: 'Pas d'auth pour v1',
    alternative: 'NextAuth.js',
    reason: 'Pas de besoin immédiat',
    cost: 'À ajouter plus tard si nécessaire'
  }
};

Évolutivité et Croissance

Prêt pour la Scalabilité

// Architecture actuelle supporte :
const scalability = {
  traffic: '100k visites/mois sans modification',
  database: 'PostgreSQL scale jusqu'à 10M rows',
  cdn: 'Edge network global (Vercel)',
  cost: '0$ jusqu'à 10k visites, puis ~5$/mois'
};

// Évolution future possible :
const futureGrowth = {
  phase2: 'Ajouter Redis pour caching',
  phase3: 'Séparer API en microservices si nécessaire',
  phase4: 'Message queue pour jobs asynchrones'
};

Conclusion : Les Principes Directeurs

Les choix architecturaux réussis reposent sur ces principes :

  1. Pragmatisme > Perfectionnisme : Choisir ce qui fonctionne, pas ce qui est à la mode
  2. Mesurer > Supposer : Données réelles > Intuition
  3. Itérer > Big Bang : Évolution graduelle > Réécriture complète
  4. Developer Experience = Business Value : DX améliore productivité et qualité
  5. Contraintes = Clarté : Les limites forcent les bonnes décisions

Métriques de succès de cette architecture :

  • Build time : 3.5s (objectif < 5s) ✅
  • Lighthouse : 100/100 (objectif 95+) ✅
  • Type coverage : 100% (objectif 95%+) ✅
  • Bugs en production : 0 sur 6 mois ✅
  • Time to deploy : < 2 min ✅

L'architecture n'est jamais "terminée" - c'est un processus continu d'apprentissage, d'adaptation, et d'amélioration. Ce qui compte, c'est de partir sur de bonnes fondations qui permettent d'évoluer sans réécriture complète.

Ressources complémentaires :