Skip to content
Back to Blog
AI Portfolio Builder

How We Built PortfolioOS: A Full-Stack Architecture Deep Dive

DS
Drew Sepeczi
|
12 min read

Building a SaaS platform that turns a resume, LinkedIn profile, or GitHub account into a polished portfolio sounds simple. But under the hood, it's a carefully orchestrated pipeline spanning AI extraction, schema validation, auth, payments, and rendering. Here's the full architecture breakdown — from monorepo structure to deployment.

PortfolioOS is a Next.js 16 monorepo with four packages — a main app, a shared schema library, a React component library for rendering portfolios, and a Python-based PDF parser. It's powered by Supabase for auth and storage, Stripe for billing, and the Vercel AI SDK with OpenRouter for AI-powered data extraction and chat. Let's walk through each layer.

The Big Picture

Before diving into individual pieces, here's the system at a glance. The architecture is organized into three columns: data sources on the left, the core platform in the center, and rendering on the right. Infrastructure services sit beneath, powering everything.

PortfolioOS system architecture overview

Click to expand — PortfolioOS architecture showing the data flow from ingestion through validation, storage, and rendering.

Monorepo Structure: Four Packages, One Source of Truth

We chose a pnpm workspace monorepo because the platform naturally splits into distinct concerns that need to share types. Four packages, each with a clear boundary:

  • apps/main — The Next.js 16 App Router. Handles all routes, API endpoints, auth, billing, and the admin dashboard. This is the command center.
  • packages/schema (published as portfolioly-schema) — The single source of truth for all data shapes. Zod schemas, TypeScript types, and the critical mapBackendToDisplay() transformer. Every other package depends on this.
  • packages/template-components (published as portfolioly-template-components) — React components that render portfolio pages. Supports both static traditional layouts and an AI chat mode where visitors can ask questions about the portfolio owner.
  • packages/pdf_parser — A Python utility that extracts structured text from uploaded resumes. Runs as a command-line tool invoked by the main app.

The build order is enforced: schema → template-components → main. Both Turbopack (local dev) and Docker (production) respect this dependency chain.

Why a monorepo works here

Shared types eliminate drift between API responses and component props. When the schema package updates, the template-components package immediately knows about new fields. No version mismatch bugs. The workspace:* protocol means we're always developing against the latest local code.

Data Pipeline: The Backbone

The core innovation is the data pipeline. Portfolio data flows through five stages, each with a clear responsibility. Understanding this pipeline is understanding the entire platform.

Stage 1: Ingestion

Data can enter the system four ways. Each path feeds into the same validation pipeline, making the ingestion methods completely interchangeable:

  • PDF upload — The Python parser extracts text from resumes, which the AI then structures into portfolio data.
  • GitHub OAuth — We pull repos, profile info, and contribution activity. Repos are sorted by stars and recency.
  • AI extraction — Paste a LinkedIn URL or resume text, and the AI SDK with OpenRouter models extracts structured work experiences, projects, education, and skills.
  • Manual editor — A rich dashboard where users build portfolios step by step with real-time preview.

Stage 2: Validation

Every piece of data, regardless of source, passes through the same Zod schemas in portfolioly-schema. No data enters the database without passing validation. This is enforced at the API handler level — there's no way to skip it.

The schema package defines one canonical representation: PortfolioData. It uses snake_case keys, DateInfo objects for dates, and strongly typed enums for things like skill levels and project types. This representation is designed for the backend — it's what goes into Supabase.

Stage 3: Storage

Supabase handles three things: PostgreSQL for structured data (portfolios, users, subscriptions, tokens), Supabase Storage for binary assets (avatars, project images), and Supabase Auth for user sessions. We use three client factories — browser, server, and admin — each with appropriate permission levels managed in src/lib/supabase.ts.

Stage 4: Transformation

Here's where the architecture gets interesting. The backend representation (PortfolioData) is not what components render. We have a strict separation:

AspectPortfolioData (backend)DisplayPortfolioData (frontend)
Key stylepersonal_infoprofile
Work historywork_experiencesexperience
Dates{ month: 1, year: 2020 }"Jan 2020"
TransformmapBackendToDisplay() — called at the API client layer, never in components

The transformer runs in the API client (apiClient.ts) before data reaches any component. Components never deal with snake_case or DateInfo objects. This is enforced by convention and type checking — the template-components package only accepts DisplayPortfolioData.

Stage 5: Rendering

The template-components package consumes DisplayPortfolioData and renders it in two modes:

  • Static portfolio — A traditional resume-style layout at /p/[username]. Clean, professional, with sections for experience, projects, education, and skills.
  • AI chat mode — Visitors can ask questions about the portfolio owner. Uses RAG (retrieval-augmented generation) to provide accurate, context-aware answers. The portfolio data becomes a knowledge base.

Auth & Route Protection

We use Supabase Auth with a custom middleware pattern. There's no Next.js middleware.ts file — instead, route protection happens at the handler level via protectRoute():

  • requireAuth — Checks the Supabase session cookie. Returns 401 if missing.
  • requireVerification — Checks email_confirmed_at. Returns 403 if unverified.
  • requirePro — Checks the subscription plan and status. Returns 402 if not paid. Supports pro and lite tiers.

This pattern is cleaner than Next.js middleware because it keeps auth logic co-located with the routes it protects. No global regex matching against path patterns. Each handler declares what it needs.

Client-side auth

On the client, useAuth() from AuthContext.tsx provides session state, loading status, and auth actions. The context uses @supabase/ssr for server-side rendering compatibility.

Infrastructure That Scales

Five external services power the platform. Each was chosen for a specific reason:

  • Supabase — Combines PostgreSQL, auth, and storage in one platform. Row-level security enforces data access at the database level. Three client factories (browser, server, admin) prevent accidental privilege escalation.
  • Stripe — Handles payments and subscriptions. We use the latest API version (2026-04-22.dahlia). Webhooks update the Supabase subscriptions table. The customer portal lets users manage their own billing.
  • AI SDK + OpenRouter — Vercel AI SDK v5 with OpenRouter as the model provider. Used for two purposes: structured data extraction from resumes and LinkedIn, and the AI chat mode on live portfolios.
  • Upstash Redis — Rate limiting only. A sliding window algorithm: 10 requests per minute per IP, 50 requests per hour per user. Gracefully falls back when Redis credentials are absent (important for local development).
  • PostHog — Product analytics and event tracking. Helps us understand how users interact with the editor, which templates they choose, and where they drop off.

Build & Deploy

Development uses Turbopack for both dev and production builds — fast HMR and incremental compilation. The production Docker image is a multi-stage build:

  1. Basenode:22-alpine with Yarn 4.9.4
  2. Builder — Copies the entire monorepo, installs dependencies, builds schema → template-components → main in order
  3. Runner — Extracts only .next/standalone output, static files, and public assets. Runs as a non-root nextjs user on port 3000

CI uses Yarn (not pnpm) with Changesets for versioning. The templates subtree is synced to a separate repository for independent versioning and consumption.

Architecture Principles We Follow

A few principles guide every decision in this codebase:

  1. Single source of truth — The schema package owns all type definitions. No local types in the main app or components.
  2. Transform at the boundary — Data shape changes happen at API client boundaries, never deep in components.
  3. Per-handler protection — Auth is colocated with route handlers, not in a global middleware file.
  4. Graceful degradation — Services like Redis have fallback behavior. The platform works without them in development.
  5. No empty catch blocks — Every error is logged or surfaced. Swallowed errors become production mysteries.

Want to build something similar?

The architecture described here is production-tested. Start with the schema package, add validation, then build outward. A monorepo with shared types prevents more bugs than any testing framework. Check the architecture docs for code examples and patterns.

Build your portfolio in minutes

Import from LinkedIn, GitHub, or your resume. AI generates a stunning portfolio automatically.

DS

Drew Sepeczi

Creator of PortfolioOS — building AI-powered tools that help developers create stunning portfolios in minutes.

Related Portfolio Tools

Share this post:XLinkedIn