MakeMyAISite: AI-Powered Portfolio Generation from LinkedIn Profiles and Resumes

April 5, 2026 (4mo ago)

What is MakeMyAISite

MakeMyAISite is a SaaS platform that generates polished portfolio websites from LinkedIn profiles and resumes. You paste a LinkedIn URL or upload a resume, AI does the heavy lifting, and you get a live, customizable portfolio site in minutes. It's live in production at makemyaisite.com.

I built the entire backend, all frontend functionality, and the DevOps pipeline. The design work was done by someone else — everything else was me.

Here's the scope:

What Numbers
Prisma Models 20+
API Versions 2 (V1 + V2, running in parallel)
AI Generation Functions 6 (hero, footer CTA, interview Q&A, skills, etc.)
LinkedIn Scraping Providers 3 (Apify, Scrapin, Proxycurl)
Zustand Stores 7
Template Variants 6
Third-Party Integrations 10+
Developer 1 (me)

The AI Content Generation Pipeline

This is the core of the product and the thing I'm most proud of. The idea is simple: take whatever we know about a person — their LinkedIn data, their resume text, their manually entered info — and use AI to generate polished portfolio content.

I built 6 generation functions that each handle a different piece of the portfolio:

  1. Hero section — title and description for the landing area
  2. Footer CTA — a compelling call-to-action
  3. Interview Q&A — 5 question-answer pairs that read like a real conversation
  4. Skill extraction — pulls and categorizes skills from raw text
  5. Experience summaries — rewrites job descriptions to sound sharp
  6. Resume parsing — structures raw resume text into clean, typed data

The key design decision was using OpenAI's Structured Outputs with Zod schemas. Instead of getting back freeform text and hoping it's in the right shape, I tell the model exactly what shape to return. Every response is type-safe and validated before it touches the database.

Here's what the core pattern looks like:

// OpenAI Structured Output with Zod
const response = await openai.chat.completions.create({
  model: "gpt-4o-mini",
  temperature: 0.5,
  response_format: zodResponseFormat(TitleAndDescriptionSchema, "hero"),
  messages: [
    { role: "system", content: heroSectionPrompt },
    { role: "user", content: candidateContext },
  ],
});

The zodResponseFormat function takes a Zod schema and a name, and OpenAI guarantees the response conforms to that schema. No parsing, no regex extraction, no "please respond in JSON" prompt hacking. The model's output is structurally valid or the call fails. This means I never have to write defensive parsing code downstream — if the call succeeds, the data is correct.

Each of the 6 functions has its own Zod schema tailored to what that section needs. The interview Q&A schema defines an array of exactly 5 objects each with a question and answer field. The skill extraction schema defines categorized skill groups. Everything is typed end-to-end from the AI response through to the React components.

Rate Limiting

AI calls cost money, so I built tiered rate limiting through Redis:

Tier Daily AI Generations
FREE 5
PAID 50

Redis tracks usage per user per day. The counter resets at midnight UTC. Simple, but it keeps costs predictable.


The LinkedIn Import Pipeline

This is where things get interesting from an engineering perspective. Importing a LinkedIn profile is a 3-stage pipeline:

Stage 1: Scrape. The user pastes a LinkedIn URL. The system tries to scrape it using one of three providers — Apify, Scrapin, or Proxycurl. If the first provider fails or returns bad data, it falls through to the next one. This fallback strategy was essential because no single LinkedIn scraping service is 100% reliable. They all have different failure modes: rate limits, profile access issues, data format changes. Having three means the user almost never sees a failure.

Stage 2: Transform and Normalize. Each provider returns data in a different shape. Apify's response looks nothing like Proxycurl's. So there's a normalization layer that maps all three formats into a single internal schema. This was tedious to build but saved enormous complexity downstream — every other part of the system only needs to understand one data shape.

Stage 3: AI Enhance. The normalized LinkedIn data feeds into the AI generation pipeline. Raw LinkedIn descriptions become polished portfolio copy. Job titles get cleaned up. Skills get categorized. The person's career story gets reshaped into something that reads well on a portfolio site.

The whole pipeline runs in sequence, and each stage has its own error handling and logging. If scraping fails across all three providers, the user gets a clear error. If normalization hits an edge case, it logs the raw data for debugging. If AI enhancement fails, the user still gets their raw LinkedIn data — just without the polish.


Resume Parsing Pipeline

Not everyone has a LinkedIn profile they want to use, so I built a parallel import path through resume uploads.

The flow: user uploads a PDF or DOCX file, the server extracts raw text, converts it to markdown, and then the AI structures it into typed profile data.

PDF extraction uses pdf-parse and DOCX uses mammoth. The raw text output is messy — inconsistent spacing, weird formatting artifacts, headers mixed with body text. The markdown conversion step cleans this up into something the AI can reason about more effectively.

Then the AI takes that markdown and returns structured data matching the same internal schema that the LinkedIn pipeline produces. Same Zod schemas, same validation, same downstream path. This means the rest of the application doesn't care whether your data came from LinkedIn or a resume — it all looks the same by the time it reaches the profile builder.


Section-Based Profile Architecture

This was the most important data modeling decision in the project. Every portfolio is made up of 11+ sections — hero, about, experience, education, skills, projects, certifications, testimonials, interview Q&A, footer CTA, and more.

Each section is its own Prisma model with a 1:1 relationship to the profile. This might seem like overkill compared to stuffing everything into one big JSON column, but it paid off in several ways:

  1. Independent updates. Updating your skills section doesn't touch your experience data. No merge conflicts, no partial update bugs.
  2. Selective loading. The public portfolio page can fetch only the sections it needs for the active template.
  3. Section-level AI generation. Each AI function targets exactly one section model. Clean boundaries.
  4. Drag-and-drop reordering. Section order is stored at the profile level, and because each section is independent, reordering is just an array swap — no data restructuring.

Media files use a polymorphic MediaFilesV2 table. Instead of each section having its own file storage logic, there's one media table that references back to any section via a type discriminator. Profile photos, project screenshots, certification badges — they all go through the same upload and storage path.


Presigned S3 Upload Flow

File uploads were a performance decision I'm glad I made early. The traditional approach is: browser sends file to your server, server uploads to S3. That means your server is a middleman for every file, eating bandwidth and CPU for no reason.

Instead, MakeMyAISite uses presigned S3 URLs. The flow:

  1. Browser asks the server for an upload URL (includes file type and size)
  2. Server generates a presigned S3 PUT URL (valid for 15 minutes)
  3. Browser uploads the file directly to S3 using that URL
  4. Browser tells the server the upload is done, server saves the S3 key to the database

The server never touches the file data. This means a 5MB profile photo doesn't consume any server memory or bandwidth. It goes straight from the user's browser to S3. The presigned URL includes content-type and size constraints, so you can't abuse it to upload unexpected file types or oversized files.


State Management with 7 Zustand Stores

On the frontend, state management is split across 7 focused Zustand stores. Each store owns one domain:

The most complex one is useUserProfileStore. It manages the full lifecycle of every section — loading states, dirty tracking, save operations, AI generation status, and optimistic updates. When a user clicks "Generate with AI" on their hero section, this store coordinates the API call, updates the loading state, writes the result into the section data, and marks it as unsaved until the user explicitly saves.

The alternative would have been one giant store or React context. But with 11+ sections, 6 AI generation functions, and drag-and-drop reordering all happening in the same UI, a single store would have become unmanageable. Seven focused stores means each one is testable, readable, and does one thing well.


V1/V2 API Coexistence

As the product evolved, I needed to make breaking changes to the API without taking the app down. So I built a versioned API system where V1 and V2 routes run in parallel.

Both versions share the same Express server and the same database. The routing layer separates them by prefix (/api/v1/... and /api/v2/...). V2 introduced the new section-based architecture and the MediaFilesV2 polymorphic table. V1 still works for any older clients or integrations that haven't migrated.

The migration was zero-downtime. I deployed V2 alongside V1, migrated the frontend to use V2 endpoints one by one, and V1 stays available as a fallback. No flag day, no big bang cutover.


Template System and Drag-and-Drop

Users pick from 6 template variants for their portfolio. Each template defines its own layout and styling, but they all consume the same section data. This means switching templates is instant — no data transformation needed, just a different rendering layer.

Section reordering uses @dnd-kit for drag-and-drop. Users can rearrange their portfolio sections in whatever order they want. The order is stored as an array of section identifiers on the profile model, and the template renderer reads that array to determine display order. Dropping a section into a new position triggers an optimistic update in the Zustand store and a background save to the API.

I also integrated driver.js for onboarding tours, so first-time users get guided through the profile builder step by step.


Vanity URL System

Every portfolio gets a URL like makemyaisite.com/p/sandeep-sharma. Users can customize this slug, and the system needs to guarantee uniqueness in real time.

As the user types their desired URL, a debounced check fires against the API to verify availability. The debounce is set to 300ms so it doesn't hammer the server on every keystroke, but it's fast enough that the user gets near-instant feedback.

On the backend, the uniqueness check and the slug reservation happen in a single Prisma transaction. This prevents the race condition where two users check the same slug simultaneously, both see it as available, and both try to claim it. The transaction ensures only one succeeds.

For auto-generated slugs (when a user signs up via LinkedIn import), the system generates a slug from the person's name and appends a short random suffix if there's a collision.


Tech Stack

Layer What's Used
Frontend Next.js 14, React 18, TypeScript, Zustand (7 stores), React Hook Form + Zod, @dnd-kit
Backend Node.js, Express.js, TypeScript, Prisma ORM, MongoDB
AI OpenAI gpt-4o-mini (Structured Outputs + Zod schemas)
Auth Supabase Auth
Payments Stripe
Storage AWS S3 (presigned uploads)
Caching Redis (rate limiting, session data)
Real-Time Socket.io
Transcription RevAI
LinkedIn Scraping Apify, Scrapin, Proxycurl
Onboarding driver.js
DevOps Docker, GitHub Actions, AWS Amplify, Hostinger KVM, AWS ECS Fargate

Deployment

The application runs on a dual deployment pipeline:

Hostinger KVM handles the primary backend deployment. It's a straightforward Docker-based setup — GitHub Actions builds the image, pushes it to the registry, and the KVM server pulls and restarts.

AWS ECS Fargate is the secondary deployment target, used for scaling and redundancy. Fargate means no server management — just define the task, and AWS handles the rest.

The frontend deploys through AWS Amplify, which gives automatic builds on push and preview deployments for pull requests.


What I Took Away

  1. OpenAI Structured Outputs with Zod schemas changed how I think about AI integration. It's not about prompting harder — it's about constraining the output shape so the rest of your code can trust it.
  2. Building a 3-provider fallback for LinkedIn scraping taught me that reliability in third-party integrations comes from redundancy, not from picking the "best" provider.
  3. The section-based architecture was the right call. Every time I added a new feature — AI generation, drag-and-drop, template switching — the clean section boundaries made it straightforward.
  4. Presigned S3 uploads are one of those patterns that feels like cheating. Zero server load for file uploads. I'll use this on every project going forward.
  5. Shipping a production SaaS solo means owning every decision. No one to blame, no one to ask. That's the fastest way to learn.