Architecture Decisions That Actually Matter Long-Term in a Portfolio Site
Most portfolio sites are built for one moment: the job application or the client pitch. Developers rush to get something live, use the latest framework they are learning, and ship it. Then, six months later, they are fighting their own codebase every time they want to add a new section.
I wanted gucluyumhe.dev to be different. Not just a portfolio that looks good today, but a technical platform that gets better over time with minimal maintenance overhead.
Here are the 6 architectural decisions that made the biggest long-term difference — and the 2 decisions that seemed clever but created friction.
Decision 1: Markdown as the Single Source of Truth for Content
The Temptation: Use a CMS or Database
Many developers instinctively reach for a headless CMS (Contentful, Sanity, Notion API) or a database (Supabase, PlanetScale) for blog content. These feel scalable and production-ready.
For a portfolio blog with one author, they are massive over-engineering.
What I Did Instead: File-System Markdown
Every blog post is a .md file in /content/posts/. The slug is the filename. The metadata is frontmatter. The whole content catalog is a directory you can clone and open in any text editor.
// src/utils/markdown.ts
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import remarkHtml from 'remark-html';
export interface PostFrontmatter {
title: string;
date: string;
author: string;
excerpt: string;
coverImage: string;
tags: string[];
category: string;
locale: 'en' | 'tr';
}
export interface PostData extends PostFrontmatter {
slug: string;
contentHtml: string;
readingTimeMinutes: number;
}
const POSTS_DIRECTORY = path.join(process.cwd(), 'content/posts');
export async function getPostBySlug(slug: string): Promise<PostData | null> {
try {
const fullPath = path.join(POSTS_DIRECTORY, `${slug}.md`);
const fileContents = await fs.readFile(fullPath, 'utf8');
const { data, content } = matter(fileContents);
const processedContent = await remark()
.use(remarkHtml, { sanitize: false })
.process(content);
const wordCount = content.split(/\s+/).length;
return {
slug,
...(data as PostFrontmatter),
contentHtml: processedContent.toString(),
readingTimeMinutes: Math.ceil(wordCount / 200)
};
} catch {
return null;
}
}
Why This Won Long-Term
- Zero lock-in: No CMS subscription, no API rate limits, no vendor dependency
- Git history is your changelog: Every edit is version-controlled, with author and timestamp
- Offline-first development: Write posts without internet, in any editor
- Build-time rendering: Markdown parsed at build time — zero runtime overhead
- Bilingual by convention:
post.md(English) andpost_tr.md(Turkish) — language detection by filename suffix
Three years from now, if I switch from Next.js to Astro or SvelteKit, my entire content catalog migrates with zero changes. The markdown files are framework-agnostic.
Decision 2: Locale-Based Routing Without a Translation Library
The Temptation: Use next-i18next or next-intl
Most multi-language Next.js tutorials immediately reach for next-i18next or next-intl. These are powerful libraries — but they add complexity, configuration files, and a non-obvious mental model for a simple use case.
What I Did: Convention-Based Routing
// app/[locale]/blog/[slug]/page.tsx
export async function generateStaticParams() {
const allFiles = await fs.readdir(POSTS_DIRECTORY);
return allFiles
.filter(file => file.endsWith('.md'))
.map(file => {
const isTranslation = file.endsWith('_tr.md');
const locale = isTranslation ? 'tr' : 'en';
const slug = file
.replace('_tr.md', '')
.replace('.md', '');
return { locale, slug };
});
}
content/posts/
building-an-ai-agent-with-mcp.md serves at /en/blog/building-an-ai-agent-with-mcp
building-an-ai-agent-with-mcp_tr.md serves at /tr/blog/building-an-ai-agent-with-mcp
nextjs-performance-before-seo.md serves at /en/blog/nextjs-performance-before-seo
nextjs-performance-before-seo_tr.md serves at /tr/blog/nextjs-performance-before-seo
This decision eliminated an entire dependency and hundreds of lines of configuration. Adding a new language means adding files with a _de.md suffix — no library config changes.
Decision 3: JSON-LD Structured Data for Every Page Type
The Temptation: Skip It — Search Engines Figure It Out
Most developers skip JSON-LD because it feels like extra SEO work with delayed results. In 2026, this is a mistake for two reasons:
- Traditional SEO: Google explicitly uses structured data for rich snippets, author verification, and ranking signals
- GEO (Generative Engine Optimization): AI models like Gemini, Claude, and Perplexity extract structured data to verify author credibility and fact attribution
What I Implemented
export function generateBlogPostSchema(post: PostData, url: string) {
return {
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": post.title,
"description": post.excerpt,
"image": post.coverImage,
"datePublished": post.date,
"dateModified": post.date,
"author": {
"@type": "Person",
"name": "Ömer Özbay",
"url": "https://gucluyumhe.dev",
"sameAs": [
"https://github.com/gucluyumhe",
"https://linkedin.com/in/omerozbay"
]
},
"publisher": {
"@type": "Person",
"name": "Ömer Özbay",
"url": "https://gucluyumhe.dev"
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": url
},
"keywords": post.tags.join(", "),
"inLanguage": post.locale === 'tr' ? "tr-TR" : "en-US",
"url": url
};
}
export default async function BlogPostPage({ params }: Props) {
const post = await getPostBySlug(params.slug);
const url = `https://gucluyumhe.dev/${params.locale}/blog/${params.slug}`;
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(generateBlogPostSchema(post, url))
}}
/>
<ArticleLayout post={post} />
</>
);
}
The sameAs array linking to GitHub and LinkedIn is critical for GEO. It allows AI models to disambiguate "Ömer Özbay" as a specific entity, not just a name string.
Decision 4: Edge-First Deployment (Vercel Edge Network)
The Temptation: Self-Host on a VPS for Control
Many developers feel that Vercel or Netlify means giving up control. For a portfolio site, this is the wrong trade-off calculation.
Self-hosting a Next.js app means managing SSL certificate renewal, DDoS protection, global CDN setup, zero-downtime deployments, and health monitoring.
For a portfolio, this is maintenance overhead that generates zero value.
The Value of vercel.json
A single configuration file gives you global distribution, automatic HTTPS, and smart routing:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-XSS-Protection", "value": "1; mode=block" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
]
},
{
"source": "/fonts/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
}
],
"redirects": [
{ "source": "/blog/:slug", "destination": "/en/blog/:slug", "permanent": false }
]
}
Long-term win: zero infrastructure maintenance, automatic scaling, and built-in analytics.
Decision 5: Dynamic Sitemap Generation
The Temptation: Static sitemap.xml
A static sitemap.xml requires manual updates every time you publish a new post. After the third post, you forget to update it. After the tenth, it is permanently out of date.
The Fix: Next.js App Router Sitemap
// app/sitemap.ts
import { MetadataRoute } from 'next';
import { getAllPosts } from '@/utils/markdown';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const baseUrl = 'https://gucluyumhe.dev';
const postEntries: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${baseUrl}/${post.locale}/blog/${post.slug}`,
lastModified: new Date(post.date),
changeFrequency: 'monthly',
priority: 0.8,
}));
return [
{ url: baseUrl, lastModified: new Date(), changeFrequency: 'weekly', priority: 1 },
{ url: `${baseUrl}/en/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
{ url: `${baseUrl}/tr/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
...postEntries,
];
}
Add a new markdown file and the sitemap updates automatically on the next build. Zero maintenance required.
Decision 6: Separation of Concerns in CSS
What Most Developers Do
Inline Tailwind utilities on every element, creating inconsistency across the codebase:
// Hard to maintain: styles scattered across components
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mt-8 mb-4 leading-tight tracking-tight">
What I Did: CSS Custom Properties and Semantic Classes
/* globals.css — design tokens as custom properties */
:root {
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
--color-accent: #6366f1;
--spacing-section: 4rem;
--font-size-h1: clamp(2rem, 5vw, 3.5rem);
--border-radius-card: 0.75rem;
}
[data-theme="dark"] {
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-accent: #818cf8;
}
/* Semantic component classes */
.article-title {
font-size: var(--font-size-h1);
font-weight: 700;
color: var(--color-text-primary);
line-height: 1.2;
letter-spacing: -0.02em;
margin-bottom: 1rem;
}
Long-term win: changing the accent color across the entire site is a one-line CSS change, not a grep-and-replace across 50 files.
The 2 Decisions That Created Pain
Pain Decision 1: Too Many Page Types Too Early
I built separate layouts for blog posts, project showcases, experience timeline, skills grid, contact form, and 404. Six distinct layout types, all in the first version.
What happened: every design iteration required touching 6 files. Layout changes became a chore.
Better approach: start with 2–3 generic layout shells. Add specialization only when content genuinely requires it.
Pain Decision 2: Mixing Languages in the Same Component
Early on, some components had hardcoded strings in both languages:
// Created maintenance chaos
<p>{locale === 'tr' ? 'Son yazılar' : 'Latest posts'}</p>
With 15 or more such strings across 20 or more components, this became a maintenance nightmare.
Better approach: even without an i18n library, extract all display strings into locale-specific JSON files from day one.
The Architecture in Summary
gucluyumhe.dev Architecture (2026)
├── Content Layer: Markdown files, framework-agnostic
├── Routing Layer: Next.js App Router ([locale]/[slug])
├── Rendering Layer: React Server Components, static HTML output
├── SEO Layer: JSON-LD schemas, dynamic sitemap, Metadata API
├── Styling Layer: CSS custom properties plus Tailwind utilities
├── Analytics Layer: Server-side GA4, non-blocking
└── Infrastructure Layer: Vercel Edge, zero maintenance
Three years from now, if React Server Components are replaced by something better, only the Rendering Layer changes. The content, routing, SEO, and styling layers are independent. That is the long-term architectural win.
The Lesson
Portfolio sites fail architecturally because developers optimize for speed-to-launch, not maintainability. The irony is that the decisions that take 30 percent longer to implement upfront — dynamic sitemaps, CSS custom properties, JSON-LD schemas, locale conventions — eliminate 90 percent of the maintenance friction over the following years.
Build for the version of yourself 2 years from now, who has 40 or more blog posts, 3 languages, and 5 minutes to add a new feature. That version will thank you for every extra decision you made on day one.
