If you've been putting off rebuilding your portfolio because it feels like a weekend project that always gets pushed to the following month, I understand. Mine sat on Vue 2 and CosmicJS for years. It still worked, but it carried the kind of technical debt that makes you hesitate every time someone asks for the repository.
This is the story of how I finally rebuilt it from scratch, why I made certain architectural decisions, and why I ended up open-sourcing it as a reusable Nuxt 4 + Strapi portfolio starter.
The Problem With Most Developer Portfolios
Here's a pattern I've noticed: developers spend a lot of energy on the initial build, then abandon the thing entirely. The portfolio accumulates outdated tech, unmaintained dependencies, and content that no longer reflects who they are.
The reasons are usually the same:
- No CMS — updating content requires a code change, a commit, a deploy. That friction kills momentum.
- Outdated stack — Vue 2, Create React App, Gatsby v2, Jekyll. Not inherently bad, but try explaining that to a recruiter who opens DevTools.
- Poor SEO baseline — client-side rendered apps with no server-rendered meta tags, missing OG images, no sitemap. Google does eventually figure it out, but you're fighting it rather than working with it.
- Hard to reuse — most portfolios are so tightly coupled to the original author's content that forking one means untangling dozens of hardcoded strings and personal data.
My old portfolio (github.com/hbollon/portfolio-vuejs) was a good project at the time. Vue 2, CosmicJS as the CMS. It worked. But every time I wanted to update my bio or add a new project, I found myself doing more than I wanted to do just to push a change. And the codebase had grown organically in ways that made it uncomfortable to share.
I wanted something different: a portfolio I'd actually enjoy maintaining, built on a stack I use professionally, with real content management, and structured so someone else could fork it and use it without major refactoring.
The Solution: An Open-Source Nuxt 4 + Strapi Portfolio Starter
GitHub: github.com/hbollon/portfolio-nuxt
Live demo: hugobollon.dev
The project is a statically generated developer portfolio built with Nuxt 4 and Strapi v5, fully open source under the MIT license. It's designed to be a working starting point, not just an inspiration screenshot. You fork it, point it at your own Strapi instance, fill in your content, and deploy.
Key Features
- Nuxt 4 with SSG — fully pre-rendered HTML, deployed to S3 + CloudFront. No server runtime in production. Excellent Lighthouse scores by default.
- Strapi v5 as headless CMS — content is fetched at build time only. Strapi is never exposed to end users. You get a full admin interface to manage projects, experience, education, and bio without touching code.
-
EN/FR internationalization — built-in support for two locales with
@nuxtjs/i18n. UI strings live in local JSON files; editorial content (bio, projects, experience descriptions) is translated inside Strapi. - Space/cosmos dark theme — custom design system with a consistent token palette (deep blacks, nebula purples, electric blues). Glassmorphism cards, particle backgrounds via tsparticles, scroll-triggered animations.
-
Full SEO setup — automatic sitemap, hreflang for each locale, Open Graph and Twitter Card tags, JSON-LD structured data, canonical URLs. Everything handled by a
useSeo()composable, configurable per-page from Strapi. - Umami analytics — privacy-friendly analytics as an alternative to Google Analytics. GDPR-compliant by design.
- CI/CD with GitHub Actions + AWS OIDC — no static AWS credentials. The deployment workflow assumes an IAM role via OIDC token, syncs the build output to S3, and invalidates CloudFront.
- Fallback content mode — if you want to run it locally without a Strapi instance, the build falls back to static content files. Useful for quickly evaluating the template.
Technical Highlights
Strapi consumed at build time only
This is a constraint I deliberately chose, and it simplifies a lot of things. During nuxt generate, Nuxt fetches all content from Strapi's REST API. The output is static HTML. No runtime API calls. No CORS configuration. No authentication tokens in the browser. Strapi stays entirely behind the build process.
This also means the content types are designed with forward compatibility in mind. Projects already have slug, fullDescription, and an SEO component which make it ready for detail pages when I decide to implement them, without any schema migration.
Custom Strapi composable instead of a module
Rather than pulling in @nuxtjs/strapi, I wrote a lightweight useStrapi() composable that wraps Nuxt's useFetch / useAsyncData with the API token passed as a header. It's about 50 lines of TypeScript, it does exactly what the project needs, and it avoids shipping authentication code that will never run.
Strapi v5 uses a flat API response format (no data.attributes wrapping), and the types in shared/types/strapi.ts reflect that directly. TypeScript strict mode is enforced so no any and no index access without guards.
Tailwind v4 configured purely in CSS
No tailwind.config.ts. Tailwind v4 uses @tailwindcss/vite as a Vite plugin and is configured via @theme blocks in app/assets/css/main.css. All design tokens (colors, gradients, shadows, animations) are CSS custom properties. Tailwind generates utility classes from them automatically.
@theme {
--color-space-black: #0a0a0f;
--color-nebula-purple: #a855f7;
--shadow-glow-purple: 0 0 20px rgba(168, 85, 247, 0.5);
--animate-float: float 6s ease-in-out infinite;
}
This approach is cleaner, more composable, and doesn't require a JavaScript file for design decisions.
Performance-first by design
SSG eliminates server response time. The critical rendering path targets under 75kb of gzipped JavaScript (Nuxt + Vue + app code + i18n). Tsparticles and analytics are loaded client-only and deferred after hydration. The site is fully readable without JavaScript — animations and interactivity layer on top.
Lighthouse targets: Performance > 95 on desktop, > 90 on mobile. Accessibility 100. SEO 100.
i18n architecture — hybrid approach
UI strings (nav labels, button text, static messages) live in local JSON files consumed by @nuxtjs/i18n. Editorial content (project descriptions, bio, job titles) is translated inside Strapi using its built-in i18n plugin. The build fetches each locale separately and generates separate URL paths: English at /, French at /fr/.
This hybrid approach avoids duplicating every editorial string in JSON files while still keeping UI strings version-controlled and reviewable in the codebase.
Infrastructure
Production runs on AWS: static output synced to a private S3 bucket, served through CloudFront with Origin Access Control. Strapi runs on an ARM EC2 instance (t4g.micro) behind Caddy, containerized with Docker Compose. Media uploads go to a separate S3 bucket served via a second CloudFront distribution. All infrastructure is Terraform-managed.
The CI/CD pipeline uses AWS OIDC which means no static credentials and no AWS_ACCESS_KEY_ID stored as a secret. The GitHub Actions workflow assumes an IAM role via OIDC token, which I consider a baseline security requirement for any workflow touching cloud resources.
Demo & Links
- Live portfolio: hugobollon.dev
- GitHub repository: github.com/hbollon/portfolio-nuxt
If this project saved you some time or inspired your own build, a star on GitHub helps more than you might think — it signals to others that the project is worth looking at, and it keeps me motivated to improve it.
Why Open Source
Open source has always been a big part of how I work and build things. I maintain and contribute to several projects, including go-edlib (edit distance algorithms in Go) and jgo (a DAG JSON parsing library for Go). Sharing tools, improving them in public, and making them useful to others is something I naturally gravitate toward.
This project follows the same logic. I built on top of countless open-source tools, and publishing it felt like a natural way to give something back. When I started, I couldn’t find a modern Nuxt 4 + Strapi portfolio that was both production-ready and reusable. Most examples were either too minimal or too tied to a specific personal setup.
There’s also a practical benefit: public code tends to be better code. Knowing that someone might fork this and rely on it pushes me to keep things clean, documented, and maintainable.
If you build something with it, I’d genuinely like to hear about it. Open an issue, leave a comment, or reach out.
Getting Started
git clone https://github.com/hbollon/portfolio-nuxt.git
cd portfolio-nuxt
cp .env.example .env.local
# Set STRAPI_URL, STRAPI_TOKEN, NUXT_PUBLIC_SITE_URL
# Leave blank to use fallback content for local evaluation
yarn install
yarn run dev
The Strapi data model is documented in specs/strapi-data-model.md. Content types, field definitions, required relations — it's all there.
Conclusion
Rebuilding a portfolio is one of those tasks that's easy to justify putting off indefinitely. I finally did it because I had a clear goal: a maintainable stack, real content management, solid SEO, and something useful to other developers.
The result is a Nuxt 4 + Strapi portfolio starter that I actually use in production, with deployment infrastructure I'd put in front of real traffic.
If you're building your own portfolio, fork it, adapt it, break it, improve it. That's what it's there for.
github.com/hbollon/portfolio-nuxt — contributions and feedback are welcome!

Top comments (0)