Building a Web Experience: Creating the Bluedot Website
10/15/2025
Building a Server-Rendered Portfolio with Next.js, MDX, NextAuth, and Prisma
A good portfolio shouldn’t feel like a slideshow — it should feel like software.
I wanted something fast, dynamic, and expressive: a personal site that actually does things.
This post walks through how I built a server-side rendered (SSR) portfolio using Next.js, MDX, NextAuth, Prisma, and the GitHub API.
What I Wanted to Build
- Fast, SEO-friendly pages: Every page is rendered on the server (SSR-first).
- Authentication: Real database-backed login with NextAuth and Prisma.
- Blog: Markdown and JSX (MDX) for flexible posts with syntax-highlighted code.
- Projects: Live data pulled from the GitHub API.
- Resume: A downloadable PDF generated on demand.
- Minimal JS: Mostly server components, styled with Tailwind.
The Tech Stack
| Layer | Tech | Purpose |
|-------|------|----------|
| Framework | Next.js 14+ | App Router with Server Components |
| Database | Prisma ORM | SQLite (dev) → Postgres (prod) |
| Auth | NextAuth v4 | Credentials provider + Prisma adapter |
| Content | mdx-bundler + Shiki | MDX posts with syntax highlighting |
| UI | Tailwind CSS | Lightweight and flexible styling |
| PDF | @react-pdf/renderer | Stream PDFs from a route |
| Data | GitHub REST API | Fetch live repo data for /projects |
Folder Layout
src/
app/
about/page.tsx
admin/
comments/page.tsx
post/
create/page.tsx
edit/page.tsx
page.tsx
api/
admin/
comments/
[id]/route.ts
route.ts
posts/
create/route.ts
delete/route.ts
edit/route.ts
route.ts
auth/
[...nextauth]/route.ts
register/route.ts
blog/
[slug]/route.ts
route.ts
categories/route.ts
comments/route.ts
tags/route.ts
blog/
[slug]/page.tsx
page.tsx
contact/
thank-you/page.tsx
page.tsx
legal/
privacy/page.tsx
terms/page.tsx
login/page.tsx
projects/page.tsx
resume/page.tsx
error.tsx
globals.css
layout.tsx
page.tsx
components/
CommentForm.tsx
DeletePostModal.tsx
Footer.tsx
Header.tsx
Hero.tsx
mdx.tsx
MDXContent.tsx
Providers.tsx
RepoCard.tsx
ThemeProvider.tsx
ThemeSwitch.tsx
lib/
auth.ts
github.ts
prisma.ts
rateLimit.ts
utility.ts
utils.ts
types/
mdx.d.ts
next-auth.d.ts
middleware.ts
prisma/schema.prisma
Environment Variables
Create a .env file:
DATABASE_URL="file:./dev.db"
NEXTAUTH_SECRET="changeme"
NEXTAUTH_URL="http://localhost:3000"
# Optional for higher GitHub API rate limits
GITHUB_TOKEN=""
GITHUB_USERNAME="your-github-username"
Prisma Schema
I used NextAuth’s built-in tables (via the Prisma Adapter) plus a simple Comment model:
model Comment {
id String @id @default(cuid())
slug String
authorId String?
author User? @relation(fields: [authorId], references: [id])
body String
createdAt DateTime @default(now())
}
Bootstrap your database:
npx prisma generate
npx prisma migrate dev --name init
Authentication with NextAuth + Prisma
Here’s the core setup (src/lib/auth.ts):
import type { NextAuthOptions } from "next-auth";
import { getServerSession } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
session: { strategy: "database" },
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const username = credentials?.username?.trim();
const password = credentials?.password || "";
if (!username || !password) return null;
const user = await prisma.user.findFirst({ where: { username } });
if (!user || !user.password) return null;
const ok = await bcrypt.compare(password, user.password);
if (!ok) return null;
return { id: String(user.id), name: user.username, email: user.email };
},
}),
],
callbacks: {
async session({ session, user }) {
if (session.user) (session.user as any).id = user.id;
return session;
},
},
secret: process.env.NEXTAUTH_SECRET,
};
export function auth() {
return getServerSession(authOptions);
}
MDX Blog System
Each post lives in src/content/posts with frontmatter and Markdown + JSX (MDX).
Example post:
---
title: Hello, World
date: 2025-01-01
excerpt: Kicking off the MDX blog.
---
Welcome to the new site. This content is rendered **server-side** using `mdx-bundler` and highlighted with Shiki.
Post loader:
// src/lib/mdx.ts
import path from "node:path";
import fs from "node:fs/promises";
import matter from "gray-matter";
import { bundleMDX } from "mdx-bundler";
import remarkGfm from "remark-gfm";
const POSTS_DIR = path.join(process.cwd(), "src/content/posts");
export async function getPostSlugs() {
const files = await fs.readdir(POSTS_DIR);
return files.filter((f) => f.endsWith(".mdx")).map((f) => f.replace(/\.mdx$/, ""));
}
export async function getPostBySlug(slug: string) {
const file = await fs.readFile(path.join(POSTS_DIR, `${slug}.mdx`), "utf8");
const { content, data } = matter(file);
const result = await bundleMDX({
source: content,
mdxOptions(options) {
options.remarkPlugins = [...(options.remarkPlugins ?? []), remarkGfm];
return options;
},
});
return { code: result.code, frontmatter: data as any };
}
Projects Page (SSR + Live GitHub Data)
// src/lib/github.ts
export async function fetchRepos(username: string) {
const headers: HeadersInit = { Accept: "application/vnd.github+json" };
if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
const res = await fetch(
`https://api.github.com/users/${username}/repos?sort=updated&per_page=100`,
{ headers, next: { revalidate: 3600 } }
);
if (!res.ok) throw new Error(`GitHub: ${res.status}`);
const repos = (await res.json()) as any[];
return repos.filter((r) => !r.fork);
}
Resume PDF Streaming
// src/app/resume/download/route.ts
import { NextResponse } from "next/server";
import ReactPDF, { Document, Page, Text } from "@react-pdf/renderer";
import { resume } from "@/lib/resume-data";
export async function GET() {
const Doc = (
<Document>
<Page size="A4" style={{ padding: 32 }}>
<Text style={{ fontSize: 24 }}>{resume.name}</Text>
<Text>{resume.title}</Text>
<Text>{resume.summary}</Text>
</Page>
</Document>
);
const stream = await ReactPDF.renderToStream(Doc);
return new NextResponse(stream as any, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": "attachment; filename=resume.pdf",
},
});
}
Tailwind Setup
Tailwind v4
npm i -D tailwindcss @tailwindcss/postcss postcss autoprefixer
// postcss.config.mjs
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};
Tailwind v3
npm i -D tailwindcss@3 postcss@8 autoprefixer@10
export default {
plugins: { tailwindcss: {}, autoprefixer: {} }
};
Deployment Notes
- Use Postgres in production (SQLite is for dev only).
- Add rate limiting to
/api/comments. - Set
runtime = "nodejs"for routes using native modules (like bcrypt or React PDF). - Prefer server components; use client ones only for forms or interactivity.
Closing Thoughts
The result is a fully server-rendered, dynamic portfolio that behaves like a real app:
authenticated features, a maintainable blog, live GitHub data, and a downloadable resume.
It’s fast, simple to maintain, and feels genuinely alive — not just a static page with a few animations.