The Overcomplicated Portfolio Architecture Series: Part 1 - Foundation
Because your portfolio definitely needs a homegrown CMS and end-to-end tests for a content API. Part 1: Setting up a development environment that's probably overkill.
Table of Contents
- Introduction
- Why Not Markdown or WordPress?
- The Ridiculous Stack
- Project Structure (or How to Pretend You’re Organized)
- Data Modularization (or How to Avoid JSON Hell)
- The Monstrosity of a CMS (ContentManager.vue)
- WYSIWYG Editor and Image Uploads
- The Dev Environment (Strictness, Formatting, and Tests—Oh My!)
- What’s Next?
Introduction
Let’s talk about building something that doesn’t suck. The goal was a simple portfolio site, but where’s the fun in that? I decided to bake in a blog, a homegrown CMS, and even a project showcase with filters. It’s like ordering a plain coffee and getting a triple-shot latte with five pumps of syrup—unnecessary but satisfying.
My agile motto is Make It Work, Make It Right, Make It Fast. This post covers how I tackled the first step: making it work. Parts 2 and 3 will tackle refactoring and performance, because when your codebase hits a certain size, refactoring becomes a weekend hobby.
Why Not Markdown or WordPress?
Markdown is great until you need a WYSIWYG editor, image uploads, preview modes, and content scheduling. Suddenly you’re juggling file paths like a circus performer and praying your build pipeline doesn’t choke. WordPress? Sure, if you want to wrestle with plugins, security patches, and a dashboard that feels like a decade-old relic.
So I built a custom CMS with modern tooling. It handles versioned content in JSON, lets me edit in the browser, and stores data via a simple API. Sure, it’s overkill for a portfolio, but it beats the spaghetti of markdown.
The Ridiculous Stack
Astro 5.8.0 orchestrates the build, giving me fast static output and partial hydration for the admin SPA. Vue 3.4.19 powers the CMS UI, parceled through @astrojs/vue
. TypeScript 5.4.2 catches type mismatches at compile time. TailwindCSS 3.4.1 keeps styles consistent with minimal effort. TipTap 2.12.0 provides rich text capabilities. Sharp, invoked by scripts/optimize-images.js
, generates multiple image sizes at build time. Netlify handles deploys and edge functions.
Snippet from astro.config.mjs
:
import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind";
import sitemap from "@astrojs/sitemap";
import robotsTxt from "astro-robots-txt";
import vue from "@astrojs/vue";
import netlify from "@astrojs/netlify";
import { SITE_URL } from "./src/data/config";
export default defineConfig({
adapter: netlify(),
integrations: [tailwind(), sitemap(), robotsTxt(), vue()],
site: SITE_URL,
markdown: { syntaxHighlight: "shiki", shikiConfig: { theme: "nord", wrap: false } },
});
That configuration keeps the build pipeline sane and integrates all the necessary features.
Project Structure (or How to Pretend You’re Organized)
Here’s the folder layout in a nutshell (with some files omitted for brevity):
src/
components/
admin/
ContentManager.vue
WysiwygEditor.vue
ImageUpload.vue
tabs/
BlogPostsTab.vue
PortfolioTab.vue
...
sections/
HeroSection.astro
PortfolioSection.astro
...
composables/
useContentManager.js
data/
content.json
portfolio.json
...
pages/
index.astro
admin.astro
blog.astro
...
utils/
contentLoader.ts
formatDate.ts
removeTrailingSlash.ts
styles/
tailwind.css
Each folder has a clear responsibility, but there’s room for refactoring. The utils
directory feels like a junk drawer, and contentLoader.ts
could be split into smaller modules.
Take index.astro
—it dynamically loads sections based on a homepage-config.json
, allowing me to toggle features without redeploying code:
---
const homepageConfig = await loadSection("homepage-config");
const personalInfo = await loadSection("personal-info");
---
<Layout>
{homepageConfig.sections.hero.enabled && <HeroSection {...personalInfo} />}
{
homepageConfig.sections.portfolio.enabled && (
<PortfolioSection items={portfolio} />
)
}
{/* More sections... */}
</Layout>
That approach saves time but adds complexity to the loader logic.
Data Modularization (or How to Avoid JSON Hell)
Here’s where things get interesting—and slightly unhinged. What started as a simple content.json
file managed through my homegrown CMS quickly became a performance nightmare. Picture this: a 500+ line JSON blob getting loaded, parsed, and saved on every edit. The CMS would choke, auto-save would lag, and I’d sit there watching spinners like it’s 2005 again.
So I did what any reasonable developer would do—I over-engineered a solution. Instead of cramming everything into one massive file, I built a dual-mode data system that supports both modular files and the monolithic structure. It’s like having your cake and eating it too, except the cake is JSON and you’re probably overthinking it. The modular approach splits content into logical chunks: personal-info.json
, experience.json
, portfolio.json
, homepage-config.json
, and so on. Each file owns its domain, making CMS updates surgical rather than archaeological. Want to tweak your bio? The CMS edits one 24-line file instead of hunting through 500+ lines of nested objects:
{
"name": "Cody",
"title": "Hey, I'm Cody.",
"description": "<p>I'm a <strong>husband</strong>, <strong>father</strong>, software engineer...</p>",
"email": "cody@spectaclesoftware.com",
"profileImage": "/uploads/1748292093349-IMG_9319.PNG",
"socialLinks": [
{
"id": "github",
"platform": "GitHub",
"url": "https://github.com/codywilliamson",
"icon": "github"
}
]
}
The magic happens in contentLoader.ts
, which auto-detects whether you’re using modular files or the monolithic structure. It tries to load personal-info.json
first—if that fails, it falls back to extracting the same data from content.json
. This means I could migrate gradually without breaking the CMS, and honestly, I kept the fallbacks just because I’m paranoid about data loss:
private async detectDataStructure(): Promise<void> {
if (this.isModularMode !== null) return;
try {
const filePath = path.join(process.cwd(), sectionFilePaths["personal-info"]);
await fs.access(filePath);
this.isModularMode = true;
} catch (error) {
this.isModularMode = false;
}
}
public async loadSection<T>(section: ContentSection): Promise<T> {
await this.detectDataStructure();
if (this.isModularMode) {
return await this.loadModularSection<T>(section);
} else {
const allContent = await this.loadMonolithicContent();
return this.extractSectionFromMonolithic(allContent, section) as T;
}
}
The CMS (ContentManager.vue
) adapted beautifully to this change. Instead of loading one massive object and watching for deep changes, it now manages individual content modules with their own loading states, modification flags, and save operations. The performance improvement was immediate—no more 2-second delays when auto-saving a simple text change. Each section loads independently, saves independently, and the UI shows exactly which parts have been modified.
What really sells this approach is the configuration-driven homepage. The homepage-config.json
acts like a feature flag system, letting me toggle entire sections through the CMS without touching code. Want to hide the technologies section while you update it? Flip enabled
to false
in the CMS and redeploy. The Astro components check these flags and conditionally render, making the site feel modular even when the underlying data structure was originally monolithic:
{
"sections": {
"hero": { "enabled": true, "title": "Hero Section" },
"experience": { "enabled": true, "title": "Experience Section" },
"technologies": { "enabled": false, "title": "Technologies Section" },
"portfolio": { "enabled": true, "title": "Portfolio Section" }
}
}
This setup saved me countless hours during development and eliminated the performance bottlenecks that were making content editing a chore. Instead of wrestling with a monolithic file through the CMS interface, I now have granular control over each piece of content. It’s the kind of flexibility that makes you wonder why more projects don’t do this—until you realize you’ve spent three days building migration tooling for what could’ve been a simple import statement.
The Monstrosity of a CMS (ContentManager.vue)
ContentManager.vue
is about 1,000 lines of Vue single-file component chaos. It orchestrates tab navigation, real-time auto-save, preview toggles, and image uploads—all in one place. Here’s a snippet that shows how the auto-save handles form changes and pushes updates without you having to click save every time:
This snippet illustrates the reactive auto-save logic: any change to contentData
triggers a debounced save. You get instant feedback via saveSuccess
, and the save button street-smartly disables until there’s something new to push. It’s not perfect—there’s no retry on failure yet—but it works, which is step one in my agile motto.
We’ll slice through this in Part 2, extract reusable hooks, and clean up the spaghetti.
The composable useContentManager.js
abstracts API calls and state, but could use improved retry logic and better error handling:
export function useContentManager() {
const contentData = ref({
/* initial shape */
});
const loading = ref(false);
const error = ref(null);
const loadContent = async () => {
loading.value = true;
error.value = null;
try {
const res = await fetch("/api/content");
if (!res.ok) throw new Error(res.statusText);
contentData.value = await res.json();
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
};
onMounted(loadContent);
return { contentData, loading, error, loadContent };
}
We’ll clean this up in Part 2, promise.
WYSIWYG Editor and Image Uploads
The WYSIWYG component wraps TipTap’s Editor
instance, wiring up toolbar buttons and handling image insertion. When you drop an image, it hits an /api/upload
endpoint and streams back a URL.
Image optimization runs separately via Node script:
// scripts/optimize-images.js
import sharp from "sharp";
import fs from "fs/promises";
async function optimize(imagePath) {
await sharp(imagePath)
.resize(800)
.webp({ quality: 80 })
.toFile(imagePath.replace(/\.\w+$/, ".webp"));
}
It’s basic, but it turns 5MB PNGs into sub-200KB web-ready assets.
The Dev Environment (Strictness, Formatting, and Tests—Oh My!)
TypeScript strict mode is on. ESLint enforces rules that would make new developers cry. Prettier formats everything on save. Vitest runs unit tests in JSDOM. Here’s part of vitest.config.ts
:
export default defineConfig({
plugins: [vue()],
test: { globals: true, environment: "jsdom", include: ["tests/**/*.test.ts"] },
resolve: { alias: { "@": path.resolve(__dirname, "src") } }
});
The netlify.toml
enforces security and redirects:
[[redirects]]
from = "/admin"
to = "/404"
status = 404
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Content-Security-Policy = "default-src 'self';"
Lighthouse CI (.lighthouserc.json
) runs on every deploy, warning me if performance dips:
{
"ci": {
"collect": { "numberOfRuns": 3 },
"assert": {
"assertions": {
"categories:performance": ["warn", { "minScore": 0.9 }]
}
}
}
}
All this overhead ensures I know when something breaks before it hits production.
What’s Next?
Part 2 dives into the CMS internals. We’ll refactor the loader, extract reusable hooks, and simplify the massive ContentManager.vue
. Stick around for some real code cleanup (it may be tomorrow or next year, who knows when my ADHD hyperfixation with this will end!).
Published 05/28/2025
5 min read • #overcomplicated-architecture-1