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-portfoliouses a headless CMS architecture in TypeScript around Strapi 5, PostgreSQL, and theusers-permissions,cloudinary, andcolor-pickerplugins, which establishes an extensible content back office. Source: cms-portfolio/package.jsonCode 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:
projectis localized, versioned withdraftAndPublish, and linked to technologies;professional-experienceandskillare also localized and structured to expose experience, skills, rating, and business relations. Source: cms-portfolio/src/api/project/content-types/project/schema.jsonSource: [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
"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" } }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-astroprovides 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.jsonCode 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
projectlifecycle 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.tsCode 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=trueand setsSEED_IN_PROGRESSduring execution, which provides an automated initialization path without mixing seed operations and public rebuilds. Source: cms-portfolio/src/index.tsCode 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].astroCode 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.tsCode 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
alternateandcanonicaltags per content item and locale, which anchors multilingual SEO directly in the page structure. Source: portfolio-website-astro/src/layouts/BaseLayout.astroCode 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
scpandssh, including container rebuilds and Docker cleanup before restart. Source: cms-portfolio/.github/workflows/deploy.ymlCode 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
pushorrepository_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.ymlSource: [portfolio-website-astro/.github/workflows/green-audit.yml](https://github.com/open-repos/website-portfolio/blob/ba9c02e0f84241d75bce7d2aee72ac3c106bd236/.github/workflows/green-audit.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@v1Code 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) andcms-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.jsonCode 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.jsonCode 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.jsonCode 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" }
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