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

Preview of the Zielscheibe Nachhilfe project
Live

Zielscheibe Nachhilfe

Tutoring and learning-coaching platform in Essen, the Ruhr region and online: aid applications (BuT), contracts with digital signature and PDF, online appointments and a members' area, in 7 languages including Arabic.

7
languages, with Arabic right-to-left
2,026
automated tests (987 front end, 1,039 back end)
975
commits, with continuous integration
Next.js 16Strapi 5TypeScriptTailwind CSSPostgreSQL

zielscheibe-nachhilfe.de

Visit the site

Case study

Zielscheibe Nachhilfe

Tutoring and learning-coaching platform in Essen, the Ruhr region and online: aid applications (BuT), contracts with digital signature and PDF, online appointments and a members' area, in 7 languages including Arabic.

My role. Design and full development: front end, CMS, member area, contracts and deployment.

Next.js 16Strapi 5TypeScriptTailwind CSSPostgreSQL

Challenges & solutions

  1. 01 · Problem

    Families speak Arabic, Turkish, Ukrainian, Russian… and the contracts for some of them came out blank.

    Solution

    7 languages with full right-to-left support, Latin-script names accepted for every family, and a PDF generator embedding the right fonts so every contract is complete.

    frontend/lib/pdf-contract-generator.ts

    Before

    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);
      // …

    After

    /**
     * pdf-lib throws when a field name is missing instead of returning undefined. So
     * `getTextField('A') || getTextField('B')` never reached B, and one renamed field aborted the rest
     * of its block, silently, inside a try/catch, leaving part of a contract or consent form blank.
     */
    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 {
        // Text goes through toWinAnsi: the form's Helvetica cannot draw "ş" or "ı", and one such throw
        // used to leave the whole contract blank.
        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 · Problem

    Personal data of children and parents: the law imposes retention limits and a clean deletion.

    Solution

    A complete purge validated by a lawyer, which no longer unsubscribes newsletter readers whose consent is still in force.

    backend/src/services/gdpr-retention.js

    Before

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

    After

    /**
     * A confirmed newsletter subscriber is a consent still in force, not a record of past
     * correspondence: purging on createdAt unsubscribed every reader three years after they signed up,
     * without their asking.
     */
    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 · Problem

    A public site with accounts is a target, and a framework flaw was published while the project was running.

    Solution

    Strict nonce-based Content-Security-Policy rolled out in report-only mode first, CSRF tokens, and an immediate upgrade of Next.js as soon as the high-severity flaws were announced.

    frontend/proxy.ts

    Before

    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;
      // …`

    After

    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);
    }
    
    // Trust scripts by nonce and let them pull their own dependencies (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 · Problem

    When the CMS had a hiccup, pages turned into 404s and the server hit its own rate limits.

    Solution

    Landing pages that stay up when Strapi is down, a server token on every read, and a failed dossier that is no longer regenerated endlessly.

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

    Before

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

    After

    // An unreachable Strapi is remembered, not turned into "not found": the static fallbacks below are
    // still tried, and only a slug none of them knows raises an error (not cached) instead of a 404
    // (cached).
    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 · Problem

    Strapi limits public reads to 120 per minute per IP, and every page render leaves from the same few Vercel addresses: the site's own renders hit the limit and got 429s (team missing from the About page, empty sitemap, opening hours falling back to static content).

    Solution

    Every server call now carries a shared server token that Strapi does not count, kept in a variable without the NEXT_PUBLIC_ prefix so it never reaches a browser bundle. A direct call without the token stays limited.

    frontend/lib/strapi-server.ts

    Code

    // Strapi limits public GETs per IP (120/min, middlewares/rate-limit.js), and every server render
    // leaves from the same few Vercel addresses: the site's own page renders filled that window and got
    // 429s (fallback content, an empty sitemap). The shared server token marks the call as coming from
    // this server, which Strapi does not count. The variable has no NEXT_PUBLIC_ prefix, so it never
    // reaches a browser bundle.
    if (FORM_API_TOKEN && !headers.has('X-Api-Token')) headers.set('X-Api-Token', FORM_API_TOKEN);
  6. 06 · Problem

    A document dossier that kept failing (unreadable attachment, provider down for days) was regenerated and re-uploaded every five minutes without end.

    Solution

    An attempts counter stored on the record: after 5 runs the dossier stays “failed” for the team to look at, and the retry job skips it.

    backend/src/services/document-jobs.js

    Before

    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'],

    After

    /**
     * A dossier that keeps failing (unreadable attachment, provider down for days) used to be
     * regenerated and re-uploaded every five minutes without end. After this many runs it stays
     * "failed" for the team to look at.
     */
    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 · Problem

    Member sessions are stateless tokens valid for 7 days: after a password change, older tokens (a stolen one, a forgotten device) kept working.

    Solution

    The change instant is stored on the user and every token issued before it is refused, floored to the second so the token issued right after the change still works. The new token replaces the cookie, so the member stays signed in. Confirmation tokens are stored hashed.

    backend/src/lib/session-revocation.js

    Code

    /**
     * Sessions end when the password changes. Member JWTs are stateless and valid for up to 7 days.
     * Changing or resetting the password stores the instant in user.passwordChangedAt; every token
     * issued before it (its "iat", in whole seconds) is then refused. The instant is floored to the
     * second so the token issued right after the change, in the same second, still works.
     */
    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;
      // A token without "iat" cannot prove it is newer than the change.
      if (typeof tokenPayload?.iat !== 'number') return true;
      return tokenPayload.iat * 1000 < changedAt;
    }

In numbers

  • 7

    languages, with Arabic right-to-left

  • 2,026

    automated tests (987 front end, 1,039 back end)

  • 975

    commits, with continuous integration

Visit the site
Preview of the École Notre Dame du Mont Carmel project
Live

École Notre Dame du Mont Carmel

Website of the Notre Dame du Mont Carmel school in Nikki (Benin), linked to the FUNDEBE foundation: presentation, practical information, news, school stages and online enrolment, in 4 languages and installable as an app (PWA).

−57%
image weight (6.2 MB down to 2.6 MB)
4
languages
PWA
installable and usable offline
Next.js 14Tailwind CSSFramer MotionPWA

ndmc-nikki.netlify.app

Visit the site

Case study

École Notre Dame du Mont Carmel

Website of the Notre Dame du Mont Carmel school in Nikki (Benin), linked to the FUNDEBE foundation: presentation, practical information, news, school stages and online enrolment, in 4 languages and installable as an app (PWA).

My role. Redesign and full development of the school website, from the design system to deployment.

Next.js 14Tailwind CSSFramer MotionPWA

Challenges & solutions

  1. 01 · Problem

    Texts were hard-coded and the translation library did not work with a static export: the first render was untranslated or failed on hydration.

    Solution

    A hybrid pattern: a server component sets the language before rendering, and a client provider adds react-i18next, so translations work on the server and in the browser.

    frontend/src/i18n/I18nProvider.tsx

    Before

    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>;
    }

    After

    // I18nServerSync.tsx: no "use client": runs on the server only
    export default function I18nServerSync({ locale, children }: { locale: Locale; children: React.ReactNode }) {
      // Force i18next to the correct locale during SSR
      if (i18n.language !== locale) {
        i18n.changeLanguage(locale);
      }
      return <>{children}</>;
    }
    
    // I18nProvider.tsx: client side
    if (!i18next.isInitialized) {
      i18next.use(initReactI18next);
    }
    
    export default function I18nProvider({ children }: { children: ReactNode }) {
      useEffect(() => {
        // Ensure react-i18next is initialized
        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 · Problem

    Form errors stayed in French: with Zod 4, custom messages written inside the schemas bypassed the error map, so they were never translated.

    Solution

    Messages removed from the schemas and one error map that turns field and error type into a translation key, in all 4 languages.

    frontend/src/lib/validations.ts

    Before

    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"),

    After

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

    The photos of the school weighed too much for connections in Benin.

    Solution

    Conversion of every image to WebP, from 6.2 MB down to 2.6 MB (−57%), and an installable PWA with a service worker.

  4. 04 · Problem

    A school site has to be found, read by everyone and understood by search engines.

    Solution

    Sitemap, robots.txt, canonical and hreflang tags, Schema.org data, accessibility work (reduced motion, ARIA) and a design system in the school colours.

In numbers

  • −57%

    image weight (6.2 MB down to 2.6 MB)

  • 4

    languages

  • PWA

    installable and usable offline

Visit the site
Preview of the QuestPocket project
Live

QuestPocket

Landing page of Pocket Quest, a mobile app that turns screen time into rewards: you earn time on social media by completing healthy tasks. Animated 3D pixel-art background, smooth scroll, waitlist and launch countdown, in 3 languages.

3
languages (English, French, German)
6
design iterations in 3 days
3D
pixel-art background in WebGL
Next.js 14Tailwind CSSThree.jsGSAPLenis

questpocket.netlify.app

Visit the site

Case study

QuestPocket

Landing page of Pocket Quest, a mobile app that turns screen time into rewards: you earn time on social media by completing healthy tasks. Animated 3D pixel-art background, smooth scroll, waitlist and launch countdown, in 3 languages.

My role. Design and development of the landing page. The mobile app itself (Flutter, NestJS) is still in its specification phase.

Next.js 14Tailwind CSSThree.jsGSAPLenis

Challenges & solutions

  1. 01 · Problem

    The 3D pixel-art background built with Three.js broke server rendering and caused hydration errors, because Three.js only works in the browser.

    Solution

    The WebGL components are loaded on the client only, with a dynamic import and server rendering turned off, while the rest of the page stays a static export.

    landing-react/src/components/PixelBackground.tsx

    Before

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

    After

    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 · Problem

    Scroll reveals depended on a JavaScript hook that toggled a class: content stayed invisible until hydration, and the smooth-scroll provider did not work in the static export.

    Solution

    Reveals rewritten as pure CSS keyframes with staggered delays, so they work without JavaScript, and Lenis is loaded in an effect once the page is up.

    landing-react/src/app/globals.css

    Before

    .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; }

    After

    /* Scroll-driven reveal animations: CSS only, no JS needed */
    .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);
      }
      /* … */
    }

In numbers

  • 3

    languages (English, French, German)

  • 6

    design iterations in 3 days

  • 3D

    pixel-art background in WebGL

Visit the site
Preview of the Laserbeni project
Live

Laserbeni

Platform and installable app (PWA) for a permanent laser hair-removal studio in Germany: treatment zones and prices, offers, FAQ, before/after, booking integrated via Beautinda, content managed in Sanity and a 3D glass atmosphere.

19
price zones on a 3D body you can touch
100
Lighthouse score for accessibility, best practices and SEO (measured)
PWA
installable on iOS and Android
Next.jsTailwind CSSSanityThree.jsPWA

laserbeni.pages.dev

Visit the site

Case study

Laserbeni

Platform and installable app (PWA) for a permanent laser hair-removal studio in Germany: treatment zones and prices, offers, FAQ, before/after, booking integrated via Beautinda, content managed in Sanity and a 3D glass atmosphere.

My role. Design and development of the platform and the installable app.

Next.jsTailwind CSSSanityThree.jsPWA

Challenges & solutions

  1. 01 · Problem

    Clients must book without leaving the app, but the studio already uses the Beautinda booking system.

    Solution

    Beautinda opens in a modal window inside the PWA, including the customer account to view or move appointments: one single experience.

  2. 02 · Problem

    The audience is on phones and expects an app, without going through an app store.

    Solution

    Mobile-first PWA with manifest, service worker, offline support and install banners for iOS and Android, ready for later native packaging.

  3. 03 · Problem

    Prices, offers and photos change often, and the studio manager is not a developer.

    Solution

    Content managed in Sanity: prices, packs, before/after photos and FAQ are edited without touching the code.

In numbers

  • 19

    price zones on a 3D body you can touch

  • 100

    Lighthouse score for accessibility, best practices and SEO (measured)

  • PWA

    installable on iOS and Android

Visit the site

What the people I build for say about working together.

“Mr. Agossou did not merely build an ordinary website for us from a purchased standard template. Instead, he programmed and designed a fully custom, technically demanding web solution tailored exactly to our business requirements.”

Viktorija MousaOwner, Nachhilfe-Netzwerk ZielscheibeTranslated from German

Websites & platforms

I build websites and applications with Next.js, React, TypeScript and Tailwind CSS: fast, search-friendly and installable as a PWA when it helps.

CMS & back-end

I manage content with Strapi, Sanity or Supabase, and build REST APIs, member areas with authentication, automatic e-mails and PDF documents.

Identity & UI/UX

I design UI/UX mockups and visual identities, down to the style guide.

Multilingual & accessible

I build sites in up to 7 languages, including right-to-left Arabic, designed for every audience and every screen.

Infrastructure & deployment

I work with Docker, Linux and continuous integration, protect forms (anti-spam, security headers) and deploy on Vercel, Netlify or Railway.

Code and design I built from start to finish. Each piece shows the real code, and what it produces right beside it.

Laserbeni

The 3D zone selector

On the price page, the client touches a zone of a 3D body and sees its price. The starting body comes from a free library: everything that makes it usable, I designed and wrote.

  • Three.js
  • GLSL
  • Raycasting
  • 24 zones

The code shown is the one behind this demo. On the Laserbeni site the selector goes further: back, face, packages and booking.

Full-stack developer with a solid background in networking and telecommunications. I design and ship multilingual web platforms (Next.js, React, TypeScript, Node.js), from the database to the interface, with tests, automated deployment and security built in from the start.

Front-end

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

Back-end & data

  • Node.js
  • REST API
  • Strapi
  • Supabase
  • Sanity
  • PostgreSQL
  • Authentication & member areas
  • Multilingual, up to 7 languages

Quality & delivery

  • Automated tests
  • GitHub Actions (CI/CD)
  • Docker
  • Cloudflare Pages
  • Vercel
  • Netlify
  • PWA

Security & reliability

  • Server-side validation
  • Anti-spam (Turnstile)
  • Security headers
  • Linux
  • SSH · Tailscale
  • Networks and telecom

AI-assisted development

  • Reviewed and tested code
  • Agentic coding
  • AI-native workflows
  • LLM integration (Groq, OpenAI)

Languages & teaching

  • German (C1)
  • French (native)
  • English (B1)
  • Pedagogy
  • Teaching materials

Homelab

A personal cluster (ZimaBoard, Raspberry Pi) where I host my own services with Docker, to test for real what I put online.

Write to me or plan a chat: let's talk over a (virtual) coffee.

❯ write --to castoleBerlin --:--

Hi, I'm Castole! Tell me what you have in mind: I read every message myself.

Your details are only used to reply to you.

Prefer to write directly?Message me on LinkedIn

Castole Agossou Yao© 2026 Castole Agossou YaoLegal noticePrivacyBack to top