Project README
Rendered from README.md
Last updated: 10/20/2018, 1:46:40 AM
Ryan Doering — Portfolio Website
A personal portfolio website built with Next.js 16 (App Router), TypeScript, React 19, and Tailwind CSS v4. The site is deployed to Vercel and serves as the primary online presence I share with prospective employers and collaborators. It features a project showcase, a rotating photo gallery, an announcements carousel, a live calendar integration, a freelance inquiry form, and a protected admin dashboard for managing all site content without redeploying.
Live site: https://ryandoering.com
Table of Contents
- Project Overview
- Features
- Tech Stack
- Repository Layout
- Environment Variables
- Production Build & Deployment
- Database Migrations (Supabase)
- Media / Blob Storage
- Admin Area & Content Management
- Photo Processing
Project Overview
This site was built as both a professional portfolio and a real production application — it's the same stack and infrastructure I'd use for a client project, just pointed at my own content.
The goals were:
- Performance-first — static-first Next.js pages with server-side data fetching, image preloading, and a minimal JavaScript footprint on the client.
- No-redeploy content updates — an authenticated admin UI lets me publish new projects, upload media, and post announcements from the browser. Content is persisted in Supabase (projects and announcements) and Vercel Blob (media), so changes are live instantly.
- Clean, maintainable code — TypeScript throughout, clear separation between server and client components, and isolated server-side helpers in
src/lib/.
Features
Home Page (/)
The homepage acts as the entry point for visitors and recruiters. It contains:
- Hero section — name, title, and avatar.
- Rotating project gallery — a client-side carousel that automatically cycles through portfolio projects every 15 seconds. Each card shows the project's thumbnail and title, and clicking it navigates to the full project detail page. On page load the server pre-embeds the project list as a JSON
<script>tag so the gallery hydrates instantly without an extra API round-trip. The server also injects<link rel="preload">tags for each project thumbnail and apreconnecthint to the Vercel Blob CDN host. - External links card — a companion card matching the gallery's dimensions, with buttons linking to GitHub, LinkedIn, and email.
- Announcements carousel — fetches announcements from
/api/announcements, displays the most recent, and auto-advances every 8 seconds. Supports keyboard navigation (left/right arrows) and manual prev/next buttons.
Projects (/projects and /projects/[slug])
- Projects index — a responsive two-column grid of
ProjectCardcomponents, each showing the project title, summary, tech-stack chips, and thumbnail. Cards are fetched server-side and sorted by animportancefield. - Project detail page — dynamically routed at
/projects/[slug]. Displays the full description, a media gallery (images and videos), tech-stack tags, and external links (GitHub, live demo, etc.). Video sources include a WebM fallback if a matching file is found inpublic/. Image captions are derived from filenames automatically or from explicitcaptionsmetadata.
About (/about and /about/gallery)
- About page — personal bio covering background, interests, and what I'm looking for in my next role. Includes an inline photo gallery powered by
PhotoGalleryServer(a server component that reads the photo manifest) andPhotoGridClient(the client-side rotating/grid display). - Gallery subpage — full-page view of processed photos and videos from the
/about/galleryroute.
Resume (/resume)
- Embedded PDF viewer using an
<object>tag with an 80 vh height, so the resume is fully readable in-browser. - "Open in new tab" and "Download PDF" buttons for maximum compatibility across browsers and devices.
Calendar (/calendar and /calendar/event/[id])
- Fetches events from a private iCloud calendar via a secret
.icsURL configured in environment variables. - The server-side helper (
src/lib/calendar.ts) parses the.icsfeed with theicalpackage, expands recurring events using theirRRULE, and returns only events within the next 7 days. - Results are cached in memory for 5 minutes to avoid hammering the calendar feed on every request.
- Individual events are accessible at
/calendar/event/[id]showing full details including description, location, and time.
Freelance (/freelance)
- A dedicated page describing freelance web design and development services — landing pages, e-commerce, performance audits, and full deployments.
- Includes a contact form (
ContactForm) that collects name, email, project type, budget range, and a message, then posts to/api/contact. - The API route saves every submission to a Supabase
contact_submissionstable for a durable backup record, then forwards the inquiry via the SendGrid API ifSENDGRID_API_KEYis set. - A per-IP rate limiter (configurable window via
RATE_LIMIT_WINDOWenv var, defaulting to 60 s) protects the endpoint from spam. Rate limiting respectsx-forwarded-for,x-real-ip, andcf-connecting-ipheaders so it works correctly behind Vercel's proxy layer.
Admin Dashboard (/admin)
A protected content management UI for updating the site without redeploying. In production, access requires signing in with a GitHub account whose email is in the ADMIN_EMAILS environment variable. Authentication is handled by NextAuth.js with the GitHub OAuth provider.
The dashboard has three tabbed sections:
Media tab
- Upload sub-tab — drag or browse to upload images. Files are streamed server-side to Vercel Blob via
@vercel/bloband the resulting public URLs are saved to the Supabasephotostable. The manifest drives both the homepage gallery and the about page gallery. - Delete gallery sub-tab — view all uploaded photos and delete individual ones from both Vercel Blob and the Supabase manifest atomically.
- Upload sub-tab — drag or browse to upload images. Files are streamed server-side to Vercel Blob via
Projects tab
- Create sub-tab — form to add a new project: title, slug, summary, full description, tech-stack tags, image URLs, optional captions, external links, and an importance value. On submit, the new project is written to Supabase (
projects_kvtable) and immediately visible on the site. - Edit sub-tab — load any existing project by selecting it from a dropdown, edit any field inline, and save or delete it.
- Create sub-tab — form to add a new project: title, slug, summary, full description, tech-stack tags, image URLs, optional captions, external links, and an importance value. On submit, the new project is written to Supabase (
Announcements tab
- Form to publish a new announcement: title, body text, and an optional image. Records are inserted into Supabase's
announcementstable and appear in the homepage carousel within seconds.
- Form to publish a new announcement: title, body text, and an optional image. Records are inserted into Supabase's
API Routes
All API routes live under src/app/api/ and run as Node.js serverless functions on Vercel.
| Route | Method | Purpose |
|---|---|---|
/api/announcements |
GET |
Returns all announcements (Supabase → local file fallback) |
/api/admin/announcements |
POST |
Create a new announcement (auth-gated) |
/api/admin/projects |
GET / POST / PUT / DELETE |
CRUD for projects (auth-gated) |
/api/admin/media |
POST |
Upload media to Vercel Blob (auth-gated) |
/api/admin/media/migrate |
POST |
Migrate local media references to Blob URLs |
/api/calendar |
GET |
Fetch and return upcoming calendar events |
/api/contact |
POST |
Handle freelance inquiry form submissions |
/api/auth/[...nextauth] |
GET / POST |
NextAuth.js handler (GitHub OAuth) |
/api/blob |
various | Direct blob helpers |
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript 5 |
| UI | React 19, Tailwind CSS v4 |
| Fonts | Geist Sans & Geist Mono (Google Fonts) |
| Database | Supabase (PostgreSQL) |
| Media storage | Vercel Blob |
| Auth | NextAuth.js v4 — GitHub OAuth provider |
| Calendar | ical package — parses iCal/.ics feeds |
| SendGrid REST API | |
| Markdown | marked (used in project descriptions) |
| Date utilities | date-fns |
| UUIDs | uuid |
| Hosting | Vercel (serverless functions + Edge CDN) |
| Image processing | sharp (build-time script) |
Repository Layout
.
├── data/ # Runtime-generated JSON data files (gitignored in prod)
│ ├── photos.json # Manifest of uploaded gallery photos
│ └── projects.json # Local project data fallback
├── public/ # Static assets served at the root
│ ├── avatar.png # Homepage avatar image
│ ├── headshot.jpg # Photo used on the about page
│ ├── resume.pdf # Downloadable resume
│ ├── *.svg # Icons (GitHub, LinkedIn, mail, etc.)
│ └── favicon.* # Favicons for all platforms
├── scripts/
│ └── process-photos.js # Sharp-based photo resizing and optimization script
├── src/
│ ├── app/ # Next.js App Router pages and API routes
│ │ ├── layout.tsx # Root layout — NavBar, fonts, metadata
│ │ ├── page.tsx # Home page
│ │ ├── about/page.tsx # About / bio page
│ │ ├── about/gallery/ # Full photo gallery subpage
│ │ ├── projects/page.tsx # Projects index
│ │ ├── projects/[slug]/ # Dynamic project detail pages
│ │ ├── resume/page.tsx # Resume viewer
│ │ ├── calendar/page.tsx # Calendar listing
│ │ ├── freelance/page.tsx # Freelance services + contact form
│ │ ├── admin/page.tsx # Admin dashboard
│ │ ├── admin/login/page.tsx # Sign-in page
│ │ └── api/ # Serverless API routes (see API Routes table above)
│ ├── components/ # Reusable React components
│ │ ├── NavBar.tsx # Primary navigation with dropdown menus
│ │ ├── RotatingGallery.tsx # Auto-cycling project gallery card
│ │ ├── AnnouncementCard.tsx # Auto-advancing announcements carousel
│ │ ├── ProjectCard.tsx # Project summary card for the index page
│ │ ├── PhotoGalleryServer.tsx # Server component: reads photo manifest
│ │ ├── PhotoGridClient.tsx # Client component: renders/animates photos
│ │ ├── ExternalLinksCard.tsx # GitHub / LinkedIn / email link card
│ │ ├── ContactForm.tsx # Freelance inquiry form
│ │ ├── CalendarEvent.tsx # Calendar event display component
│ │ ├── AdminContentManager.tsx # Tabbed admin UI (projects/media/announcements)
│ │ ├── AdminMediaManager.tsx # Media upload UI
│ │ ├── AdminDeleteGallery.tsx # Media deletion UI
│ │ ├── MeetingRequestForm.tsx # Meeting / scheduling request form
│ │ ├── SignInButton.tsx # NextAuth sign-in trigger
│ │ └── SignOutButton.tsx # NextAuth sign-out trigger
│ ├── content/
│ │ └── projects.ts # Project type definition and static seed data
│ ├── lib/ # Server-side helpers
│ │ ├── announcementsStore.ts # Read/write announcements (Supabase-first, local file fallback)
│ │ ├── projectsStore.ts # Supabase-first project CRUD: upsertProject, deleteProject, getAllProjects
│ │ ├── projectsDBStore.ts # Supabase-backed read/write used by the migration utility
│ │ ├── photosStore.ts # Supabase-first photo manifest: insertPhoto, deletePhoto, readPhotos
│ │ ├── auth.ts # isAdmin() helper — checks session email against allowlist
│ │ ├── authOptions.ts # NextAuth configuration (GitHub provider)
│ │ ├── calendar.ts # iCal fetch, parse, and 5-minute in-memory cache
│ │ └── rateLimit.ts # In-memory per-IP rate limiter
│ └── types/
│ ├── ical.d.ts # Type declarations for the `ical` package
│ └── jsx.d.ts # JSX ambient type patches
├── next.config.ts # Next.js config — remote image patterns for Vercel Blob
├── tsconfig.json # TypeScript compiler config
├── eslint.config.mjs # ESLint config
└── postcss.config.mjs # PostCSS / Tailwind config
Environment Variables
The following environment variables are set in the Vercel project dashboard and are never committed to the repository.
| Variable | Required | Description |
|---|---|---|
SUPABASE_URL |
Yes | Supabase project REST URL |
SUPABASE_SERVICE_ROLE_KEY |
Yes | Supabase service-role key (server-side writes) |
SUPABASE_ANON_KEY |
Optional | Supabase anon key (read-only fallback) |
BLOB_READ_WRITE_TOKEN |
Yes | Vercel Blob read/write token for media uploads |
GITHUB_ID |
Yes | GitHub OAuth App client ID (for admin auth) |
GITHUB_SECRET |
Yes | GitHub OAuth App client secret |
NEXTAUTH_SECRET |
Yes | Random secret used to sign NextAuth session tokens |
NEXTAUTH_URL |
Yes | Canonical URL of the site (https://ryandoering.com) |
ADMIN_EMAILS |
Yes | Comma-separated list of email addresses granted admin access |
ICLOUD_CALENDAR_URL |
Yes | Secret iCloud .ics feed URL for calendar events |
SENDGRID_API_KEY |
Yes | SendGrid API key for forwarding contact form submissions |
CONTACT_EMAIL |
Optional | Destination email for contact form messages |
SENDER_EMAIL |
Optional | Sender address used in SendGrid requests |
RATE_LIMIT_WINDOW |
Optional | Contact form rate-limit window in seconds (default: 60) |
Production Build & Deployment
The site is deployed automatically via Vercel's GitHub integration. Every push to the main branch triggers a new production deployment. Preview deployments are created for all other branches.
Build command: next build
Output: A hybrid of statically pre-rendered pages and Node.js serverless functions (one per API route).
Vercel handles:
- Edge CDN caching for static assets and pages
- Serverless function cold-start optimisation
- Automatic HTTPS and domain routing for
ryandoering.com - Environment variable injection at build and runtime
The next.config.ts file whitelists Vercel Blob CDN hostnames (**.public.blob.vercel-storage.com, **.vercel-storage.com) so Next.js's <Image> component can optimise remotely hosted photos.
Database Migrations (Supabase)
The site uses four Supabase tables:
announcements
Stores site announcements shown in the homepage carousel.
create table announcements (
id text primary key,
title text not null,
body text not null,
image text,
created_at timestamptz default now()
);
projects_kv
Stores project data. Each project is saved as an individual row where id is the project slug and payload is the full project object.
create table projects_kv (
id text primary key,
payload jsonb not null
);
photos
Stores the photo manifest for the gallery. Each row is one uploaded photo URL.
create table photos (
url text primary key,
created_at timestamptz default now()
);
contact_submissions
Durable log of every freelance inquiry submitted via the contact form.
create table contact_submissions (
id bigint generated always as identity primary key,
ts timestamptz default now(),
name text,
email text,
project_type text,
budget text,
message text
);
All tables use Row Level Security (RLS) with the service-role key for server-side writes. Every store (announcementsStore.ts, projectsStore.ts, photosStore.ts) follows a Supabase-first pattern and falls back to reading from local JSON files in data/ if the database is unreachable, so the site keeps serving content even during a Supabase outage.
Media / Blob Storage
All gallery images and project media are stored in Vercel Blob — a CDN-backed object store. The upload flow is:
- Admin selects files in the Media tab of the admin dashboard.
- The browser sends a
multipart/form-dataPOSTto/api/admin/media. - The API route streams each file directly to Vercel Blob via the
@vercel/blobSDK (put(key, stream, { access: 'public' })). - The resulting public CDN URL is saved to the Supabase
photostable viainsertPhoto(), so it appears immediately in the gallery.
Blob naming convention: Files are stored under the photos_processed/ prefix with a Unix timestamp prepended to the original filename to prevent collisions: photos_processed/1700000000000-my-photo.jpg.
Deletion is handled by /api/admin/media DELETE requests, which call del() from the Vercel Blob SDK and call deletePhoto() to remove the entry from Supabase simultaneously.
Remote image patterns — next.config.ts explicitly whitelists Vercel Blob hostnames so the Next.js <Image> optimisation pipeline can safely fetch and transform blob-hosted images.
Admin Area & Content Management
The admin dashboard at /admin is the primary interface for keeping the site up to date after initial deployment.
Authentication
Production access requires signing in via GitHub OAuth (NextAuth.js). After signing in, the session email is checked against the ADMIN_EMAILS environment variable. Any email not in that list is rejected and redirected back to the sign-in page. The isAdmin() helper in src/lib/auth.ts performs this check on every protected page and API route.
Content Manager tabs
Media
- Upload — multi-file uploader. Images are streamed to Vercel Blob server-side. Supports common formats: JPEG, PNG, WebP, AVIF, SVG, MP4, WebM.
- Delete gallery — renders all current photos from the Supabase
photostable with a delete button on each. Deletion hits the server, removes the blob from Vercel, and deletes the row from Supabase atomically.
Projects
- Create — fill in all project fields (title, slug, summary, description, tech tags, images, captions, links, importance score) and submit. The new project is upserted into Supabase and immediately live.
- Edit — select an existing project from a dropdown to load all its fields into the editor. Make changes and save, or delete the project entirely with a confirmation prompt.
Announcements
- Publish short update posts with a title, body text, and optional image URL. Announcements are inserted into Supabase and show up in the homepage carousel on the next page load.
Data persistence hierarchy
Every store follows the same pattern: Supabase first, local file fallback. This means:
- In production with Supabase configured, all reads and writes go to the database.
- If the database is unreachable (e.g. during a Supabase outage), the site continues to serve the last-known data from local JSON files in
data/(reads only — writes require Supabase in production, since Vercel's serverless filesystem is read-only). - This prevents a complete site outage due to a third-party service being temporarily unavailable.
Photo Processing
A Node.js script at scripts/process-photos.js handles batch photo optimization using the sharp library. Run it with:
npm run photos:process
The script:
- Reads source images from a configurable input directory.
- Resizes images to a maximum dimension (width and height capped) while preserving aspect ratio.
- Converts to optimized JPEG or WebP depending on the source format.
- Writes the processed files to
public/photos/so they are available at static URLs. - Optionally generates multiple sizes for responsive
srcsetusage.
Processed photos in public/photos/ are then uploaded to Vercel Blob via the admin UI to make them available on the CDN, or referenced directly via their /photos/... public path for the about page gallery.
Video files (.mp4) are handled separately — the project detail page checks for a matching .webm file at the same path and includes it as a <source> fallback in the <video> element for broader browser compatibility.
Contact: ryan.w.doering@gmail.com · LinkedIn · GitHub