A full-stack portfolio website with a custom-built CMS — featuring a blog engine, services showcase, contact inbox with message categorization, admin dashboard, and ⌘K global search. Built entirely with Next.js 14, PostgreSQL, Prisma, and NextAuth.

Most developers use Notion, Contentful, or some third-party CMS to manage their portfolio content. I decided to build my own from scratch — a fully custom content management system where I own everything: the data, the UI, the auth, the image pipeline, and the admin dashboard.
This is that project. Here's how it's built, what it does, and what I learned building a production CMS for myself.
The portfolio has two distinct layers: a public-facing frontend and a private admin dashboard. Both are part of the same Next.js 14 app, protected by role-based access using NextAuth.js.
ADMIN badge in the sidebar is rendered based on session role.The entire site follows a consistent terminal/hacker aesthetic: near-black backgrounds (#0a0f0a range), bright green (#00d67e) as the primary accent, monospace fonts for headings and labels, and double-slash comment syntax (// SECTION) for section markers. This isn't a theme applied on top — it's baked into the Tailwind config and applied consistently across every page, component, and the admin dashboard.
The result is a portfolio that looks like it was built by the person using it, not assembled from a template.
The dashboard is the most complex part of the project. It's a full internal tool built inside the same Next.js app, accessible only to users with the ADMIN role.
// Sidebar structure
OVERVIEW
└── Dashboard ← stats + recent messages + recent projects
CONTENT
├── Projects ← list, add, edit, delete
├── Blog Posts ← list, add, edit, delete
└── Services ← list, add, edit, delete
INBOX
└── Messages ← full inbox with read state and labels
PEOPLE
└── User Management ← view registered users
CONFIG
└── Site Settings ← global site configuration
The dashboard homepage shows five live stats — Projects, Blog Posts, Services, Messages, and Total Views — fetched server-side on every load. Recent messages display sender, email, reason tag, timestamp, and admin/guest label. Recent projects show the title and stack tags with view counts.
One of my favorite features is the ⌘K command palette. Pressing Ctrl+K (or ⌘K on Mac) opens a full-screen modal search that queries blog posts and projects simultaneously. Results come back with type badges (blog / project) and truncated descriptions. Keyboard navigation works fully — arrow keys to move, Enter to open, Escape to close.
This is implemented as a client component that fires a debounced server action on input change, returning ranked results from PostgreSQL full-text search across title and description fields.
The contact page collects name, email, a reason dropdown (Collaboration, Freelance Project, Hiring / Job Opportunity, Feedback), and a message. On submit, the data is written to the messages table in PostgreSQL via a Server Action. No third-party form service — it all lives in my database.
In the admin inbox, each message shows sender, email, reason as a colored tag, timestamp, and a read/unread indicator. Messages from registered users get an Admin badge. The response priority panel on the contact page makes it clear to visitors that registered users get faster replies — a small detail that drives signups.
Blog posts are created from the admin dashboard with a title, slug, tags, cover image (uploaded to Cloudinary), and rich content. On the public blog page, posts are displayed in a card grid with cover image, tags, read time, date, and view count. A tag cloud sidebar on the right lets visitors filter by topic. Individual post pages track views — each visit increments the post's view counter via a Server Action called on page load.
View counts are tracked at the database level, not via an analytics service. When a project or blog post page loads, a Server Action fires to increment the views field on that record. This gives me real per-content view data visible directly in the admin dashboard and on each public card, without needing Google Analytics or any external script.
Auth is handled by NextAuth.js with a credentials provider backed by the PostgreSQL users table. Session tokens include the user's role field, which is checked on every admin route via middleware. The admin sidebar renders only for users whose session role is ADMIN. Regular registered users can log in, get priority contact routing, and see personalized UI — but cannot access the dashboard.
Building your own CMS is one of the best exercises in full-stack development because you're the user — you know exactly what you need, and there's no spec to hide behind. Every feature in this portfolio exists because I needed it. The result is a system I understand completely, can extend freely, and actually enjoy using. If you're a developer without a portfolio CMS of your own, I'd highly recommend building one.
// Tech Stack