Building a Full-Stack Side Project with Cursor AI - The CareerWeb Journey
Building a Full-Stack Side Project with Cursor AI - The CareerWeb Journey
“When an idea meets an AI coding assistant, one person can build a full-stack web app from scratch.”
While job hunting, I found myself doing the same tedious work over and over: copying job posting URLs, transcribing key details into Notion, and manually comparing requirements against my resume. I wanted to automate this entire workflow, and that’s how CareerWeb was born.
What makes this project special is that it was built entirely through conversations with Cursor AI – from tech stack selection to UI design, API architecture, and LLM prompt engineering. In this post, I’ll share that journey with all the technical details.
Table of Contents
- What is CareerWeb?
- Choosing the Tech Stack - First Conversation with Cursor
- Frontend UI Evolution
- Backend API Design and Vite Proxy
- LLM Integration Trial and Error
- LinkedIn Auto-Fill Implementation
- Notion Integration - Structured Data Store
- ATS Analysis and Job Closure Detection
- Overall Architecture
- Lessons from Developing with Cursor AI
- Conclusion
What is CareerWeb?
CareerWeb is a personal job posting tracker that connects job collection, LLM parsing, Notion organization, and ATS analysis into one seamless pipeline.
Core Features:
- Job Input: Paste a LinkedIn URL to auto-fill title, body, company name, and posting date
- LLM Analysis: Google Gemini extracts structured fields – salary, work type, required/preferred skills, experience
- Notion Storage: Automatically creates Notion database pages with formatted summaries and original text
- ATS Score: Compares your resume (Notion page) against the posting to generate a fit score, gaps, and suggestions
- Closure Detection: Revisits LinkedIn pages to detect when a position is no longer accepting applications
┌─────────────────────────────────────────────────────┐
│ CareerWeb Flow │
│ │
│ URL Input → LinkedIn Parse → DB Save → LLM Parse │
│ ↓ ↓ │
│ Auto-fill Notion Page Creation ← Structured │
│ ↓ │
│ Resume Comparison → ATS Score/Feedback │
└─────────────────────────────────────────────────────┘
Choosing the Tech Stack - First Conversation with Cursor
The project began with explaining the idea to Cursor:
“I want to build a web app where I paste a job posting URL, it gets organized in Notion, and my resume is compared against it for an ATS score.”
From this single sentence, Cursor and I worked through the technical decisions together.
Frontend: Vite 7 + React 19
We chose Vite + React for rapid development. The deliberate choice to skip TypeScript was about prioritizing speed for a side project.
npm create vite@latest CareerWeb -- --template react
Backend: FastAPI + SQLModel + SQLite
Python was the natural choice given its rich LLM and scraping ecosystem. FastAPI offered auto-documentation and async support that fit perfectly.
fastapi # Async web framework
google-genai # Gemini API client
httpx # HTTP client (LinkedIn scraping)
beautifulsoup4 # HTML parsing
notion-client # Notion API
sqlmodel # ORM (SQLAlchemy + Pydantic)
uvicorn # ASGI server
Monorepo vs Separate Repos
Initially I considered a monorepo, but through discussion with Cursor, we decided on separate repositories:
- Independent dependency management for frontend and backend
- Cleaner .gitignore and environment configuration
- Clearer context when working in Cursor
This resulted in two repos: CareerWeb (frontend) and CareerWeb-backend (backend).
Frontend UI Evolution
Initial: Card-Based Layout
The first UI Cursor generated consisted of JobForm, SummaryList, DataModeToggle, and AtsResult components in a card-based layout. Results appeared as cards below the input form.
Pivot: Table List View
After actual usage, comparing many postings in card form was difficult. When I told Cursor “a table-based list would be more efficient,” the UI changed dramatically.
Key Changes:
- “New Posting” button switches to form (list-first view)
- Company, title, date, links, ATS score displayed in one row
- Click to expand detail panel
Toolbar and Filtering
// App.jsx - State management
const [searchQuery, setSearchQuery] = useState('')
const [sortOrder, setSortOrder] = useState('recent')
const [hideClosed, setHideClosed] = useState(false)
Adding search, sort (by date or ATS score), and closed-posting hiding dramatically improved usability. Cursor suggested clean filtering logic using useMemo.
Expandable Detail Panel
Clicking a table row reveals a detail panel directly below it:
// SummaryList.jsx - Detail panel toggle
const [detailId, setDetailId] = useState(null)
{isDetailOpen && (
<tr className="detail-row">
<td colSpan={8}>
<div className="detail-card">
{/* position, workType, salaryRange, experience */}
{/* required/preferred skills */}
{/* ATS analysis: score, feedback, re-analyze, close */}
</div>
</td>
</tr>
)}
The detail panel contains position, work type, salary, experience, skills, and ATS results – all visible from a single row.
Backend API Design and Vite Proxy
RESTful API Endpoints
POST /api/job-postings # Submit posting (async processing)
GET /api/job-postings # List all postings
PUT /api/job-postings/{id} # Update a posting
POST /api/job-postings/preview # LinkedIn URL preview
POST /api/job-postings/{id}/ats # Re-run ATS analysis
POST /api/job-postings/{id}/close # Mark as closed
Async BackgroundTasks Pattern
LLM parsing, Notion page creation, and ATS analysis are time-consuming. To avoid blocking the user, we leverage FastAPI’s BackgroundTasks:
@app.post("/api/job-postings")
def submit_job_posting(
payload: JobPostingCreate,
background_tasks: BackgroundTasks,
session: Session = Depends(get_session),
):
# Duplicate check
duplicate = crud.find_duplicate_by_job_id(session=session, job_id=job_id)
if duplicate:
return {"status": "duplicate", "existingId": duplicate.id}
# Save to DB immediately
job = crud.create_job_posting(session=session, ...)
# Heavy processing in background
background_tasks.add_task(
process_job_posting,
job_id=job.id,
title=payload.title,
body=payload.body,
url=payload.url,
posted_at_hint=payload.postedAt,
company_name_hint=payload.companyName,
)
return {"status": "queued", "jobId": job.id}
The frontend shows “Analysis pending” and uses optimistic updates to immediately add the item to the list.
Vite Proxy for CORS
In development, the frontend (port 5173) and backend (port 8000) run on different ports. Vite’s proxy configuration handles this cleanly:
// vite.config.js
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
})
The API client uses relative paths (/api/job-postings), and Vite automatically proxies to the backend.
SQLModel - Pydantic Meets SQLAlchemy
SQLModel lets us define a single model that covers both the DB schema and API schema:
class JobPosting(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
job_id: Optional[str] = Field(default=None, index=True)
company_name: Optional[str] = None
title: str
url: Optional[str] = None
body: str
posted_at: Optional[datetime] = None
salary_range: Optional[str] = None
work_type: Optional[str] = None
position: Optional[str] = None
required_skills: Optional[str] = None
preferred_skills: Optional[str] = None
notion_url: Optional[str] = None
ats_score: Optional[int] = None
ats_feedback: Optional[str] = None
status: str = "queued"
hiring_status: str = "채용중"
created_at: datetime = Field(default_factory=datetime.utcnow)
Choosing SQLite was intentional – for a personal local app, a single file database with no separate server is more than enough.
LLM Integration Trial and Error
The LLM integration was where we experienced the most trial and error. Cursor and I iterated through multiple options to find the right fit.
Attempt 1: HuggingFace Serverless API
First, we tried the free HuggingFace Serverless Inference API with Mistral, Qwen, and flan-t5 models. All returned 410 Gone errors – HuggingFace had deprecated their free Serverless API.
Attempt 2: Ollama (Local LLM)
“How about a local LLM?” I asked Cursor, and it suggested Ollama + llama3.1:8b. It worked well locally without any API keys, but the dependency on local environment was a limitation.
Final Choice: Google Gemini API
We settled on the Google Gemini API. The gemini-3.1-flash-lite-preview model proved fast, affordable, and capable enough for JSON extraction tasks.
from google import genai
def call_gemini(prompt: str) -> str:
client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))
response = client.models.generate_content(
model=os.getenv("GEMINI_MODEL", "gemini-1.5-flash"),
contents=prompt,
config={
"temperature": 0.2,
"max_output_tokens": 1024,
},
)
return response.text
Three Roles for the LLM
1. Structured Field Extraction (parse_job_fields)
def parse_job_fields(title: str, body: str) -> dict:
prompt = (
"Extract the following fields from the job posting in JSON: "
"salaryRange, workType (Remote, Onsite, Hybrid), "
"hybridDaysOnsite (number of onsite days if Hybrid), "
"postedAt, position, requiredSkills, preferredSkills, "
"requiredExperience. If unknown, use null.\n\n"
f"Title: {title}\n\nBody:\n{body}\n"
)
text = call_gemini(prompt)
parsed = json.loads(text) # with fallback parsing
return parsed
Extracts salary, work type, skills, experience, and more from unstructured posting text.
2. Job Body Formatting (format_job_body)
def format_job_body(title: str, body: str) -> str:
prompt = (
"You are a helpful editor. Rewrite the job posting body into a clear, "
"well-structured summary in Korean. Use short sections with headings "
"and bullet points. Organize using these headings where possible: "
"역할, 자격요건, 우대사항, 베네핏.\n\n"
f"Job Title: {title}\n\nJob Posting:\n{body}\n"
)
return call_gemini(prompt)
Rewrites English postings into structured Korean summaries for better readability in Notion.
3. ATS Analysis (analyze_ats)
def analyze_ats(*, title: str, body: str, resume_text: str) -> dict:
prompt = (
"You are an ATS reviewer. Return JSON with fields: "
"score (0-100 integer), gaps (array of short strings), "
"suggestions (array). "
"Use only information from the resume and job posting.\n\n"
f"Job Title: {title}\n\nJob Posting:\n{body}\n\n"
f"Resume:\n{resume_text}\n"
)
return json.loads(call_gemini(prompt))
Compares resume text against the posting to generate a fitness score, gaps, and improvement suggestions.
Safe JSON Parsing
LLM responses aren’t always clean JSON. They might be wrapped in markdown code blocks or include explanatory text. We added fallback parsing:
try:
parsed = json.loads(text)
except json.JSONDecodeError:
json_start = text.find("{")
json_end = text.rfind("}")
if json_start == -1 or json_end == -1:
raise ValueError("Invalid response")
parsed = json.loads(text[json_start : json_end + 1])
LinkedIn Auto-Fill Implementation
This was the most impactful feature for daily usability. Just paste a LinkedIn URL to auto-fill title, body, company name, and posting date.
Frontend: URL Input Debounce
// JobForm.jsx - Auto-preview on URL change
useEffect(() => {
if (urlAutoTimer.current) clearTimeout(urlAutoTimer.current)
const url = form.url.trim()
if (!url || url === lastPreviewUrl.current || autoPreviewed.current) return
urlAutoTimer.current = setTimeout(() => {
handlePreview()
autoPreviewed.current = true
}, 600)
}, [form.url])
After a 600ms debounce, the preview is automatically triggered. The lastPreviewUrl ref prevents duplicate requests for the same URL.
Relative Time Conversion
LinkedIn shows posting dates as “1 month ago”, “3 weeks ago”. We convert these to actual dates:
const normalizePostedAt = (value) => {
if (!value) return ''
const relativeMatch = value.match(
/(\d+)\s+(day|week|month|hour|minute)s?\s+ago/i,
)
let date = null
if (relativeMatch) {
const amount = Number(relativeMatch[1])
const unit = relativeMatch[2].toLowerCase()
date = new Date()
if (unit === 'day') date.setDate(date.getDate() - amount)
if (unit === 'week') date.setDate(date.getDate() - amount * 7)
if (unit === 'month') date.setMonth(date.getMonth() - amount)
}
return date ? date.toISOString().slice(0, 10) : ''
}
Backend: Multi-Level Fallback Parsing
LinkedIn page HTML structure varies by login state, region, and time. A single parsing strategy isn’t enough, so we designed a multi-level fallback structure:
def parse_linkedin_job(html: str) -> dict:
soup = BeautifulSoup(html, "html.parser")
# Title: h1 → og:title → <title>
title = _extract_title(soup)
# Company: <title> parse → JSON-LD → og:description → About section → raw HTML
company_name = _extract_company_from_title_text(page_title)
if not company_name:
company_name = _extract_json_ld_company(soup)
if not company_name:
company_name = _extract_company_from_og_description(soup)
if not company_name:
company_name = _extract_company_from_about_section(soup)
if not company_name:
company_name = _extract_company_from_html_raw(html)
# Body: About the job section → show-more-less markup → og:description → JSON-LD
body = _extract_about_section(soup)
if not body:
body = _extract_show_more_markup(soup)
if not body:
body = _extract_json_ld_description(soup)
# Login wall detection
if not title or not body:
if _is_login_wall(text_blob):
return {"fallback": "manual", "reason": "login_required"}
return {"title": title, "body": body, "companyName": company_name, "postedAt": posted_at}
This fallback structure successfully parses most LinkedIn postings. When parsing fails, users see a “Manual input required” message.
Company Name Extraction Logic
LinkedIn <title> tags typically follow the pattern "Job Title | Company Name | LinkedIn". But some include the “hiring” keyword, so we handle both patterns:
def _extract_company_from_title_text(title_text):
# "Company hiring Job Title..." pattern
hiring_match = re.match(r"^(.*?)\s+hiring\b", title_text, flags=re.IGNORECASE)
if hiring_match:
return hiring_match.group(1)
# "Job Title | Company | LinkedIn" pattern
parts = [part.strip() for part in title_text.split("|") if part.strip()]
if len(parts) >= 3 and parts[-1].lower() == "linkedin":
return parts[1]
return None
Notion Integration - Structured Data Store
The Notion Integration Challenge
The trickiest part of Notion API integration was workspace permissions. Initially, I tried connecting an Integration in a Personal workspace, but Database Connections only worked properly in a Team workspace. Cursor and I debugged this together.
Page Structure
Each Notion page is organized as follows:
┌─────────────────────────────────────┐
│ [Properties] │
│ Name: Job Title │
│ Company: Company Name │
│ URL: Original Link │
│ Position: Role Title │
│ WorkType: Remote/Hybrid/Onsite │
│ SalaryRange: Salary Range │
│ RequiredSkills: Required Skills │
│ PostedAt: Posting Date │
├─────────────────────────────────────┤
│ [Body] │
│ LLM-formatted Korean summary │
│ (Role / Requirements / Nice-to- │
│ have / Benefits) │
│ │
│ ── Original ── │
│ Original English posting text │
├─────────────────────────────────────┤
│ [ATS Analysis] │
│ Score: 75 │
│ Gaps: [...] │
│ Suggestions: [...] │
└─────────────────────────────────────┘
Text Chunking
The Notion API has a 2000-character limit per rich_text block. We implemented chunking and batch append for long posting bodies:
def chunk_text(text: str, max_len: int = 1800) -> list[str]:
normalized = (text or "").strip()
if not normalized:
return ["-"]
return [normalized[i : i + max_len] for i in range(0, len(normalized), max_len)]
def append_children_in_batches(
notion, page_id, children, batch_size=80
):
for start in range(0, len(children), batch_size):
notion.blocks.children.append(
block_id=page_id,
children=children[start : start + batch_size]
)
Notion Sync on Update
When a posting is updated, both the DB and Notion page are synchronized. Existing blocks are deleted and recreated:
def update_notion_page(*, notion, page_id, title, url, body, parsed):
# Update properties
notion.pages.update(page_id=page_id, properties=properties)
# Delete all existing blocks
cursor = None
while True:
response = notion.blocks.children.list(block_id=page_id, start_cursor=cursor)
for block in response.get("results", []):
notion.blocks.delete(block_id=block["id"])
if not response.get("has_more"):
break
cursor = response.get("next_cursor")
# Append new blocks
append_children_in_batches(notion, page_id, children)
ATS Analysis and Job Closure Detection
ATS Analysis Flow
The ATS (Applicant Tracking System) analysis compares your resume against a job posting to score fitness:
Resume (Notion page) ──┐
├─→ Gemini API ─→ { score, gaps, suggestions }
Job posting body ──────┘
- Extract resume text from a Notion page via the API
- Send both the resume and job posting to Gemini
- Receive a 0-100 score, gaps, and improvement suggestions as JSON
- Append results to the Notion job page and save to DB
Job Closure Detection
LinkedIn posting pages are revisited to check for “No longer accepting applications” text or missing “Apply” buttons. Closed postings are shown with grey rows and can be filtered with a “Hide closed” toggle.
@app.post("/api/job-postings/{job_id}/close")
def close_job_posting(job_id: int, session: Session = Depends(get_session)):
job = crud.get_job_posting(session=session, job_id=job_id)
crud.update_job_posting(session=session, job=job, hiring_status="종료")
return {"status": "closed", "jobId": job_id}
The frontend visually distinguishes closed postings:
<tr className={[
submission.id === activeId ? 'active' : '',
isClosed ? 'summary-row-closed' : '',
].filter(Boolean).join(' ')}>
Overall Architecture
┌───────────────────────────────────────────────────────────────┐
│ Frontend (React + Vite) │
│ ┌──────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ JobForm │ │ SummaryList │ │ apiClient.js │ │
│ └──────────┘ └──────────────┘ └───────┬───────┘ │
│ │ /api proxy │
├──────────────────────────────────────────┼───────────────────┤
│ Backend (FastAPI) │ │
│ ┌───────────┐ ┌──────────┐ ┌──────────▼───────┐ │
│ │ models.py │ │ crud.py │ │ main.py │ │
│ │ schemas.py│ │ │ │ (API Routes) │ │
│ └─────┬─────┘ └────┬─────┘ └──┬──────┬───────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
│ │ SQLite │ │ parsers/ │ │ services.py │ │
│ │ (data.db)│ │linkedin │ │ (LLM+Notion) │ │
│ └──────────┘ └────┬─────┘ └──┬─────┬───────┘ │
│ │ │ │ │
├─────────────────────┼───────────┼─────┼──────────────────────┤
│ External Services │ │ │ │
│ ┌───────▼──┐ ┌─────▼─┐ ┌▼───────────┐ │
│ │ LinkedIn │ │Gemini │ │ Notion API │ │
│ │(Scraping)│ │ API │ │ │ │
│ └──────────┘ └───────┘ └────────────┘ │
└───────────────────────────────────────────────────────────────┘
Data Flow Summary:
- User enters URL -> LinkedIn HTML scraping -> Auto-fill
- Submit posting -> Immediate DB save -> Background processing starts
- Gemini API for structured parsing + body formatting
- Notion page auto-creation (Properties + formatted body + original text)
- Resume vs posting ATS analysis -> Results appended to Notion
- Frontend list management (search, sort, detail view, edit)
Lessons from Developing with Cursor AI
What Worked Well
1. Rapid Scaffolding
“Create a Vite + React project with a job posting form and result list component” – and the basic structure appears immediately. Boilerplate writing time was dramatically reduced.
2. Repetitive CRUD Code
The create/read/update/delete functions in crud.py, Pydantic models in schemas.py, API response mappings – Cursor generated these consistently and accurately.
3. Debugging Partner
When the HuggingFace API returned 410, when Notion permissions failed – working through root causes and finding alternatives with Cursor felt like genuine pair programming with a colleague.
4. UI/UX Improvement Ideas
Saying “I think a table would work better than cards” prompted an immediate table-based UI proposal, along with proactive suggestions for toolbar, filtering, and detail panels.
5. Understanding the Whole Process
Beyond just generating code, Cursor engaged in discussions about “why this technology,” “what are the trade-offs of this pattern” – genuine technical decision-making conversations.
Things to Watch Out For
1. Context Window Limitations
In long development sessions, early decisions sometimes weren’t reflected later. Important decisions should be explicitly re-stated.
2. Precise Requirements Matter
“Make it prettier” produces vague results. “Change the card layout to a table, with expandable detail panels on row click” yields much better outcomes.
3. External API Changes
For issues like HuggingFace API deprecation or LinkedIn HTML structure changes, Cursor may not have the latest information. Sharing exact error messages helps find alternatives quickly.
Why AI Pair Programming Suits Side Projects
- Reduced decision cost: Technology choices that would take hours of solo deliberation can be quickly discussed and decided
- Flattened learning curves: Unfamiliar technologies (Notion API, Gemini API) can be applied immediately with examples
- Sustained motivation: Visible rapid progress keeps the project from stalling
- Quality maintenance: Error handling and edge cases are addressed together, not forgotten
Conclusion
CareerWeb started from a simple frustration – “organizing job postings is tedious” – and evolved into a full-stack web app through conversations with Cursor AI.
Tech Stack Summary:
| Area | Technology |
|---|---|
| Frontend | Vite 7 + React 19 |
| Backend | FastAPI + SQLModel + SQLite |
| LLM | Google Gemini API |
| Data Storage | Notion API |
| Scraping | BeautifulSoup + httpx |
| Development Tool | Cursor AI |
The most impressive takeaway from building with Cursor was that AI isn’t just a code generation tool – it’s a partner for technical decision-making. Of course, not every decision should be delegated to AI. Final judgment and direction remain the developer’s responsibility. But the perspectives AI provides and its rapid prototyping capability significantly increase the completion rate of side projects.
If you’re job hunting, building an automation tool like this can itself become a great portfolio piece. And in that process, an AI coding assistant like Cursor will be a reliable partner.
The CareerWeb project discussed in this post is available on GitHub.