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.

Comments