EDICHANNN

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:
  1. Cookie flags must be environment-aware. Hardcoding production values breaks local development silently.
  2. External resources (Supabase buckets, environment variables) must be treated as potentially absent. Add initialization guards, not just error messages.
  3. CORS and NEXT_PUBLIC_ variables couple the two servers. Changes to one server's URL require corresponding changes to the other's environment config.
  4. 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