portfolio

portfolio

Full stack portfolio platform built on a Strapi 5 headless CMS and an Astro 5 website, with bilingual fr/en content, CMS-triggered frontend rebuilds, and quality/performance workflows. The project industrializes delivery with Docker, GitHub Actions, and an automated chain between Strapi publication and Astro redeployment.

🎯 Context and goals

  • Separate content administration from public delivery so the editing surface remains flexible while the website stays static and easy to deploy.
  • Publish a bilingual portfolio with static routes, translation fallback when a locale is missing, and technology taxonomies that can be surfaced in the UI.
  • Industrialize delivery with Docker, GitHub Actions, and an automated chain between Strapi publication and Astro redeployment.

πŸ› οΈ Deliverables

🧩 Design

  • cms-portfolio uses a headless CMS architecture in TypeScript around Strapi 5, PostgreSQL, and the users-permissions, cloudinary, and color-picker plugins, which establishes an extensible content back office. Source: cms-portfolio/package.json
    Code snippet
    "dependencies": {
      "@strapi/plugin-cloud": "5.31.3",
      "@strapi/plugin-color-picker": "5.31.3",
      "@strapi/plugin-users-permissions": "5.31.3",
      "@strapi/provider-upload-cloudinary": "5.31.3",
      "@strapi/strapi": "5.31.3",
      "pg": "8.8.0",
      "react": "18.3.1",
      "react-dom": "18.3.1",
      "react-router-dom": "6.30.1",
      "styled-components": "6.1.18"
    },
    "devDependencies": {
      "@strapi/types": "^5.13.0",
      "@strapi/upgrade": "5.31.3",
      "typescript": "^5"
    }
    
  • The CMS data model is genuinely portfolio-oriented: project is localized, versioned with draftAndPublish, and linked to technologies; professional-experience and skill are also localized and structured to expose experience, skills, rating, and business relations. Source: cms-portfolio/src/api/project/content-types/project/schema.json
    Code snippet
    "options": {
      "draftAndPublish": true
    },
    "pluginOptions": {
      "i18n": {
        "localized": true
      }
    },
    "attributes": {
      "title": {
        "type": "string",
        "pluginOptions": {
          "i18n": {
            "localized": true
          }
        }
      },
      "slug": {
        "type": "uid",
        "required": true,
        "targetField": "title"
      },
      "technologies": {
        "type": "relation",
        "relation": "manyToMany",
        "target": "api::technology.technology",
        "inversedBy": "projects"
      }
    }
    
    Source: [cms-portfolio/src/api/professional-experience/content-types/professional-experience/schema.json](https://github.com/open-repos/cms-portfolio/blob/d9de1ec6ca5862e6cfbf50d3d1b0aa60e51aa9fb/src/api/professional-experience/content-types/professional-experience/schema.json)
    Code snippet
    "attributes": {
      "start_date": {
        "type": "date",
        "required": true
      },
      "missions": {
        "type": "richtext",
        "pluginOptions": {
          "i18n": {
            "localized": true
          }
        },
        "required": true
      },
      "technologies": {
        "type": "relation",
        "relation": "manyToMany",
        "target": "api::technology.technology",
        "pluginOptions": {
          "i18n": {
            "localized": true
          }
        },
        "inversedBy": "professional_experiences"
      },
      "resume": {
        "type": "richtext",
        "required": true
      }
    }
    
  • portfolio-website-astro provides the public layer with Astro 5, TypeScript, Tailwind 4, marked, highlight.js, lint/format Git hooks, and Lighthouse audits, which results in a static frontend with explicit quality tooling. Source: portfolio-website-astro/package.json
    Code snippet
    "scripts": {
      "dev": "astro dev",
      "build": "astro build",
      "prepare": "husky",
      "format:check": "prettier --check \"**/*.{astro,ts,js,css,md}\" --ignore-path .prettierignore",
      "astro:check": "astro check",
      "lint": "eslint \"src/**/*.{astro,ts,js}\" --ignore-path .eslintignore",
      "audit": "npm run build && lhci autorun",
      "lint-staged": "lint-staged"
    },
    "dependencies": {
      "@lucide/astro": "^0.542.0",
      "astro": "^5.7.6",
      "highlight.js": "^11.11.1",
      "marked": "^15.0.11",
      "marked-highlight": "^2.2.2",
      "typescript": "^5.8.3"
    },
    "devDependencies": {
      "@lhci/cli": "^0.14.0",
      "tailwindcss": "^4.1.7"
    }
    
  • Frontend i18n is designed as a typed contract with explicit supported locales, which reduces drift between translations and UI behavior. Source: portfolio-website-astro/src/utils/i18n.ts
    Code snippet
    import type { Translations } from '../types/translations';
    import fr from '../locales/fr.json';
    import en from '../locales/en.json';
    
    export const translations = {
      fr,
      en,
    } as const satisfies Record<string, Translations>;
    
    export type SupportedLocale = keyof typeof translations;
    
    export const supportedLocales: SupportedLocale[] = Object.keys(translations) as SupportedLocale[];
    
    export function useTranslations(lang: SupportedLocale): Translations {
      return translations[lang];
    }
    

πŸ’» Development

  • Backend :
  • A generic lifecycle handler factory synchronizes non-localized fields, selects a source locale, and merges relations by identifier when a translation is created, which addresses a real CMS i18n consistency problem. Source: cms-portfolio/src/utils/i18n-sync.ts
    Code snippet
    export const createI18nLifecycleHandlers = <T extends CTUID>(
      config: I18nSyncConfig<T>,
      opts?: { rebuild?: (s: Core.Strapi) => Promise<void> }
    ) => {
      const { uid, populate, fields, rebuildOnPublish = true } = config;
    
      return {
        async beforeCreate(event: any) {
          const { data } = event.params;
          const targetLocale: string | undefined = data?.locale;
          if (!targetLocale) return;
    
          const pop = normalizePopulate(uid, populate);
          let original: any = null;
    
          if (data?.documentId) {
            original = await findOriginal(uid, data.documentId, targetLocale, pop);
          }
    
          if (!original) return;
    
          for (const f of fields.copyIfMissing ?? []) {
            if (data[f] === undefined && original?.[f] !== undefined) data[f] = original[f];
          }
          for (const f of fields.mergeRelationsById ?? []) {
            const srcIds = Array.isArray(original?.[f])
              ? original[f].map((t: any) => (typeof t === 'object' ? t?.id : t)).filter((v: any) => typeof v === 'number')
              : [];
            const reqIds = relInputToIds(data?.[f]);
            const merged = Array.from(new Set<number>([...srcIds, ...reqIds]));
            if (merged.length) data[f] = { set: merged };
          }
        },
      };
    };
    
  • The project lifecycle copies technologies, image, slug, and publication date from the source locale, then triggers an Astro rebuild after publication, directly connecting content administration to public delivery. Source: cms-portfolio/src/api/project/content-types/project/lifecycles.ts
    Code snippet
    export default {
      async afterCreate(event) {
        if (event.result.publishedAt) {
          await triggerAstroRebuild(strapi);
        }
      },
      async afterUpdate(event) {
        if (event.result.publishedAt) {
          await triggerAstroRebuild(strapi);
        }
      },
      async beforeCreate(event) {
        const { data } = event.params;
    
        if (data.locale && data.documentId) {
          const original = await strapi.db.query('api::project.project').findOne({
            where: {
              documentId: data.documentId,
              locale: { $ne: data.locale },
            },
            populate: ['technologies', 'image'],
          });
    
          if (original) {
            if (original.technologies?.length) {
              data.technologies = {
                connect: original.technologies.map((tech) => ({ id: tech.id })),
              };
            }
            if (original.image?.id) data.image = original.image.id;
            if (original.slug) data.slug = original.slug;
            if (original.pubDate) data.pubDate = original.pubDate;
          }
        }
      },
    };
    
  • Strapi bootstrap runs a seed only when SEED_ON_BOOT=true and sets SEED_IN_PROGRESS during execution, which provides an automated initialization path without mixing seed operations and public rebuilds. Source: cms-portfolio/src/index.ts
    Code snippet
    export default {
      async bootstrap({ strapi }: { strapi: Core.Strapi }) {
        if (process.env.SEED_ON_BOOT !== 'true') return;
    
        process.env.SEED_IN_PROGRESS = 'true';
        try {
          strapi.log.info('[seed] start');
          await runSeed(strapi);
          strapi.log.info('[seed] done');
        } finally {
          delete process.env.SEED_IN_PROGRESS;
        }
      },
    };
    
  • Middleware configuration enables an explicit CSP, whitelists the media origins required by Cloudinary, and configures CORS, showing that HTTP security is handled in application code rather than left implicit. Source: cms-portfolio/config/middlewares.ts
    Code snippet
    export default [
      'strapi::logger',
      'strapi::errors',
      {
        name: 'strapi::security',
        config: {
          contentSecurityPolicy: {
            useDefaults: true,
            directives: {
              'connect-src': ["'self'", 'https:'],
              'img-src': ["'self'", 'data:', 'blob:', 'res.cloudinary.com'],
              'media-src': ["'self'", 'data:', 'blob:', 'res.cloudinary.com'],
              upgradeInsecureRequests: null,
            },
          },
        },
      },
      {
        name: 'strapi::cors',
        config: {
          origin: ['*'],
          methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
          headers: '*',
          keepHeadersOnError: true,
        },
      },
    ];
    
  • Frontend :
  • The dynamic project route fetches Strapi content by slug, populates the required media and relations, and falls back to another supported locale when the requested translation does not exist. Source: portfolio-website-astro/src/pages/[lang]/projects/[slug].astro
    Code snippet
    const buildProjectUrl = (locale: SupportedLocale) => {
      const requestUrl = new URL(`${baseUrl}/api/projects`);
      requestUrl.searchParams.append('filters[slug][$eq]', String(slug));
      requestUrl.searchParams.append('locale', String(locale));
      requestUrl.searchParams.append('populate[image]', 'true');
      requestUrl.searchParams.append('populate[localizations]', 'true');
      requestUrl.searchParams.append('populate[technologies][populate][icon]', 'true');
      requestUrl.searchParams.append('populate[technologies][populate][technology_type]', 'true');
      return requestUrl;
    };
    
    const currentLocaleProject = await fetchProjectForLocale(lang);
    let project: ProjectAPIItem | undefined = currentLocaleProject;
    
    if (!project) {
      const fallbackLocales = supportedLocales.filter((locale) => locale !== lang);
      for (const fallbackLocale of fallbackLocales) {
        const fallbackProject = await fetchProjectForLocale(fallbackLocale);
        if (fallbackProject) {
          availableLocale = fallbackLocale;
          availableLocaleProject = fallbackProject;
          break;
        }
      }
    }
    
  • Project navigation implements client-side cross-filtering and pagination from data-* attributes, with dynamic disabling of incompatible options and page count recomputation. Source: portfolio-website-astro/src/utils/paginatedFilterList.ts
    Code snippet
    export function initPaginatedFilterLists() {
      const roots = document.querySelectorAll<HTMLElement>('[data-list-root]');
    
      roots.forEach((root) => {
        const items = Array.from(root.querySelectorAll<HTMLElement>('[data-list-item]'));
        const typeCheckboxes = Array.from(
          root.querySelectorAll<HTMLInputElement>('[data-filter-type-checkbox]')
        );
        const techCheckboxes = Array.from(
          root.querySelectorAll<HTMLInputElement>('[data-filter-tech-checkbox]')
        );
    
        const applyState = () => {
          let selectedTypes = getCheckedValues(typeCheckboxes);
          let selectedTechs = getCheckedValues(techCheckboxes);
    
          const itemsForTech = items.filter((item) => {
            const itemTypes = splitDatasetValues(item.dataset.techTypes);
            return hasAnyMatch(itemTypes, selectedTypes);
          });
          const allowedTechs = collectValuesFromItems(itemsForTech, 'techNames');
          setCheckboxAvailability(techCheckboxes, allowedTechs);
    
          const filteredItems = items.filter((item) => {
            const itemTypes = splitDatasetValues(item.dataset.techTypes);
            const itemTechs = splitDatasetValues(item.dataset.techNames);
            return hasAnyMatch(itemTypes, selectedTypes) && hasAnyMatch(itemTechs, selectedTechs);
          });
    
  • The shared layout generates alternate and canonical tags per content item and locale, which anchors multilingual SEO directly in the page structure. Source: portfolio-website-astro/src/layouts/BaseLayout.astro
    Code snippet
    const alternateLinks: AlternateLink[] =
      entity && contentType && slug
        ? [
            { href: `${baseUrl}/${currentLang}/${contentType}/${slug}`, hreflang: currentLang },
            ...otherLangs.map((loc) => ({
              href: `${baseUrl}/${loc.locale}/${contentType}/${slug}`,
              hreflang: loc.locale,
            })),
            { href: `${baseUrl}/${contentType}/${slug}`, hreflang: 'x-default' },
          ]
        : [];
    ---
    
    <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>{title || 'Mon Portfolio'}</title>
      <meta name="description" content={description} />
      {
        alternateLinks.map((link) => (
          <link rel="alternate" href={link.href} hreflang={link.hreflang} />
        ))
      }
      {
        entity && contentType && slug && (
          <link rel="canonical" href={`${baseUrl}/${currentLang}/${contentType}/${slug}`} />
        )
      }
    </head>
    

πŸ—οΈ DevOps & Quality

  • The CMS includes production Docker tooling and a VM deployment workflow based on scp and ssh, including container rebuilds and Docker cleanup before restart. Source: cms-portfolio/.github/workflows/deploy.yml
    Code snippet
    on:
      push:
        branches: [ master ]
    
    jobs:
      deploy:
        runs-on: ubuntu-latest
        steps:
          - name: Copy files via SCP
            uses: appleboy/scp-action@v0.1.7
          - name: Rebuild and restart Strapi on VM
            uses: appleboy/ssh-action@v1.0.0
            with:
              script: |
                cd /var/www/strapi-portfolio
                docker compose -f docker-compose.prod.yml down --remove-orphans || true
                docker builder prune -af || true
                docker image prune -af || true
                docker compose -f docker-compose.prod.yml build --pull --force-rm || { echo "Build failed"; exit 1; }
                docker compose -f docker-compose.prod.yml up -d || { echo "Compose up failed"; exit 1; }
    
  • The frontend automates build and deployment on push or repository_dispatch, and it adds dedicated workflows for Lighthouse, GreenFrame, Ecoindex, ESLint, and Prettier to enforce quality, performance, and environmental checks. Source: portfolio-website-astro/.github/workflows/deploy.yml
    Code snippet
    on:
      push:
        branches: [main]
      repository_dispatch:
        types: [strapi-content-update]
    jobs:
      build-and-deploy:
        runs-on: ubuntu-latest
        steps:
          - name: Install dependencies
            run: npm ci
          - name: Build site
            run: npm run build
            env:
              PUBLIC_SITE_URL: ${{ secrets.PUBLIC_SITE_URL }}
              PUBLIC_STRAPI_URL: ${{ secrets.PUBLIC_STRAPI_URL }}
              STRAPI_API_TOKEN: ${{ secrets.STRAPI_TOKEN }}
          - name: Deploy to VM via SSH
            uses: appleboy/scp-action@v1
    
    Source: [portfolio-website-astro/.github/workflows/green-audit.yml](https://github.com/open-repos/website-portfolio/blob/ba9c02e0f84241d75bce7d2aee72ac3c106bd236/.github/workflows/green-audit.yml)
    Code snippet
    jobs:
      greenframe:
        runs-on: ubuntu-latest
        env:
          PAGES_TO_TEST: / /fr/projects /en/projects
        steps:
          - name: Build the site
            run: npm run build
          - name: Run GreenFrame on local build
            if: github.ref == 'refs/heads/develop'
            run: |
              for PAGE in $PAGES_TO_TEST; do
                greenframe analyze "http://localhost:4321$PAGE" --threshold=0.045
              done
          - name: Run GreenFrame on production
            if: github.ref == 'refs/heads/main'
            run: |
              for PAGE in $PAGES_TO_TEST; do
                SITE_URL="${PUBLIC_SITE_URL%/}"
                greenframe analyze "$SITE_URL$PAGE" --threshold=0.045
              done
    

πŸ“ˆ Results

  • Based on the local Git history observed between April 27, 2025 and March 5, 2026, the work accounts for 136 commits split between portfolio-website-astro (83) and cms-portfolio (53), across 3 unique Git authors. The concrete outcome is an end-to-end delivery chain from editable Strapi content to a built and deployed Astro site.
  • The technical benefit is visible in the code itself: CMS publication can trigger a frontend rebuild, the website serves two locales with translation fallback, and workflows automate quality gates, deployment, and part of the performance and environmental audits.

πŸ”§ Technical environment

  • Backend: Strapi 5, TypeScript, PostgreSQL through pg, React-based Strapi admin, @strapi/plugin-users-permissions, @strapi/provider-upload-cloudinary, @strapi/plugin-color-picker, lifecycle hooks, seed bootstrap, and security middleware. Source: cms-portfolio/package.json
    Code snippet
    "dependencies": {
      "@strapi/plugin-cloud": "5.31.3",
      "@strapi/plugin-color-picker": "5.31.3",
      "@strapi/plugin-users-permissions": "5.31.3",
      "@strapi/provider-upload-cloudinary": "5.31.3",
      "@strapi/strapi": "5.31.3",
      "pg": "8.8.0",
      "react": "18.3.1",
      "react-dom": "18.3.1"
    }
    
  • Frontend: Astro 5, TypeScript, Tailwind CSS 4, marked, marked-highlight, highlight.js, @lucide/astro, PostCSS, ts-node, dotenv, client-side pagination/filtering, multilingual SEO, and static generation. Source: portfolio-website-astro/package.json
    Code snippet
    "dependencies": {
      "@astrojs/check": "^0.9.4",
      "@lucide/astro": "^0.542.0",
      "astro": "^5.7.6",
      "highlight.js": "^11.11.1",
      "marked": "^15.0.11",
      "marked-highlight": "^2.2.2",
      "typescript": "^5.8.3"
    },
    "devDependencies": {
      "@tailwindcss/postcss": "^4.1.7",
      "dotenv": "^16.5.0",
      "postcss": "^8.5.3",
      "tailwindcss": "^4.1.7",
      "ts-node": "^10.9.2"
    }
    
  • DevOps and quality: multi-stage Docker, dev/prod docker-compose, GitHub Actions, Husky, lint-staged, ESLint, Prettier, Lighthouse CI, GreenFrame, and Ecoindex. Source: portfolio-website-astro/package.json
    Code snippet
    "scripts": {
      "prepare": "husky",
      "format:check": "prettier --check \"**/*.{astro,ts,js,css,md}\" --ignore-path .prettierignore",
      "astro:check": "astro check",
      "lint": "eslint \"src/**/*.{astro,ts,js}\" --ignore-path .eslintignore",
      "audit": "npm run build && lhci autorun",
      "lint-staged": "lint-staged"
    },
    "devDependencies": {
      "@lhci/cli": "^0.14.0",
      "eslint": "^8.57.1",
      "husky": "^9.1.7",
      "lint-staged": "^15.5.1",
      "prettier": "^3.6.2",
      "stylelint": "^16.19.1"
    }
    
🌐 View the project

Tech Stack

Frontend
Astro
React
Tailwind CSS
TypeScript
DevOps
Docker
docker-compose
GitHub Actions
Qualite / Tests
ESLint
Prettier
Bases de donnees (SGBD & SQL)
PostgreSQL
Backend
Strapi