I’ve spent a long time telling other people how to document their products. It felt overdue to build something that documented my own work clearly. This post is the full story of how I built this portfolio — from template to production — including every technical decision, the problems I hit, and how the AI-augmented workflow I write about actually showed up in practice.
Choosing the base
The first decision was whether to build from scratch or start with a template. I’ve built enough sites to know that the interesting work isn’t in getting a grid to render or a dark mode toggle to persist — it’s in the content, the copy, and the architecture decisions that make the site feel like you rather than like everyone else’s boilerplate.
I chose Astro Rocket, a production-ready Astro 6 theme built on Tailwind CSS v4. The reasons were specific:
- Astro 6 with Content Layer API — content as structured data, not just markdown files thrown in a folder
- Tailwind v4 — CSS-first configuration, OKLCH color tokens, no PostCSS config to wrestle with
- A component library already built — 57 components covering forms, cards, badges, overlays, icons
- Real deployment adapters —
@astrojs/verceland@astrojs/netlifyalready wired in - An icon system I could extend — a unified
Iconcomponent backed by Iconify, mapping short names to Lucide UI icons or Simple Icons brand icons
The critical principle I set before touching anything: don’t change the engine, only change the content. The component architecture, the design system, the routing — those stay intact. What changes is everything that identifies who the site is for.
The identity layer
The template’s single source of truth for identity is src/config/site.config.ts. This file feeds the header, footer, SEO metadata, JSON-LD structured data schemas, RSS feed, and the web manifest. One change here propagates everywhere.
The first pass was straightforward — name, email, URL, social handles, author image path. But there were two places where the template had hardcoded identity that site.config.ts didn’t control:
src/lib/schema.ts — the JSON-LD Person and ProfessionalService schemas had the template author’s name, city, and country hardcoded as string literals. I rewrote both functions to pull from siteConfig, so the structured data stays consistent with everything else:
createPersonSchema() {
return {
'@type': 'Person',
name: siteConfig.name,
jobTitle: 'Docs Engineer & Technical Writer',
address: {
'@type': 'PostalAddress',
addressLocality: siteConfig.address.city,
addressCountry: 'NG',
},
};
}
src/content/authors/team.json — the author profile that gets attached to blog posts. Updated with real bio, real social links, real context.
Small changes, but important. Identity inconsistencies in structured data erode SEO trust in ways that are hard to debug later.
Rewriting the homepage
The homepage is the hardest page to write for yourself. You know too much about what you do, which makes it easy to either undersell it or bury it in jargon.
I organised it around three questions a visitor would actually ask:
- Who is this? — the hero section
- What do they do? — the services section
- Have they done this for anyone I’d trust? — testimonials and projects
The hero
The hero opens with a badge — "AI-Native · Open to Opportunities" — and a typed effect that cycles through: Docs Engineer, Technical Writer, Software Developer, API Doc Specialist, Developer Advocate, Blogger. The headline is "AI-Enabled Docs Engineer & Technical Writer".
The description paragraph was the most rewritten part of the whole page. The goal was to say three things in two sentences: I understand both writing and code, I use AI as a genuine work tool (not a gimmick), and the combination means my documentation is verifiably accurate.
Services (four cards, 2×2 grid)
The template had a generic services section. I restructured it to four specific cards:
- Technical Documentation — API references, developer guides, SDK docs
- Technical Writing — tutorials, concept guides, onboarding flows
- Developer Advocacy & Relations — community content, developer-facing communications, evangelism
- AI-Augmented Delivery — the explicit card that names the AI workflow as a feature, not a footnote
That last card was a deliberate choice. I write about AI in documentation. It would be strange not to make it visible in what I offer.
Testimonials
Four testimonials, named and attributed. The template had placeholder cards. I replaced them with real feedback from real engagements — Kyle Pippin at Boost Security, Toyin Olasehinde at Woodcore, John Fahl at Ayrshare, Damilola Teidi at Paystack. Real names, real roles, real companies.
The about page
The about page had one section I had to think carefully about — the “approach” section, which describes how I work.
The original draft I wrote for it used Claude’s name directly, linked to claude.ai/code. I reconsidered. Naming a specific AI tool in the way that section was written made it sound like a product endorsement rather than a genuine description of how I work. The rewrite shifted the framing:
“I’m AI-augmented — working with AI tools as a genuine thinking partner, not a shortcut. They help me pressure-test explanations, review code, and surface gaps before they reach the reader. The writing stays mine. The standard gets higher.”
The card next to it changed from “Claude” → “AI-Augmented Workflow”, with the external link and the arrow icon removed. The concept stayed exactly the same. The framing became more durable — it describes a working method, not a dependency on a specific product.
Blog posts and the SVG banner system
I added seven blog posts total — three written specifically for this portfolio, four migrated from my dev.to profile.
The template’s blog card component accepts an svgSlug prop. When present, it renders an SVG from src/assets/blog/${slug}.svg via BlogImageSVG.astro, which loads it raw and injects it with set:html. The clever part is how the SVGs are styled: instead of hardcoded colors, they use semantic CSS classes:
<rect width="1200" height="630" class="bg"/>
<g class="ico" opacity="0.9">...</g>
<text class="txt">docs engineer</text>
These classes resolve to brand CSS custom properties in BlogImageSVG.astro:
.svg-host :global(.bg) { fill: var(--brand-500); }
.svg-host :global(.ico) { stroke: var(--brand-50); }
.svg-host :global(.txt) { fill: var(--brand-50); }
The result: every blog banner is automatically theme-aware. Switch the site’s colour theme and all seven banners update instantly — no JavaScript, no build step, no image regeneration. Just CSS custom properties doing what they’re designed to do.
Each banner uses a different Lucide icon to visually distinguish the post categories: book-open, code, clipboard-check, file-code, shield-check, lock, cpu, and monitor.
The SVG glob deprecation warning
While building the banner system, Vite started logging a deprecation warning:
[WARN] [vite] The glob option "as" has been deprecated in favour of "query".
Please update `as: 'raw'` to `query: '?raw', import: 'default'`.
The fix was a one-line change in BlogImageSVG.astro:
// Before
const svgs = import.meta.glob<string>('/src/assets/blog/*.svg', { as: 'raw', eager: true });
// After
const svgs = import.meta.glob<string>('/src/assets/blog/*.svg', { query: '?raw', import: 'default', eager: true });
Not a breaking change, but ignoring deprecation warnings is how you end up with a build that fails after a minor version bump.
Projects
Five project case studies — Woodcore, Boost Security, Ayrshare, Paystack, and Flutterwave. Each lives in src/content/projects/ as an MDX file with frontmatter that controls display:
---
title: "Woodcore"
description: "..."
tags: ["API Documentation", "Banking", "Developer Experience"]
githubUrl: ""
liveUrl: "https://docs.woodcore.co"
featured: true
order: 1
---
The featured flag surfaces the project on the homepage. order controls the sort sequence. The case studies themselves cover what the problem was, what I built, and what the outcome was — written the same way I’d write a technical brief.
The tech stack section
The template’s stack section shipped with Astro, React, Tailwind CSS, TypeScript, and Markdown/MDX. That’s a frontend developer’s stack. I needed a Docs Engineer’s stack.
I kept TypeScript and Markdown/MDX. I removed the rest. Then I added six tools that actually describe how I work:
| Tool | Why it’s here |
|---|---|
| Git | Docs-as-code means documentation lives in version control |
| GitHub | Where docs are reviewed, merged, and shipped from |
| Node.js | The runtime I use to build test integrations and verify code samples |
| Postman | Every API example I publish has been tested here first |
| VS Code | Primary editor for writing, coding, and reviewing in one environment |
| OpenAPI | The spec layer underneath every API reference I write |
Each needed a brand icon. The template’s Icon component maps short names to Iconify entries via an iconMap object. I added five new entries:
'brand-git': 'simple-icons:git',
'brand-node': 'simple-icons:nodedotjs',
'brand-postman': 'simple-icons:postman',
'brand-vscode': 'simple-icons:visualstudiocode',
'brand-openapi': 'simple-icons:openapiinitiative',
GitHub already had an entry (github → simple-icons:github), so that one was free.
The grid is grid-cols-2 md:grid-cols-4 — eight items fills two clean rows on desktop. Six would leave two empty slots in the second row, which looks like an unfinished thought. Eight is the right number.
Deployment: Vercel and the lockfile problem
I deployed to Vercel. The project already had @astrojs/vercel installed, the adapter already configured as the default in astro.config.mjs, and the build command was standard (astro build). Should have been a five-minute setup.
Instead, the first build failed:
ERR_PNPM_OUTDATED_LOCKFILE Cannot install with "frozen-lockfile" because
pnpm-lock.yaml is not up to date with package.json
- @astrojs/vercel (lockfile: ^10.0.0, manifest: ^8.0.4)
- @astrojs/check (lockfile: 0.9.7, manifest: ^0.9.2)
Two things were wrong. First, the pnpm-lock.yaml had been generated with a newer version of @astrojs/vercel than what package.json specified — the template’s lockfile and manifest were out of sync. Vercel’s CI runs pnpm install --frozen-lockfile, which refuses to install when this happens.
Second, @astrojs/vercel@8.x doesn’t support Astro 6 — the project uses Astro 6. The correct adapter version is ^10.0.0.
The fix was two steps:
- Update
package.json:"@astrojs/vercel": "^8.0.4"→"@astrojs/vercel": "^10.0.0" - Run
pnpm installlocally to regenerate a clean lockfile - Commit both files and push
The second build succeeded.
There was one more local issue: after switching from npm (which had been used to install dependencies initially) to pnpm, the node_modules directory had packages in node_modules/.ignored — moved there by pnpm because they’d been installed by a different package manager. This caused a Vite plugin resolution error:
TypeError: Cannot read properties of undefined (reading 'call')
at EnvironmentPluginContainer.transform
Fix: delete node_modules and .astro entirely, run pnpm install fresh. The clean install rebuilds node_modules under pnpm’s management with no leftovers from npm.
What the AI-augmented workflow actually looked like
I write about using AI tools in documentation work. This project gave me a concrete case to reflect on.
The pattern that emerged wasn’t “use AI to generate content.” It was more specific than that:
Research under access constraints. Several external resources were blocked or required authentication — LinkedIn’s skills page returns 403 to scrapers, Upwork project pages returned similar. Rather than stopping at “I can’t access that,” AI-assisted web search found indexed versions of the same information from ZoomInfo, GitHub, and DEV Community profiles. Not perfect, but enough to work with.
First draft as thinking tool. For the blog posts migrated from dev.to, the original articles had been summarised by the research agent into structured notes. Writing the MDX versions from those notes — expanding them into full prose, adding code examples, adjusting the voice — was faster because the structural thinking was already done. The editorial judgment (what to cut, what to lead with, how to frame the conclusion) was still mine.
Verification, not generation. The most consistently useful thing was having a second perspective during review: “does this sentence actually say what I think it says?” That’s the use case I described in my post on testing every code sample — a second perspective at every stage, not a replacement for the first.
Catching what I’d otherwise skip. The Vite deprecation warning, the peer dependency mismatch, the lockfile version conflict — these are the kinds of issues that are easy to defer because they’re not blocking anything right now. Having them flagged and explained in context made it easier to decide when to fix them (immediately, in the case of the deployment blocker) versus when they were low priority (the deprecation warning).
The site is live. The code is on GitHub. The content reflects real work and real thinking.
If you’re building something similar — a portfolio, a docs site, anything on Astro — the principles that made this easier are the same ones I’d apply to product documentation: start with identity, keep the structure clean, test what you ship, and don’t defer the obvious fixes.
The template handles the engine. Everything else is just writing.