Back
Portfolio Site
Year
2026
Tech & Technique
React, Tailwind, GSAP, Railway, NodeJS
Overview
A fullstack portfolio site with react, nextjs, supabase, hosted frontend on Vercel and backend on Railway
About the Project
Hello! First off thank you guys for taking the time to visit my portfolio and being interested enough to read one of my 'showcases'/ 'devblog'. This portfolio site is a self-managing portfolio CMS with:
- Next.js 14 App Router frontend
- Full admin dashboard CRUD interface
- Node.js backend API
- Database + file upload system
- Automatic structure documentation
- On-demand ISR revalidation
Part 1: Introduction
Most portfolios are just static pages, where the content have to be modified by directly modifying the code or by integrating Content Management Software (CMS) to your personal site. I however, would like a challenge, with the advancement in generative AI in recent years, I have take it upon myself to learn backend development and try to build a self maintaining portfolio site. This article walks through exactly how this architecture works, the decisions behind it, and how you can build one too.
Part 2: Architecture Design
Typically, it is industry standard to separate the application into 3 layers, namely the Presentation Layer (Frontend), Application Layer (Backend) and Data Layer (Database). The architecture in my case will be:
CLIENT → Next.js 14 (App Router, TypeScript, Tailwind)
SERVER → Express (ESM, Supabase, multer, bcrypt)
DB → Supabase (PostgreSQL + Storage)
The frontend is deployed on Vercel. The backend lives in a separate container and exposes a REST API at api.edichan.com. They communicate over HTTPS, with cookies carrying the session between them.
Why not use Next.js API routes for everything? I wanted a backend I could evolve independently, hit from other clients if I ever needed to, and reason about in isolation from React rendering concerns. Express also made the auth and file upload logic simpler to control.
The public-facing portfolio pages use ISR (Incremental Static Regeneration) - they're pre-rendered at build time and revalidated on demand when I publish changes through the admin panel. Visitors get static-fast pages; I still get a live CMS.
Part 3: Backend Admin Panel (The mini CMS)
The site has a hidden /admin route — a full CRUD dashboard for managing projects, experience, skills, site settings, and media uploads. No external CMS dependency just a custom-built interface talking to my own API.
Content flow:
1. I log into /admin/login
2. Make changes through the dashboard (add project, upload image, etc.)
3. Hit "Publish" — which fires a POST to /api/admin/revalidate
4. The backend calls Next.js's on-demand revalidation endpoint
5. The static pages are rebuilt with the new data
The whole thing means a content update takes seconds to go live without a full redeploy.
Part 4: Authentication
1 of my biggest worries when publishing my personal site was the security, as I have never built anything security related from the ground up, so this required many hours of research because I don't want my site breached. Here is the auth stack:
- Browser: SHA-256 hash the password (Web Crypto API)
-> Send hash, never plaintext
- Server: bcrypt.compare(SHA-256 hash, stored bcrypt)
-> DB stores bcrypt(SHA-256), never the hash itself
The double-layer means: even if the network layer is compromised, the server only receives a hash. Even if the DB is compromised, bcrypt makes brute-forcing the stored value expensive.
Sessions are stateful not JSON Web Tokens (JWT). A UUID token is generated on login, stored in a Supabase `sessions` table with an expiry timestamp, and sent as an HttpOnly cookie. Every protected request queries the sessions table to validate and check expiry. This means I can invalidate sessions server-side instantly, which JWTs can't do without a blocklist.
Cookie flags differ by environment:
Production: Secure=true, SameSite=None, Domain=.edichan.com
Development: Secure=false, SameSite=Lax, Domain=<omitted>
That last distinction caused me a lost afternoon — the original code hardcoded Domain=.edichan.com and SameSite=None everywhere. Browsers silently drop SameSite=None cookies without the Secure flag, and reject cookies whose domain doesn't match the current host. Login appeared to succeed (the server returned 200) but the cookie was never stored, so every subsequent request was treated as unauthenticated.
Part 5: Security Layers
Beyond the auth system, several layers protect the API:
RATE LIMITING
├── Login endpoint: 10 attempts / 15 min / IP (brute force protection)
├── Admin routes: 100 requests / 15 min / IP
└── Global: 2000 requests / 15 min / IP
CORS
└── Explicit whitelist via CLIENT_ORIGIN env var. Development also
allows any localhost origin. Credentials are included on every
request (necessary for the cross-domain cookie to travel).
HELMET
└── Applied globally. Sets CSP, HSTS, X-Frame-Options, and more.
Cross-origin resource policy set to 'cross-origin' so the frontend
can load assets served from the API domain.
ROUTE PROTECTION
└── requireAuth middleware applied at the router level on all admin
route files. Every endpoint in those files inherits it automatically.
AUDIT LOGGING
└── All admin write actions (create, update, delete, login) are logged
to an admin_logs table non-blocking. I can always reconstruct what
changed and when.
Part 6: File Uploads
Media uploads are handled with multer using memory storage — the file buffer never hits disk before we decide where to put it.
Validation before storage:
- MIME type must be image/jpeg, image/png, or image/webp
- File size capped at MAX_FILE_SIZE_MB (default 5MB)
- Filename replaced with a UUID before storage
Storage is env-driven:
STORAGE_BACKEND=local → writes to ./uploads on disk (dev)
STORAGE_BACKEND=supabase → uploads to Supabase Storage bucket (prod)
Part 7: Smooth Animations
The frontend leans heavily on GSAP (GreenSock Animation Platform) for
anything motion-related. A few highlights:
SCROLL ENTRANCE ANIMATIONS
Section containers animate in from below as they enter the viewport, using ScrollTrigger with scrub for a parallax-adjacent feel.
FLOATING PROJECT THUMBNAILS (desktop)
On the projects section, hovering a project row shows a floating thumbnail that follows the cursor vertically. The image tracks the mouse with a 1-second GSAP tween so it feels weighty rather than instant. All sibling rows dim to 30% opacity on hover, drawing focus to the hovered item.
ANIMATED EXTERNAL-LINK ICON
Each project row has an SVG icon that draws itself on hover using strokeDasharray / strokeDashoffset animation — the box, arrow line, and arrow tip each animate in sequence, then loop. GSAP context ensures the animation state is properly cleaned up on mouse leave.
GRADIENT TITLE SWEEP
Project titles use a two-stop background gradient clipped to text. On hover, the background-position shifts from right to left over 700ms, creating a colour sweep effect using only Tailwind's bg-[length:200%] and group-hover:bg-left.
VIEW TRANSITIONS
On navigation between the projects list and individual project pages, the thumbnail image uses the View Transitions API (viewTransitionName) to morph smoothly from the list thumbnail to the hero image — a native browser transition, no JS animation needed.
Part 8: Data Strategy
The portfolio content has two sources:
1. Static data in lib/data.ts — hardcoded fallback values
2. Live data from /api/projects — the actual CMS content
On first render, static data populates the UI immediately. The useEffect
fires, fetches from the API, and replaces the content if the response is
non-empty. This means:
- No loading skeleton flash on first paint
- The page works even if the API is down
- Content updates appear within one navigation cycle
The static data is intentionally kept in sync with the production DB as a safety net incase production DB goes down, though in practice there would be redundant servers on standby.
Part 9: ENVIRONMENT HANDLING
A recurring theme in this build was behaviour that differed between local and production. The lessons:
- Cookie flags must be environment-aware. Hardcoding production values breaks local development silently.
- External resources (Supabase buckets, environment variables) must be treated as potentially absent. Add initialization guards, not just error messages.
- CORS and NEXT_PUBLIC_ variables couple the two servers. Changes to one server's URL require corresponding changes to the other's environment config.
- NODE_ENV is the single source of truth for environment branching. Avoid adding new environment flags that mirror it.
Part 10: Stack Summary
Frontend -> Next.js 14 (App Router), TypeScript, Tailwind CSS
Animation -> GSAP + ScrollTrigger, View Transitions API
Backend -> Node.js, Express, ESM modules
Database -> Supabase (PostgreSQL)
Storage -> Supabase Storage (prod), local disk (dev)
Auth -> Custom — bcrypt, UUID sessions, HttpOnly cookies
Security -> Helmet, express-rate-limit, CORS whitelist
Deployment -> Vercel (frontend), containerised Express (backend)
edichan.com — Edison Chan — 2025
Get In Touch
Contact Me
Have a project in mind or just want to say hello? Drop me a message and I'll get back to you within 24 hours.
© 2026· Built with Next.js & GSAP