Technologies : Next.js, React, TypeScript, Tailwind CSS, Strapi, Supabase, Sanity, PostgreSQL, Node.js, Docker, Linux, Framer Motion, Three.js, GitHub Actions, Vercel.

Aperçu du projet Zielscheibe Nachhilfe
En ligne

Zielscheibe Nachhilfe

Plateforme de soutien scolaire et de coaching d'apprentissage à Essen, dans la Ruhr et en ligne : demande d'aides (BuT), contrat avec signature numérique et PDF, rendez-vous en ligne et espace membres, en 7 langues dont l'arabe.

7
langues, dont l'arabe de droite à gauche
2 026
tests automatisés (987 front, 1 039 back)
975
commits, avec intégration continue
Next.js 16Strapi 5TypeScriptTailwind CSSPostgreSQL

zielscheibe-nachhilfe.de

Visiter le site

Étude de cas

Zielscheibe Nachhilfe

Plateforme de soutien scolaire et de coaching d'apprentissage à Essen, dans la Ruhr et en ligne : demande d'aides (BuT), contrat avec signature numérique et PDF, rendez-vous en ligne et espace membres, en 7 langues dont l'arabe.

Mon rôle. Conception et développement complets : front, CMS, espace membres, contrats et déploiement.

Next.js 16Strapi 5TypeScriptTailwind CSSPostgreSQL

Défis & solutions

  1. 01 · Problème

    Les familles parlent arabe, turc, ukrainien, russe… et les contrats de certaines sortaient vides.

    Solution

    7 langues avec prise en charge complète de l'écriture de droite à gauche, noms en alphabet latin acceptés pour toutes les familles, et un générateur de PDF qui embarque les bonnes polices pour que chaque contrat soit complet.

    frontend/lib/pdf-contract-generator.ts

    Avant

    try {
      const form = mainPdfDoc.getForm();
      // …
      const tf1_6 = form.getTextField('Textfeld 1_6');
      if (tf1_6 && studentName) tf1_6.setText(studentName);
    
      const tf1_7 = form.getTextField('Textfeld 1_7');
      if (tf1_7 && data.dateOfBirth) tf1_7.setText(data.dateOfBirth);
      // …

    Après

    /**
     * pdf-lib lève une erreur quand un nom de champ manque, au lieu de renvoyer undefined. Donc
     * `getTextField('A') || getTextField('B')` n'atteignait jamais B, et un champ renommé interrompait
     * le reste de son bloc, en silence, dans un try/catch, laissant une partie d'un contrat ou d'un
     * formulaire de consentement vide.
     */
    function lenientForm(form: PDFForm, label: string) {
      const lookup = <T>(kind: string, name: string, get: () => T): T | undefined => {
        try {
          return get();
        } catch {
          console.warn(`[PDF-GENERATOR] ${label}: ${kind} "${name}" fehlt in der Vorlage`);
          return undefined;
        }
      };
      return {
        // Le texte passe par toWinAnsi : l'Helvetica du formulaire ne sait pas dessiner « ş » ni « ı »,
        // et une seule erreur de ce genre laissait tout le contrat vide.
        getTextField: (name: string) => {
          const field = lookup('Textfeld', name, () => form.getTextField(name));
          return field ? { setText: (value?: string) => field.setText(value === undefined ? value : toWinAnsi(value)) } : undefined;
        },
        // …
      };
    }
    
    const form = lenientForm(mainPdfDoc.getForm(), 'Vertrag');
  2. 02 · Problème

    Données personnelles d'enfants et de parents : la loi impose des durées de conservation et une suppression propre.

    Solution

    Une purge complète validée par un avocat, qui ne désabonne plus les lecteurs de la newsletter dont le consentement est toujours en vigueur.

    backend/src/services/gdpr-retention.js

    Avant

    const stale = await strapi.documents(uid).findMany({
      filters: { createdAt: { $lt: cutoff } },
      limit: BATCH_SIZE,
      // …
    });

    Après

    /**
     * Un abonné confirmé à la newsletter est un consentement toujours en vigueur, pas une trace
     * d'échanges passés : purger selon createdAt désabonnait chaque lecteur trois ans après son
     * inscription, sans qu'il l'ait demandé.
     */
    const EXCLUDED_FROM_PURGE = {
      'api::newsletter-subscriber.newsletter-subscriber': { status: { $ne: 'Bestätigt' } },
    };
    
    const stale = await strapi.documents(uid).findMany({
      filters: { createdAt: { $lt: cutoff }, ...(EXCLUDED_FROM_PURGE[uid] || {}) },
      limit: BATCH_SIZE,
      // …
    });
  3. 03 · Problème

    Un site public avec des comptes est une cible, et une faille du framework a été publiée pendant le projet.

    Solution

    Content-Security-Policy stricte par nonce, d'abord en mode rapport, jetons CSRF, et mise à jour immédiate de Next.js dès l'annonce des failles de gravité élevée.

    frontend/proxy.ts

    Avant

    const csp = `
      default-src 'self';
      script-src 'self' 'unsafe-inline' ${evalDirective} https://challenges.cloudflare.com https://www.googletagmanager.com …;
      style-src 'self' 'unsafe-inline' https://cdn-cookieyes.com;
      // …`

    Après

    const STRICT_CSP = process.env.CSP_STRICT === 'true';
    
    function generateNonce(): string {
      const bytes = new Uint8Array(16);
      crypto.getRandomValues(bytes);
      let binary = '';
      for (const b of bytes) binary += String.fromCharCode(b);
      return btoa(binary);
    }
    
    // Faire confiance aux scripts par nonce et les laisser charger leurs propres dépendances
    // (strict-dynamic).
    const scriptSrcPrefix =
      STRICT_CSP && nonce
        ? `'self' 'nonce-${nonce}' 'strict-dynamic' 'unsafe-inline' ${evalDirective}`
        : `'self' 'unsafe-inline' ${evalDirective}`;
    
    const csp = `
      default-src 'self';
      script-src ${scriptSrcPrefix} https://challenges.cloudflare.com …;
      // …`
  4. 04 · Problème

    Quand le CMS avait un souci, des pages devenaient des 404 et le serveur atteignait ses propres limites de débit.

    Solution

    Des pages d'accueil qui restent en ligne si Strapi tombe, un jeton serveur sur chaque lecture, et un dossier en échec qui n'est plus régénéré à l'infini.

    frontend/app/[locale]/angebote/[...slug]/page.tsx

    Avant

    let strapiOffer = await getAngebotBySlugServer(fullSlug, locale).catch(() => null);
    if (!strapiOffer && slugArray.length > 1) {
      strapiOffer = await getAngebotBySlugServer(normalizedSlug, locale).catch(() => null);
    }

    Après

    // Un Strapi injoignable est mémorisé, sans être transformé en « introuvable » : les contenus
    // statiques de secours ci-dessous sont quand même essayés, et seul un slug qu'aucun ne connaît lève
    // une erreur (non mise en cache) au lieu d'un 404 (mis en cache).
    let strapiUnavailable = false;
    const lookup = (slugToFind: string) =>
      getAngebotBySlugServer(slugToFind, locale, { throwWhenUnavailable: true }).catch(() => {
        strapiUnavailable = true;
        return null;
      });
    let strapiOffer = await lookup(fullSlug);
    if (!strapiOffer && slugArray.length > 1) {
      strapiOffer = await lookup(normalizedSlug);
    }
    // …
    if (strapiUnavailable) {
      throw new Error(`Strapi unavailable while resolving /angebote/${fullSlug}`);
    }
    return null;
  5. 05 · Problème

    Strapi limite les lectures publiques à 120 par minute et par IP, et chaque rendu de page part des mêmes quelques adresses Vercel : les rendus du site lui-même atteignaient la limite et recevaient des 429 (équipe absente de la page À propos, sitemap vide, horaires en repli statique).

    Solution

    Chaque appel serveur porte désormais un jeton partagé que Strapi ne compte pas, dans une variable sans préfixe NEXT_PUBLIC_ pour qu'elle n'atteigne jamais un bundle navigateur. Un appel direct sans jeton reste limité.

    frontend/lib/strapi-server.ts

    Code

    // Strapi limite les GET publics par IP (120/min, middlewares/rate-limit.js), et chaque rendu
    // serveur part des mêmes quelques adresses Vercel : les rendus des pages du site remplissaient
    // cette fenêtre et recevaient des 429 (contenu de secours, plan du site vide). Le jeton partagé du
    // serveur indique que l'appel vient de ce serveur, que Strapi ne compte pas. La variable n'a pas de
    // préfixe NEXT_PUBLIC_, donc elle n'atteint jamais le code envoyé au navigateur.
    if (FORM_API_TOKEN && !headers.has('X-Api-Token')) headers.set('X-Api-Token', FORM_API_TOKEN);
  6. 06 · Problème

    Un dossier de documents qui échouait sans cesse (pièce illisible, fournisseur en panne pendant des jours) était régénéré et renvoyé toutes les cinq minutes, sans fin.

    Solution

    Un compteur de tentatives stocké sur l'enregistrement : après 5 essais, le dossier reste « failed » pour que l'équipe le regarde, et la tâche de reprise l'ignore.

    backend/src/services/document-jobs.js

    Avant

    await strapi.db.query(uid).updateMany({ where: { documentId, documentLease: lease }, data: { documentStatus: 'failed', documentLease: null, documentLeaseExpiresAt: null } });
    strapi.log.error(`[DOCUMENTS] ${kind} dossier retained for retry: ${error.message}`);
    // …
    filters: { $or: [{ documentStatus: { $in: ['pending', 'failed'] } }, { documentStatus: 'processing', documentLeaseExpiresAt: { $lt: new Date().toISOString() } }] }, limit: 25, sort: ['updatedAt:asc'],

    Après

    /**
     * Un dossier qui échoue en boucle (pièce jointe illisible, prestataire en panne pendant des jours)
     * était régénéré et renvoyé toutes les cinq minutes sans fin. Après ce nombre d'essais, il reste «
     * en échec » pour que l'équipe s'en occupe.
     */
    const MAX_DOCUMENT_ATTEMPTS = 5;
    
    // …
    const attempts = (record.documentAttempts || 0) + 1;
    await strapi.db.query(uid).updateMany({ where: { documentId, documentLease: lease }, data: { documentStatus: 'failed', documentLease: null, documentLeaseExpiresAt: null, documentAttempts: attempts } });
    // …
    filters: { $or: [
      { documentStatus: 'pending' },
      { documentStatus: 'failed', $or: [{ documentAttempts: { $null: true } }, { documentAttempts: { $lt: MAX_DOCUMENT_ATTEMPTS } }] },
      { documentStatus: 'processing', documentLeaseExpiresAt: { $lt: new Date().toISOString() } },
    ] }, limit: 25, sort: ['updatedAt:asc'],
  7. 07 · Problème

    Les sessions des membres sont des jetons sans état valables 7 jours : après un changement de mot de passe, les anciens jetons (un jeton volé, un appareil oublié) continuaient de fonctionner.

    Solution

    L'instant du changement est stocké sur l'utilisateur et tout jeton émis avant est refusé, arrondi à la seconde pour que le jeton émis juste après le changement fonctionne. Le nouveau jeton remplace le cookie : le membre reste connecté. Les jetons de confirmation sont stockés hachés.

    backend/src/lib/session-revocation.js

    Code

    /**
     * Les sessions se terminent quand le mot de passe change. Les JWT des membres sont sans état et
     * valables jusqu'à 7 jours. Changer ou réinitialiser le mot de passe enregistre l'instant dans
     * user.passwordChangedAt ; tout jeton émis avant (son « iat », en secondes entières) est alors
     * refusé. L'instant est arrondi à la seconde pour que le jeton émis juste après le changement, dans
     * la même seconde, fonctionne encore.
     */
    function passwordChangeInstant(now = Date.now()) {
      return new Date(Math.floor(now / 1000) * 1000);
    }
    
    function issuedBeforePasswordChange(tokenPayload, passwordChangedAt) {
      if (!passwordChangedAt) return false;
      const changedAt = new Date(passwordChangedAt).getTime();
      if (Number.isNaN(changedAt)) return false;
      // Un jeton sans « iat » ne peut pas prouver qu'il est plus récent que le changement.
      if (typeof tokenPayload?.iat !== 'number') return true;
      return tokenPayload.iat * 1000 < changedAt;
    }

En chiffres

  • 7

    langues, dont l'arabe de droite à gauche

  • 2 026

    tests automatisés (987 front, 1 039 back)

  • 975

    commits, avec intégration continue

Visiter le site
Aperçu du projet École Notre Dame du Mont Carmel
En ligne

École Notre Dame du Mont Carmel

Site de l'école de Nikki (Bénin), liée à la fondation FUNDEBE : présentation, infos pratiques, actualités, étapes de scolarité et inscription en ligne, en 4 langues et installable comme une application (PWA).

−57 %
de poids d'images (de 6,2 Mo à 2,6 Mo)
4
langues
PWA
installable et utilisable hors ligne
Next.js 14Tailwind CSSFramer MotionPWA

ndmc-nikki.netlify.app

Visiter le site

Étude de cas

École Notre Dame du Mont Carmel

Site de l'école de Nikki (Bénin), liée à la fondation FUNDEBE : présentation, infos pratiques, actualités, étapes de scolarité et inscription en ligne, en 4 langues et installable comme une application (PWA).

Mon rôle. Refonte et développement complets du site de l'école, du design system au déploiement.

Next.js 14Tailwind CSSFramer MotionPWA

Défis & solutions

  1. 01 · Problème

    Les textes étaient écrits en dur et la bibliothèque de traduction ne fonctionnait pas avec un export statique : le premier rendu n'était pas traduit ou échouait à l'hydratation.

    Solution

    Un schéma hybride : un composant serveur fixe la langue avant le rendu et un provider client ajoute react-i18next, si bien que les traductions marchent côté serveur et dans le navigateur.

    frontend/src/i18n/I18nProvider.tsx

    Avant

    export default function I18nProvider({ children, locale }: { children: ReactNode; locale: string }) {
      const [Provider, setProvider] = useState<ComponentType<{ i18n: unknown; children: ReactNode }> | null>(null);
    
      useEffect(() => {
        if (i18n.language !== locale) {
          i18n.changeLanguage(locale);
        }
        let cancelled = false;
        import("react-i18next").then((mod) => {
          if (!cancelled) {
            setProvider(() => mod.I18nextProvider as ComponentType<{ i18n: unknown; children: ReactNode }>);
          }
        });
        return () => { cancelled = true; };
      }, [locale]);
    
      if (!Provider) {
        return <>{children}</>;
      }
    
      return <Provider i18n={i18n}>{children}</Provider>;
    }

    Après

    // I18nServerSync.tsx : pas de « use client », s'exécute uniquement sur le serveur
    export default function I18nServerSync({ locale, children }: { locale: Locale; children: React.ReactNode }) {
      // Forcer i18next sur la bonne langue pendant le rendu serveur
      if (i18n.language !== locale) {
        i18n.changeLanguage(locale);
      }
      return <>{children}</>;
    }
    
    // I18nProvider.tsx : côté client
    if (!i18next.isInitialized) {
      i18next.use(initReactI18next);
    }
    
    export default function I18nProvider({ children }: { children: ReactNode }) {
      useEffect(() => {
        // S'assurer que react-i18next est initialisé
        if (!i18next.hasResourceBundle(i18next.language, "translation")) {
          i18next.use(initReactI18next);
        }
      }, []);
    
      return <I18nextProvider i18n={i18next}>{children}</I18nextProvider>;
    }
    
    // layout.tsx
    <I18nServerSync locale={locale}>
      <I18nProvider>
        <Navbar />
        {/* … */}
      </I18nProvider>
    </I18nServerSync>
  2. 02 · Problème

    Les erreurs de formulaire restaient en français : avec Zod 4, les messages personnalisés écrits dans les schémas contournaient la table d'erreurs et n'étaient jamais traduits.

    Solution

    Messages retirés des schémas et une seule table d'erreurs qui transforme champ et type d'erreur en clé de traduction, dans les 4 langues.

    frontend/src/lib/validations.ts

    Avant

    enfantNom: z
      .string()
      .min(2, "zodErrors.nameMin")
      .max(100, "zodErrors.nameMax")
      .regex(/^[a-zA-ZÀ-ÿ\s'-]+$/, "zodErrors.nameRegex"),
    
    dateNaissance: z
      .string()
      .min(1, "zodErrors.dateNaissanceRequired")
      .refine((val) => {
        // …
      }, "zodErrors.dateNaissanceAge"),

    Après

    enfantNom: z
      .string()
      .min(2)
      .max(100)
      .regex(/^[a-zA-ZÀ-ÿ\s'-]+$/),
    
    dateNaissance: z
      .string()
      .min(1)
      .refine((val) => {
        // …
      }),
  3. 03 · Problème

    Les photos de l'école pesaient trop lourd pour les connexions au Bénin.

    Solution

    Conversion de toutes les images en WebP, de 6,2 Mo à 2,6 Mo (−57 %), et une PWA installable avec service worker.

  4. 04 · Problème

    Un site d'école doit être trouvé, lisible par tous et compris des moteurs de recherche.

    Solution

    Sitemap, robots.txt, balises canonical et hreflang, données Schema.org, travail d'accessibilité (mouvement réduit, ARIA) et un design system aux couleurs de l'école.

En chiffres

  • −57 %

    de poids d'images (de 6,2 Mo à 2,6 Mo)

  • 4

    langues

  • PWA

    installable et utilisable hors ligne

Visiter le site
Aperçu du projet QuestPocket
En ligne

QuestPocket

Landing page de Pocket Quest, une application mobile qui transforme le temps d'écran en récompenses : on gagne du temps sur les réseaux sociaux en accomplissant des tâches saines. Fond 3D en pixel art animé, défilement fluide, liste d'attente et compte à rebours, en 3 langues.

3
langues (anglais, français, allemand)
6
itérations de design en 3 jours
3D
fond pixel art en WebGL
Next.js 14Tailwind CSSThree.jsGSAPLenis

questpocket.netlify.app

Visiter le site

Étude de cas

QuestPocket

Landing page de Pocket Quest, une application mobile qui transforme le temps d'écran en récompenses : on gagne du temps sur les réseaux sociaux en accomplissant des tâches saines. Fond 3D en pixel art animé, défilement fluide, liste d'attente et compte à rebours, en 3 langues.

Mon rôle. Conception et développement de la landing page. L'application mobile elle-même (Flutter, NestJS) est encore en phase de spécification.

Next.js 14Tailwind CSSThree.jsGSAPLenis

Défis & solutions

  1. 01 · Problème

    Le fond 3D en pixel art construit avec Three.js cassait le rendu serveur et provoquait des erreurs d'hydratation, car Three.js ne fonctionne que dans le navigateur.

    Solution

    Les composants WebGL sont chargés côté client seulement, avec un import dynamique et le rendu serveur désactivé, tandis que le reste de la page reste un export statique.

    landing-react/src/components/PixelBackground.tsx

    Avant

    export default function PixelBackground() {
      return (
        <div className="fixed inset-0 z-0" style={{ filter: 'blur(60px)', opacity: 0.35, pointerEvents: 'none' }}>
          <Canvas
            // …
          </Canvas>
        </div>
      )
    }

    Après

    import dynamic from 'next/dynamic'
    
    function PixelBackgroundInner() {
      return (
        <div className="fixed inset-0 z-0" style={{ filter: 'blur(60px)', opacity: 0.35, pointerEvents: 'none' }}>
          <Canvas
            // …
          </Canvas>
        </div>
      )
    }
    
    export default dynamic(() => Promise.resolve(PixelBackgroundInner), { ssr: false })
  2. 02 · Problème

    Les apparitions au scroll dépendaient d'un hook JavaScript qui basculait une classe : le contenu restait invisible jusqu'à l'hydratation, et le défilement fluide ne fonctionnait pas dans l'export statique.

    Solution

    Apparitions réécrites en pures animations CSS avec des délais échelonnés, donc sans JavaScript, et Lenis chargé dans un effet une fois la page prête.

    landing-react/src/app/globals.css

    Avant

    .reveal {
      opacity: 0;
      transform: translateY(40px);
      transition: opacity 0.8s cubic-bezier(0.22, 1, 0.36, 1),
                  transform 0.8s cubic-bezier(0.22, 1, 0.36, 1);
    }
    .reveal.visible {
      opacity: 1;
      transform: translateY(0);
    }
    
    .stagger-1 { transition-delay: 0.05s; }
    .stagger-2 { transition-delay: 0.1s; }

    Après

    /* Animations d'apparition liées au défilement : CSS seul, aucun JS nécessaire */
    .reveal {
      animation: revealUp 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
      opacity: 0;
    }
    
    .stagger-1 { animation-delay: 0.05s; }
    .stagger-2 { animation-delay: 0.1s; }
    
    @keyframes revealUp {
      from {
        opacity: 0;
        transform: translateY(40px);
      }
      /* … */
    }

En chiffres

  • 3

    langues (anglais, français, allemand)

  • 6

    itérations de design en 3 jours

  • 3D

    fond pixel art en WebGL

Visiter le site
Aperçu du projet Laserbeni
En ligne

Laserbeni

Plateforme et application installable (PWA) d'un institut d'épilation laser durable en Allemagne : zones et tarifs, offres, FAQ, avant/après, réservation intégrée via Beautinda, contenus gérés dans Sanity et ambiance 3D en verre.

19
zones de prix sur un corps 3D à toucher
100
score Lighthouse en accessibilité, bonnes pratiques et SEO (mesuré)
PWA
installable sur iOS et Android
Next.jsTailwind CSSSanityThree.jsPWA

laserbeni.pages.dev

Visiter le site

Étude de cas

Laserbeni

Plateforme et application installable (PWA) d'un institut d'épilation laser durable en Allemagne : zones et tarifs, offres, FAQ, avant/après, réservation intégrée via Beautinda, contenus gérés dans Sanity et ambiance 3D en verre.

Mon rôle. Conception et développement de la plateforme et de l'application installable.

Next.jsTailwind CSSSanityThree.jsPWA

Défis & solutions

  1. 01 · Problème

    Les clientes doivent réserver sans quitter l'application, mais le studio utilise déjà le système de réservation Beautinda.

    Solution

    Beautinda s'ouvre dans une fenêtre modale à l'intérieur de la PWA, compte client compris pour consulter ou déplacer ses rendez-vous : une seule expérience.

  2. 02 · Problème

    Le public est sur téléphone et attend une application, sans passer par un magasin d'applications.

    Solution

    PWA pensée mobile d'abord : manifeste, service worker, mode hors ligne et bandeaux d'installation iOS et Android, prête pour un emballage natif plus tard.

  3. 03 · Problème

    Les prix, offres et photos changent souvent, et la gérante n'est pas développeuse.

    Solution

    Contenu géré dans Sanity : prix, forfaits, photos avant/après et FAQ se modifient sans toucher au code.

En chiffres

  • 19

    zones de prix sur un corps 3D à toucher

  • 100

    score Lighthouse en accessibilité, bonnes pratiques et SEO (mesuré)

  • PWA

    installable sur iOS et Android

Visiter le site

Ce que disent de notre collaboration les personnes pour qui je construis.

« Monsieur Agossou n'a pas simplement développé pour nous un site web ordinaire à partir d'un modèle standard acheté. Il a au contraire programmé et conçu une solution web entièrement individuelle, techniquement exigeante et taillée exactement pour les besoins de notre entreprise. »

Viktorija MousaPropriétaire, Nachhilfe-Netzwerk ZielscheibeTraduit de l'allemand

Sites & plateformes web

Je conçois des sites et des applications avec Next.js, React, TypeScript et Tailwind CSS : rapides, bien référencés, installables en PWA quand c'est utile.

CMS & back-end

Je gère les contenus avec Strapi, Sanity ou Supabase, et je construis des API REST, des espaces membres avec authentification, des e-mails automatiques et des documents PDF.

Identité & UI/UX

Je dessine des maquettes UI/UX et des identités visuelles, jusqu'au guide graphique.

Multilingue & accessible

Je réalise des sites jusqu'à 7 langues, dont l'arabe de droite à gauche, pensés pour tous les publics et tous les écrans.

Infrastructure & mise en ligne

J'utilise Docker, Linux et l'intégration continue, je protège les formulaires (anti-spam, en-têtes de sécurité) et je déploie sur Vercel, Netlify ou Railway.

Du code et du design que j'ai construits de bout en bout. Chaque pièce montre le vrai code, et ce qu'il produit juste à côté.

Laserbeni

Le sélecteur de zones en 3D

Sur la page des tarifs, la cliente touche une zone d'un corps en 3D et voit son prix. Le corps de départ vient d'une bibliothèque libre : tout ce qui le rend utilisable, je l'ai pensé et écrit.

  • Three.js
  • GLSL
  • Raycasting
  • 24 zones

Le code affiché est celui de cette démonstration. Sur le site de Laserbeni, le sélecteur va plus loin : dos, visage, forfaits et réservation.

Développeur full-stack avec une base solide en réseaux et télécommunications. Je conçois et livre des plateformes web multilingues (Next.js, React, TypeScript, Node.js), de la base de données à l'interface, avec des tests, un déploiement automatisé et la sécurité pensés dès le départ.

Front-end

  • Next.js
  • React
  • TypeScript
  • Tailwind CSS
  • Three.js
  • Framer Motion
  • Responsive
  • UX / UI design

Back-end & données

  • Node.js
  • API REST
  • Strapi
  • Supabase
  • Sanity
  • PostgreSQL
  • Authentification & espaces membres
  • Multilingue, jusqu'à 7 langues

Qualité & livraison

  • Tests automatisés
  • GitHub Actions (CI/CD)
  • Docker
  • Cloudflare Pages
  • Vercel
  • Netlify
  • PWA

Sécurité & fiabilité

  • Validation côté serveur
  • Anti-spam (Turnstile)
  • En-têtes de sécurité
  • Linux
  • SSH · Tailscale
  • Réseaux et télécoms

Développement assisté par IA

  • Code relu et testé
  • Codage agentique
  • Workflows natifs IA
  • Intégration de LLM (Groq, OpenAI)

Langues & transmission

  • Allemand (C1)
  • Français (natif)
  • Anglais (B1)
  • Pédagogie
  • Création de supports

Homelab

Un cluster personnel (ZimaBoard, Raspberry Pi) où j'héberge mes propres services avec Docker, pour tester en vrai ce que je mets en ligne.

Écris-moi ou planifie un échange : on en parle autour d'un café (virtuel).

❯ write --to castoleBerlin --:--

Salut, c'est Castole ! Dis-moi ce que tu as en tête : je lis chaque message moi-même.

Vos informations servent uniquement à vous répondre.

Vous préférez écrire directement ?M'écrire sur LinkedIn

Castole Agossou Yao© 2026 Castole Agossou YaoMentions légalesConfidentialitéRetour en haut