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.

Next.js 16Strapi 5TypeScriptTailwind CSSPostgreSQL

zielscheibe-nachhilfe.de

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).

Next.js 14Tailwind CSSFramer MotionPWA

ndmc-nikki.netlify.app

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.

Next.js 14Tailwind CSSThree.jsGSAPLenis

questpocket.netlify.app

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(

    After

    /* Scroll-driven reveal animations: CSS only, no JS needed */
    .reveal {
      animation: revealUp 0.

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
In progress

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.

Next.jsTailwind CSSSanityThree.jsPWA

Delivery end of October 2026

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

  • PWA

    installable on iOS and Android

  • 1

    booking flow, never leaving the app

Delivery end of October 2026

Websites & platforms

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

CMS & back-end

Content you can manage without a developer with Strapi, Sanity or Supabase, REST APIs, member areas with authentication, automatic e-mails and PDF documents.

Identity & UI/UX

Visual identity, UI/UX mockups and style guides, then marketing material: flyers, brochures and social-media visuals.

Multilingual & accessible

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

Infrastructure & deployment

Docker containers, Linux servers, continuous integration, anti-spam protection and security headers, deployment on Vercel, Netlify or Railway.

The confirmation e-mails sent by my contact form, designed and coded by hand: tables, inline styles, CSS animations and a plain-text version. Preview them, then read the real code.

A light card: a pixel logo whose >_ types itself, a staggered reveal, the request recap and a numbered timeline.

This is the real e-mail the form sends, with sample data, in the language of the page. Animations play in Apple Mail and iOS Mail; Gmail, Yahoo and Outlook show the same e-mail without motion. The real source files of the repository, split by role (template, sections, styles, theme): the same code builds the preview above and the e-mail sent to visitors. The style is chosen with one variable, CONFIRMATION_STYLE.

Full-stack developer with a solid background in networking and telecommunications. I build modern web platforms (Next.js, React, TypeScript), set up the infrastructure that runs them and apply current technologies to real projects, from the CMS to the interface.

Front-end

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

Back-end & CMS

  • Strapi
  • Supabase
  • Sanity
  • PostgreSQL
  • REST API
  • SEO (basics)

Infrastructure & networks

  • Docker
  • Portainer
  • Linux (Kali)
  • SSH · Tailscale
  • CI/CD
  • Networks & IT security
  • Fiber optics

AI-native development

  • Agentic coding
  • AI-assisted development
  • AI-native workflows
  • LLM integration (Groq, OpenAI)

Teaching & languages

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

Homelab

Personal cluster (ZimaBoard, Raspberry Pi) hosting my own services, managed with Docker and Portainer and automated CI/CD pipelines.

Together we can create something clear and impactful. Book a slot or write to me: 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.

Castole Agossou Yao© 2026 Castole Agossou YaoBack to top