
Building a Zero-Database Portfolio with Static Site Generation
How I built a fully-featured portfolio website without a runtime database using Next.js Static Site Generation, MDX for content, and Git as the single source of truth.
Introduction
When planning my portfolio rebuild, I had a controversial requirement: no runtime database. No PostgreSQL, no MongoDB, not even SQLite. All content would live in Git-tracked files and be generated statically at build time.
This decision led to a architecture that's:
- Blazing fast: Sub-1s page loads with no database queries
- Zero-cost: No database hosting or maintenance
- Version-controlled: Every content change tracked in Git
- Portable: Deploy anywhere static files are served
Here's how I built it and the tradeoffs I encountered.
The Architecture
┌─────────────────────────────────────────────────────────┐
│ Git Repository │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ content/blog/│ │content/ │ │lib/ │ │
│ │ *.mdx │ │projects/ │ │constants.ts │ │
│ │ │ │ *.json │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└────────────────┬────────────────────────────────────────┘
│
▼
┌───────────────┐
│ Build Process │
│ (Next.js) │
└───────┬───────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
┌────────────┐ ┌──────────────┐
│ Static HTML│ │ Static JSON │
│ Pages │ │ (Search) │
└────────────┘ └──────────────┘
│ │
└────────────┬────────────┘
│
▼
┌───────────────┐
│ Vercel CDN │
│ (Edge Network)│
└───────────────┘
Every page is pre-rendered at build time. No database queries at runtime. Ever.
Content Management: MDX + JSON
Blog Posts as MDX Files
MDX combines Markdown with React components:
---
title: "My Blog Post"
description: "A great article"
publishedAt: "2025-10-12"
category: "technical"
tags: ["nextjs", "react"]
---
## Introduction
Regular markdown content here...
{/* But you can embed React components! */}
<YouTubeEmbed videoId="dQw4w9WgXcQ" />
## Code Examples
```typescript
// Syntax highlighting works out of the box
const greeting: string = "Hello, world!";
console.log(greeting);Math Equations
You can even use KaTeX for math: $E = mc^2$
### Projects as Structured JSON
```json
{
"id": "portfolio-website",
"title": "Personal Portfolio Website",
"description": "A fast, accessible portfolio built with Next.js 15",
"technologies": ["Next.js", "React", "TypeScript", "Tailwind CSS"],
"category": "fullstack",
"status": "completed",
"image": "portfolio-screenshot.jpg",
"demoUrl": "https://me.zealer.in",
"githubUrl": "https://github.com/mnishanth02/portfolio",
"featured": true,
"startDate": "2025-09",
"endDate": "2025-10",
"problemStatement": "Create a portfolio that serves both technical recruiters and fitness community members...",
"technicalApproach": "Used Next.js 15 App Router with Static Site Generation...",
"challenges": [
"Achieving 95+ Lighthouse scores with rich interactive features",
"Building a flexible content system without a CMS"
],
"outcomes": [
"98+ Lighthouse performance score",
"Sub-1.5s First Contentful Paint",
"Zero runtime database costs"
],
"metrics": {
"usersImpacted": 10000,
"performanceImprovement": "58%",
"codeReduction": "40%"
}
}
Content Processing Pipeline
1. Schema Validation with Zod
Every piece of content is validated at build time:
import { z } from 'zod';
export const blogPostSchema = z.object({
title: z.string().min(10).max(100),
description: z.string().min(50).max(200),
publishedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
category: z.enum(['technical', 'fitness', 'lifestyle']),
tags: z.array(z.string()).min(1).max(10),
image: z.string().optional(),
featured: z.boolean().default(false),
});
export type BlogPost = z.infer<typeof blogPostSchema>;If content doesn't match schema, build fails - catching errors early.
2. MDX Processing with Content Collections
I use @content-collections/next for type-safe content management:
// content-collections.config.ts
import { defineCollection, defineConfig } from '@content-collections/core';
import { compileMDX } from '@content-collections/mdx';
const posts = defineCollection({
name: 'posts',
directory: 'content/blog',
include: '*.mdx',
schema: (z) => ({
title: z.string(),
description: z.string(),
publishedAt: z.string(),
category: z.enum(['technical', 'fitness', 'lifestyle']),
tags: z.array(z.string()),
}),
transform: async (document, context) => {
const mdx = await compileMDX(context, document);
return {
...document,
mdx,
slug: document._meta.fileName.replace(/\.mdx$/, ''),
readingTime: calculateReadingTime(document.content),
};
},
});
export default defineConfig({
collections: [posts],
});3. Static Generation with generateStaticParams
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<MDXRenderer source={post.mdx} />
</article>
);
}At build time, Next.js generates HTML for every blog post.
Search Without a Database
Client-side search with Fuse.js:
'use client';
import Fuse from 'fuse.js';
import { useState, useMemo } from 'react';
export function SearchBar({ posts }: { posts: BlogPost[] }) {
const [query, setQuery] = useState('');
// Create search index
const fuse = useMemo(() => {
return new Fuse(posts, {
keys: ['title', 'description', 'tags'],
threshold: 0.3, // Fuzzy matching
includeScore: true,
});
}, [posts]);
const results = query
? fuse.search(query).map(result => result.item)
: posts;
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search posts..."
/>
{results.map(post => (
<BlogCard key={post.slug} post={post} />
))}
</div>
);
}Search runs entirely in the browser. No API calls needed.
Dynamic Data: The Contact Form Exception
The one exception to the zero-database rule: the contact form.
// app/api/contact/route.ts
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: Request) {
const body = await request.json();
const validatedData = contactSubmissionSchema.parse(body);
// Send email - no database storage
await resend.emails.send({
from: 'portfolio@me.zealer.in',
to: 'nishanth.murugan@gmail.com',
subject: `Contact: ${validatedData.name}`,
html: validatedData.message,
});
return Response.json({ success: true });
}I use Resend to send emails directly. No storage = no GDPR concerns.
Content Updates: The Git Workflow
Updating Content
# 1. Edit content file
vim content/blog/new-post.mdx
# 2. Commit changes
git add content/blog/new-post.mdx
git commit -m "Add new blog post"
# 3. Push to GitHub
git push origin main
# 4. Vercel automatically rebuilds and deploys
# New content live in ~2 minutesPreview Changes with Pull Requests
git checkout -b draft/new-feature-post
# Edit content...
git push origin draft/new-feature-post
# Open PR → Vercel creates preview deployment
# Review → Merge → Auto-deploy to productionPerformance Benefits
Lighthouse Scores: 98-99
No database means:
- Zero query latency
- No connection pooling
- No N+1 queries
- No database timeouts
Result: Consistent sub-1s page loads
Hosting Costs: $0
With static files:
- No database server
- No connection pooling
- No backup infrastructure
- Vercel Hobby plan: Free for personal projects
Scalability: Infinite
Static files scale infinitely via CDN:
- 10 users? Fast.
- 10,000 users? Still fast.
- 10 million users? Still fast.
CDN edge caching handles any traffic spike.
Tradeoffs: What You Lose
1. No Real-Time Updates
Content updates require rebuilds:
- Edit time: Instant
- Build time: 2-3 minutes
- Deploy time: 30 seconds
- Total: ~3 minutes from edit to live
For a portfolio, this is acceptable. For real-time apps, it's not.
2. No User-Generated Content
Users can't directly create content:
- ❌ Comments
- ❌ Likes/reactions
- ❌ User profiles
- ✅ Contact form (via API route)
Solution: Use third-party services (Disqus for comments, etc.)
3. No Server-Side Personalization
Can't personalize content per user:
- ❌ "Recommended for you"
- ❌ A/B testing (server-side)
- ✅ Client-side personalization (with local storage)
4. Large Sites = Slow Builds
Build time scales with content:
- 10 posts: ~30 seconds
- 100 posts: ~2 minutes
- 1,000 posts: ~15 minutes
Solution: Incremental Static Regeneration (ISR) for large sites
When to Use This Architecture
Perfect For
✅ Portfolios ✅ Blogs ✅ Marketing sites ✅ Documentation ✅ Landing pages ✅ Project showcases
Not Suitable For
❌ Social networks ❌ E-commerce (with inventory) ❌ Real-time dashboards ❌ User-generated content platforms ❌ Apps requiring personalization
Alternative Architectures
If you need more dynamic features:
Hybrid: Static + Database
// Static site with dynamic features
export default async function Page() {
// Static content
const post = await getStaticPost();
return (
<div>
<h1>{post.title}</h1>
{/* Dynamic comments from database */}
<Comments postId={post.id} />
</div>
);
}Incremental Static Regeneration (ISR)
export const revalidate = 3600; // Rebuild every hour
export default async function Page() {
const data = await fetch('https://api.example.com/data');
return <div>{data.title}</div>;
}Lessons Learned
After 3 months running this architecture:
- Simplicity is powerful: No database = fewer moving parts = fewer bugs
- Git is underrated as a CMS: Version control, branching, pull requests - all free
- Build times matter: Keep content organized for fast builds
- Edge cases exist: Some features genuinely need a database
Conclusion
A zero-database architecture isn't right for every project, but for portfolios and content-heavy sites, it's incredibly powerful:
- Fast: No query latency
- Cheap: Zero hosting costs
- Reliable: No database downtime
- Simple: Fewer components to maintain
The key is understanding the tradeoffs and choosing the right tool for the job.
For my portfolio, it was the perfect choice.