A full walkthrough of how I built my developer portfolio from database schema to a live, dynamic frontend — with no static JSON files.
Most developer portfolios are just static JSON files masquerading as a database. Mine used to be the same — a hardcoded array of projects in a .js file that I had to manually edit and redeploy every time I added something new. I decided to rebuild it properly: a real backend, a real database, and a frontend that fetches live data on every request. Here's exactly how I did it.
The Stack
Frontend: Next.js (App Router, Server Components)
Backend: Node.js + Express
Database: PostgreSQL
Deployment: (your hosting choice here)
The architecture is intentionally simple. No GraphQL, no ORM, no microservices. Just clean REST endpoints backed by SQL queries.
Database Schema
The first step was designing the database. I kept it flat and simple
sql
— no over-engineering.
-- Profile
CREATE TABLE profile (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
title VARCHAR(200),
bio TEXT,
email VARCHAR(200),
github_username VARCHAR(100),
linkedin_url VARCHAR(300),
created_at TIMESTAMP DEFAULT NOW()
);
-- Projects
CREATE TABLE projects (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
description TEXT,
tech_stack TEXT[],
live_url VARCHAR(300),
github_url VARCHAR(300),
created_at TIMESTAMP DEFAULT NOW()
);
-- Skills
CREATE TABLE skills (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
category VARCHAR(100),
proficiency INTEGER CHECK (proficiency BETWEEN 0 AND 100)
);
-- Experience
CREATE TABLE experience (
id SERIAL PRIMARY KEY,
role VARCHAR(200) NOT NULL,
company VARCHAR(200) NOT NULL,
start_date DATE NOT NULL,
end_date DATE,
description TEXT,
tech_stack TEXT[]
);
javascript
The key insight here: tech_stack TEXT[] uses PostgreSQL's native array type. This means I can store ['Next.js', 'Node.js', 'PostgreSQL'] directly in the database without a separate join table. For a portfolio, this tradeoff is worth it.
The Backend (Express API)
I kept the API layer thin — its only job is to query the database and return JSON.
javascript
// server.js
const express = require('express');
const cors = require('cors');
const { Pool } = require('pg');
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.use(cors());
app.use(express.json());
// A pattern I use: always have a fallback
// so the server doesn't crash if the DB is unavailable
app.get('/api/projects', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM projects ORDER BY created_at DESC'
);
res.json(result.rows);
} catch (error) {
console.error(error.message);
res.json(FALLBACK_PROJECTS); // static fallback array
}
});
The fallback pattern is critical during development. Your frontend shouldn't break just because the database is temporarily unavailable.
The Frontend (Next.js Server Components)
This is where Next.js App Router shines. Since the data fetching happens on the server, there's no loading state to manage and no client-side fetch on initial render:
javascript
// app/page.js
async function getProjects() {
const res = await fetch('http://localhost:3001/api/projects', {
cache: 'no-store' // Always fresh data
});
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
}
export default async function Home() {
const projects = await getProjects();
return (
<main>
{projects.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</main>
);
}
The cache: 'no-store' option ensures every page visit gets fresh data from the database. If you want caching, you can switch to { next: { revalidate: 60 } } to refresh every 60 seconds.
Interactive Architecture Diagrams Per Project
One feature I'm particularly proud of: each project card has an expandable section that auto-generates a system architecture diagram based on its tech_stack array.
The diagram infers which "layers" a project has by scanning the tech stack:
javascript
function inferNodes(techStack) {
const tech = techStack.map(t => t.toLowerCase());
const layers = [];
// Always add the client layer
layers.push({ id: 'client', title: 'Client', tech: 'Browser / UI' });
// Detect frontend framework
if (tech.some(t => ['react', 'next.js', 'vue'].some(k => t.includes(k)))) {
layers.push({ id: 'frontend', title: 'Presentation', tech: 'Next.js' });
}
// Detect backend
if (tech.some(t => ['node', 'express', 'django'].some(k => t.includes(k)))) {
layers.push({ id: 'backend', title: 'API Layer', tech: 'Express' });
}
// Detect database
if (tech.some(t => ['postgresql', 'mysql', 'mongodb'].some(k => t.includes(k)))) {
layers.push({ id: 'database', title: 'Data Layer', tech: 'PostgreSQL' });
}
return layers;
}
The nodes are then connected by animated SVG dashed lines to visualize the request flow. No diagram tool, no external library — just React state and CSS animations.
Live GitHub Stats
The portfolio also shows live GitHub data pulled from the public GitHub API with no authentication needed:
javascript
const [userRes, reposRes] = await Promise.all([
fetch(`https://api.github.com/users/${username}`),
fetch(`https://api.github.com/users/${username}/repos?sort=stars&per_page=100`)
]);
From this I calculate total stars across all repos, detect top languages, and display the contribution heatmap using ghchart.rshah.org.
What I Learned
Server Components change the architecture. When data fetching happens server-side, your component tree gets simpler. No useEffect, no loading spinner, no hydration issues for the initial render.
PostgreSQL arrays are underused. The native TEXT[] type eliminated a whole join table for tech stacks. For simple list data, don't reach for a separate table immediately.
Fallback patterns are not optional. A portfolio that crashes when the database hiccups looks worse than one showing slightly stale data. Always have a fallback.
The architecture should be visible. Showing the system design of each project directly in the portfolio communicates more than a list of bullet points ever could.
The full source code is available on my GitHub.
Top comments (0)