This is a submission for the Notion MCP Challenge
What I Built
Match You CV is an AI career assistant that lets job seekers import their resume directly from Notion, tailor it to any job description using AI, and export a polished, ATS-friendly PDF — all in under 10 minutes.
The core workflow:
- Connect Notion — One-click OAuth to link your Notion workspace
- Pick a page — Search and browse your Notion pages, select the one with your resume
- Preview & import — See a structured preview of your resume content before importing
- Edit in the builder — All your Notion data (contact, experience, education, skills, projects, certifications, languages) is parsed and mapped into our resume editor
- AI Tailor — Paste a job description and let AI rewrite your resume to match the role — optimized for ATS
- Export PDF — Download a clean, professional PDF ready to submit
We also provide a Notion resume template that users can duplicate into their workspace, fill in their details, and import directly into Match You CV.
Why Notion?
Many professionals already maintain their career information in Notion — it's their personal knowledge base. Rather than forcing users to re-type everything into yet another form, we meet them where their data already lives. Notion becomes the single source of truth for your career history, and Match You CV becomes the tool that transforms it into job-winning applications.
Live at: matchyou.cv
Video Demo
Show us the code
The Notion integration spans the full stack — frontend (React + TypeScript), backend (Supabase Edge Functions / Deno), and database (Postgres with RLS). Here's the complete implementation:
Database Schema
Stores Notion OAuth tokens per user with Row Level Security:
-- supabase/migrations/20250306000000_notion_connections.sql
create table if not exists public.notion_connections (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
access_token text not null,
workspace_id text not null,
workspace_name text,
bot_id text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(user_id)
);
alter table public.notion_connections enable row level security;
create policy "Users can read own connection"
on public.notion_connections for select
using (auth.uid() = user_id);
create policy "Users can delete own connection"
on public.notion_connections for delete
using (auth.uid() = user_id);
Backend: Notion OAuth Edge Function
Handles token exchange, connection status, and disconnection — all server-side so secrets never touch the browser:
// supabase/functions/notion-auth/index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
const cors = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
}
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { ...cors, "Content-Type": "application/json" },
})
}
async function getUser(req: Request) {
const auth = req.headers.get("Authorization")
if (!auth?.startsWith("Bearer ")) return null
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
)
const {
data: { user },
} = await supabase.auth.getUser(auth.slice(7))
return user
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS")
return new Response(null, { headers: cors })
try {
const user = await getUser(req)
if (!user) return jsonResponse({ error: "Unauthorized" }, 401)
const { action, code, redirectUri } = await req.json()
if (action === "exchange")
return await handleExchange(user.id, code, redirectUri)
if (action === "status") return await handleStatus(user.id)
if (action === "disconnect")
return await handleDisconnect(user.id)
return jsonResponse({ error: "Unknown action" }, 400)
} catch (e) {
return jsonResponse({ error: String(e) }, 500)
}
})
async function handleExchange(
userId: string,
code: string,
redirectUri: string
) {
const clientId = Deno.env.get("NOTION_OAUTH_CLIENT_ID")
const clientSecret = Deno.env.get("NOTION_OAUTH_CLIENT_SECRET")
if (!clientId || !clientSecret)
return jsonResponse({ error: "Notion OAuth not configured" }, 500)
// Exchange code for access token
const tokenRes = await fetch(
"https://api.notion.com/v1/oauth/token",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization:
"Basic " + btoa(`${clientId}:${clientSecret}`),
},
body: JSON.stringify({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
}),
}
)
if (!tokenRes.ok) {
const err = await tokenRes.text()
return jsonResponse(
{ error: "Notion token exchange failed", details: err },
502
)
}
const tokenData = await tokenRes.json()
// Store in database
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
)
const { error: dbError } = await supabase
.from("notion_connections")
.upsert(
{
user_id: userId,
access_token: tokenData.access_token,
workspace_id: tokenData.workspace_id,
workspace_name: tokenData.workspace_name ?? null,
bot_id: tokenData.bot_id,
updated_at: new Date().toISOString(),
},
{ onConflict: "user_id" }
)
if (dbError)
return jsonResponse(
{ error: "Failed to save connection", details: dbError.message },
500
)
return jsonResponse({
connected: true,
workspaceName: tokenData.workspace_name,
})
}
async function handleStatus(userId: string) {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
)
const { data } = await supabase
.from("notion_connections")
.select("workspace_name, workspace_id, created_at")
.eq("user_id", userId)
.single()
return jsonResponse({
connected: !!data,
workspaceName: data?.workspace_name ?? null,
})
}
async function handleDisconnect(userId: string) {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
)
await supabase
.from("notion_connections")
.delete()
.eq("user_id", userId)
return jsonResponse({ connected: false })
}
Backend: Notion Pages Edge Function
Searches the user's workspace and reads page blocks with full pagination:
// supabase/functions/notion-pages/index.ts
import "jsr:@supabase/functions-js/edge-runtime.d.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
const NOTION_API = "https://api.notion.com/v1"
const NOTION_VERSION = "2022-06-28"
const cors = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
}
function jsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { ...cors, "Content-Type": "application/json" },
})
}
async function getUserAndToken(req: Request) {
const auth = req.headers.get("Authorization")
if (!auth?.startsWith("Bearer ")) return null
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
)
const {
data: { user },
} = await supabase.auth.getUser(auth.slice(7))
if (!user) return null
const { data: conn } = await supabase
.from("notion_connections")
.select("access_token")
.eq("user_id", user.id)
.single()
if (!conn) return null
return { userId: user.id, notionToken: conn.access_token }
}
function notionHeaders(token: string) {
return {
Authorization: `Bearer ${token}`,
"Notion-Version": NOTION_VERSION,
"Content-Type": "application/json",
}
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS")
return new Response(null, { headers: cors })
try {
const ctx = await getUserAndToken(req)
if (!ctx)
return jsonResponse(
{ error: "Unauthorized or Notion not connected" },
401
)
const { action, pageId, query } = await req.json()
if (action === "search")
return await handleSearch(ctx.notionToken, query)
if (action === "read" && pageId)
return await handleRead(ctx.notionToken, pageId)
return jsonResponse({ error: "Unknown action" }, 400)
} catch (e) {
return jsonResponse({ error: String(e) }, 500)
}
})
/** Search for pages in the user's Notion workspace */
async function handleSearch(token: string, query?: string) {
const res = await fetch(`${NOTION_API}/search`, {
method: "POST",
headers: notionHeaders(token),
body: JSON.stringify({
query: query ?? "",
filter: { value: "page", property: "object" },
page_size: 20,
}),
})
if (!res.ok) {
const err = await res.text()
return jsonResponse(
{ error: "Notion search failed", details: err },
502
)
}
const data = await res.json()
const pages = data.results.map((p: any) => {
let title = "Untitled"
if (p.properties) {
for (const prop of Object.values(p.properties) as any[]) {
if (prop.title && prop.title.length > 0) {
title = prop.title
.map((t: any) => t.plain_text)
.join("")
break
}
}
}
return {
id: p.id,
title,
icon: p.icon?.emoji ?? null,
lastEdited: p.last_edited_time ?? null,
}
})
return jsonResponse({ pages })
}
/** Read all blocks from a Notion page and extract text content */
async function handleRead(token: string, pageId: string) {
// Fetch page metadata for title
const pageRes = await fetch(`${NOTION_API}/pages/${pageId}`, {
headers: notionHeaders(token),
})
let pageTitle = ""
if (pageRes.ok) {
const pageData = await pageRes.json()
if (pageData.properties) {
for (const prop of Object.values(pageData.properties) as any[]) {
if (prop.title && prop.title.length > 0) {
pageTitle = prop.title
.map((t: any) => t.plain_text)
.join("")
break
}
}
}
}
// Fetch all blocks with pagination
const blocks: any[] = []
let cursor: string | undefined
do {
const url = new URL(`${NOTION_API}/blocks/${pageId}/children`)
url.searchParams.set("page_size", "100")
if (cursor) url.searchParams.set("start_cursor", cursor)
const res = await fetch(url.toString(), {
headers: notionHeaders(token),
})
if (!res.ok) {
const err = await res.text()
return jsonResponse(
{ error: "Failed to read page", details: err },
502
)
}
const data = await res.json()
blocks.push(...data.results)
cursor = data.has_more ? data.next_cursor : undefined
} while (cursor)
// Convert blocks to structured sections
const sections = extractSections(blocks)
return jsonResponse({ pageTitle, sections })
}
interface Section {
heading: string
items: string[]
}
function getRichText(block: any): string {
const typeData = block[block.type]
if (!typeData?.rich_text) return ""
return typeData.rich_text.map((t: any) => t.plain_text).join("")
}
/** Extract structured sections from Notion blocks */
function extractSections(blocks: any[]): Section[] {
const sections: Section[] = []
let current: Section = { heading: "", items: [] }
for (const block of blocks) {
const type = block.type
if (
type === "heading_1" ||
type === "heading_2" ||
type === "heading_3"
) {
if (current.heading || current.items.length > 0)
sections.push(current)
current = { heading: getRichText(block), items: [] }
} else if (
[
"paragraph",
"bulleted_list_item",
"numbered_list_item",
"to_do",
"toggle",
"callout",
"quote",
].includes(type)
) {
const text = getRichText(block).trim()
if (text) current.items.push(text)
} else if (type === "divider") {
if (current.heading || current.items.length > 0)
sections.push(current)
current = { heading: "", items: [] }
}
}
if (current.heading || current.items.length > 0)
sections.push(current)
return sections
}
Frontend: Notion Service Layer
Client-side functions that communicate with the edge functions:
// src/services/notion.ts
import { supabase } from '../lib/supabase'
const NOTION_CLIENT_ID = import.meta.env.VITE_NOTION_CLIENT_ID
/** Build the Notion OAuth authorization URL */
export function getNotionAuthUrl(): string {
const redirectUri = `${window.location.origin}/notion/callback`
const params = new URLSearchParams({
client_id: NOTION_CLIENT_ID,
response_type: 'code',
owner: 'user',
redirect_uri: redirectUri,
})
return `https://api.notion.com/v1/oauth/authorize?${params}`
}
async function callEdgeFunction(
fnName: string,
body: Record<string, unknown>
) {
if (!supabase) throw new Error('Supabase not configured')
const {
data: { session },
} = await supabase.auth.getSession()
if (!session) throw new Error('Not authenticated')
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const res = await fetch(
`${supabaseUrl}/functions/v1/${fnName}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.access_token}`,
},
body: JSON.stringify(body),
}
)
const data = await res.json()
if (!res.ok) throw new Error(data.error ?? 'Request failed')
return data
}
/** Exchange OAuth code for token and store connection */
export async function exchangeNotionCode(
code: string
): Promise<{ workspaceName: string }> {
const redirectUri = `${window.location.origin}/notion/callback`
return callEdgeFunction('notion-auth', {
action: 'exchange',
code,
redirectUri,
})
}
/** Check if user has a Notion connection */
export async function getNotionStatus(): Promise<{
connected: boolean
workspaceName: string | null
}> {
return callEdgeFunction('notion-auth', { action: 'status' })
}
/** Disconnect Notion */
export async function disconnectNotion(): Promise<void> {
await callEdgeFunction('notion-auth', { action: 'disconnect' })
}
/** Search pages in user's Notion workspace */
export async function searchNotionPages(
query?: string
): Promise<
Array<{
id: string
title: string
icon: string | null
lastEdited: string | null
}>
> {
const data = await callEdgeFunction('notion-pages', {
action: 'search',
query,
})
return data.pages
}
/** Read a Notion page and get structured sections */
export async function readNotionPage(
pageId: string
): Promise<{
pageTitle: string
sections: Array<{ heading: string; items: string[] }>
}> {
const data = await callEdgeFunction('notion-pages', {
action: 'read',
pageId,
})
return { pageTitle: data.pageTitle ?? '', sections: data.sections }
}
Frontend: Intelligent Resume Parser
The brain of the import — converts raw Notion sections into structured resume data:
// src/services/parseNotionResume.ts
import type { Resume } from '../types/resume'
import { emptyResume, emptyContact } from '../types/resume'
interface Section {
heading: string
items: string[]
}
const HEADING_PATTERNS: Record<string, RegExp> = {
contact: /^(contact|info|personal|details)/i,
summary: /^(summary|about|objective|profile|overview)/i,
experience: /^(experience|work|employment|career|professional)/i,
education: /^(education|academic|school|university|degree)/i,
skills: /^(skills|technical|technologies|competencies|expertise)/i,
tools: /^(tools|software|platforms)/i,
projects: /^(projects|portfolio|works)/i,
certifications: /^(certifications?|licenses?|credentials?)/i,
languages: /^(languages?)/i,
}
function classifySection(heading: string): string {
const h = heading.trim()
for (const [key, pattern] of Object.entries(HEADING_PATTERNS)) {
if (pattern.test(h)) return key
}
return 'unknown'
}
/** Parse "Role at Company" or "Role - Company" patterns */
function parseExperienceItem(
text: string
): { role: string; company: string } | null {
if (
/^(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec|\d{4})\b/i.test(
text.trim()
)
)
return null
const match = text.match(
/^(.+?)\s+(?:at|@|-|–|—|,)\s+(.+)$/i
)
if (match)
return { role: match[1].trim(), company: match[2].trim() }
return null
}
/** Extract date ranges like "Jan 2020 - Present" */
function extractDateRange(
text: string
): { dateFrom: string; dateTo: string; rest: string } {
const datePattern =
/\b((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{4}|\d{4})\s*[-–—to]+\s*((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{4}|\d{4}|[Pp]resent|[Cc]urrent|[Nn]ow)\b/
const match = text.match(datePattern)
if (match) {
return {
dateFrom: match[1].trim(),
dateTo: match[2].trim(),
rest: text
.replace(match[0], '')
.replace(/[|,;]\s*$/, '')
.replace(/^\s*[|,;]\s*/, '')
.trim(),
}
}
return { dateFrom: '', dateTo: '', rest: text }
}
function parseContactFragments(
fragments: string[],
resume: Resume
) {
for (const frag of fragments) {
const trimmed = frag.trim()
if (!trimmed) continue
if (trimmed.includes('@') && !resume.contact.email) {
resume.contact.email = trimmed
} else if (
/[\d\-+()]{7,}/.test(trimmed) &&
!resume.contact.phone
) {
resume.contact.phone = trimmed
} else if (!resume.contact.title) {
resume.contact.title = trimmed
} else if (!resume.contact.location) {
resume.contact.location = trimmed
}
}
}
/**
* Parse structured sections from a Notion page into a Resume.
* Uses heuristics to map headings to resume fields.
*/
export function parseNotionSections(
sections: Section[],
pageTitle?: string
): Resume {
const resume: Resume = {
...emptyResume,
contact: { ...emptyContact },
experience: [],
education: [],
skills: [],
tools: [],
otherSkills: [],
projects: [],
certifications: [],
languages: [],
}
// Extract contact info from first section
if (sections.length > 0) {
const first = sections[0]
const firstType = classifySection(first.heading)
if (!first.heading || firstType === 'unknown') {
if (first.heading && firstType === 'unknown') {
resume.contact.name = first.heading
} else if (first.items.length > 0) {
resume.contact.name = first.items.shift()!
}
const fragments: string[] = []
for (const item of first.items) {
fragments.push(...item.split(/\s*[|]\s*/))
}
parseContactFragments(fragments, resume)
}
}
if (!resume.contact.name && pageTitle) {
resume.contact.name = pageTitle
.replace(/^resume\s*template\s*[-–—]\s*/i, '')
.trim()
}
for (const section of sections) {
const type = classifySection(section.heading)
switch (type) {
case 'contact': {
const frags: string[] = []
for (const item of section.items) {
frags.push(...item.split(/\s*[|]\s*/))
}
parseContactFragments(frags, resume)
break
}
case 'summary':
resume.summary = section.items.join('\n')
break
case 'experience': {
let current: any = null
for (const item of section.items) {
const parsed = parseExperienceItem(item)
if (parsed) {
if (current) resume.experience.push(current)
const { dateFrom, dateTo } = extractDateRange(item)
current = {
...parsed,
dateFrom,
dateTo,
location: '',
bullets: [],
}
} else if (current) {
const { dateFrom, dateTo, rest } =
extractDateRange(item)
if (dateFrom) {
current.dateFrom = dateFrom
current.dateTo = dateTo
if (rest) current.location = rest
} else {
current.bullets.push(item)
}
} else {
const { dateFrom, dateTo, rest } =
extractDateRange(item)
current = {
role: rest || item,
company: '',
dateFrom,
dateTo,
location: '',
bullets: [],
}
}
}
if (current) resume.experience.push(current)
break
}
case 'education': {
let current: any = null
for (const item of section.items) {
const { dateFrom, dateTo, rest } =
extractDateRange(item)
if (dateFrom && current) {
current.dateFrom = dateFrom
current.dateTo = dateTo
if (rest && !current.institution)
current.institution = rest
} else if (!current) {
current = {
degree: rest || item,
institution: '',
dateFrom,
dateTo,
}
} else if (!current.institution) {
current.institution = item.trim()
} else {
resume.education.push(current)
current = {
degree: rest || item,
institution: '',
dateFrom,
dateTo,
}
}
}
if (current) resume.education.push(current)
break
}
case 'skills':
for (const item of section.items) {
resume.skills.push(
...item
.split(/[,;|]/)
.map((s) => s.trim())
.filter(Boolean)
)
}
break
case 'tools':
for (const item of section.items) {
resume.tools.push(
...item
.split(/[,;|]/)
.map((s) => s.trim())
.filter(Boolean)
)
}
break
case 'projects':
for (const item of section.items) {
resume.projects.push({ name: item, description: '' })
}
break
case 'certifications':
for (const item of section.items) {
resume.certifications.push({
name: item,
issuer: '',
date: '',
})
}
break
case 'languages':
for (const item of section.items) {
const match = item.match(/^(.+?)\s*[-–—:]\s*(.+)$/)
if (match) {
resume.languages.push({
language: match[1].trim(),
proficiency: match[2].trim(),
})
} else {
resume.languages.push({
language: item.trim(),
proficiency: '',
})
}
}
break
default:
if (section.items.length > 0 && !resume.summary) {
resume.summary = section.items.join('\n')
}
break
}
}
return resume
}
Frontend: OAuth Callback Handler
Handles the redirect after Notion authorization:
// src/pages/NotionCallback.tsx
import { useEffect, useState, useRef } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { exchangeNotionCode } from '../services/notion'
export function NotionCallback() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [status, setStatus] = useState<'loading' | 'error'>('loading')
const exchanged = useRef(false)
useEffect(() => {
if (exchanged.current) return
exchanged.current = true
const code = searchParams.get('code')
const error = searchParams.get('error')
if (error) {
toast.error('Notion authorization was denied')
navigate('/app/import-notion', { replace: true })
return
}
if (!code) {
toast.error('No authorization code received')
navigate('/app/import-notion', { replace: true })
return
}
exchangeNotionCode(code)
.then((data) => {
toast.success(
`Connected to ${data.workspaceName || 'Notion'}!`
)
navigate('/app/import-notion', { replace: true })
})
.catch((err) => {
console.error('Notion exchange failed:', err)
toast.error(err.message || 'Failed to connect Notion')
setStatus('error')
})
}, [searchParams, navigate])
// ... renders loading spinner or error state
}
Frontend: Import Page (UI)
The full import experience — connect, search, preview, and import:
// src/pages/ImportNotion.tsx (key logic, UI simplified for brevity)
import {
getNotionAuthUrl,
getNotionStatus,
disconnectNotion,
searchNotionPages,
readNotionPage,
} from '../services/notion'
import { parseNotionSections } from '../services/parseNotionResume'
export function ImportNotion() {
const navigate = useNavigate()
const { setResume } = useResume()
const [connected, setConnected] = useState(false)
const [workspaceName, setWorkspaceName] = useState<string | null>(null)
const [pages, setPages] = useState([])
const [selectedPageId, setSelectedPageId] = useState<string | null>(null)
const [preview, setPreview] = useState(null)
// Check connection on mount
useEffect(() => {
getNotionStatus().then((status) => {
setConnected(status.connected)
setWorkspaceName(status.workspaceName)
if (status.connected) loadPages()
})
}, [])
const handleConnect = () => {
window.location.href = getNotionAuthUrl()
}
const handleSelectPage = async (pageId: string) => {
setSelectedPageId(pageId)
const sections = await readNotionPage(pageId)
setPreview(sections)
}
const handleImport = () => {
if (!preview) return
const resume = parseNotionSections(
preview.sections,
preview.pageTitle
)
setResume(resume)
toast.success('Resume imported from Notion!')
navigate('/app/editor')
}
// Renders:
// - Not connected: Connect button + link to Notion resume template
// - Connected: Split view with page search (left) and preview (right)
// - Preview: Structured sections with "Import to Resume Editor" button
}
How I Used Notion MCP
Match You CV integrates with Notion through a full OAuth + API pipeline that gives users secure, scoped access to their workspace data.
1. OAuth Authentication Flow
Users connect their Notion workspace with a single click. The flow:
- Authorization — User is redirected to Notion to grant workspace access
- Token Exchange — Our Supabase Edge Function exchanges the auth code for an access token server-side (secrets never touch the browser)
-
Secure Storage — Tokens are stored in a
notion_connectionstable with Row Level Security — users can only access their own connection - Status Check — On every visit to the import page, we verify the connection is still active
- Disconnect — Users can revoke access anytime with one click
2. Workspace Search & Page Reading
Once connected, users interact with their Notion workspace directly from Match You CV:
- Search — Queries all pages in the user's workspace using Notion's Search API. Returns titles, page icons, and last-edited timestamps for easy identification
- Read — Fetches all blocks from a selected page using the Block Children API with full cursor-based pagination (handles pages with 100+ blocks)
- Block Parsing — Supports all common Notion block types: headings (H1/H2/H3), paragraphs, bulleted lists, numbered lists, to-dos, toggles, callouts, quotes, and dividers
3. Intelligent Resume Parsing
This is where Notion data becomes a resume. The parser:
- Classifies sections by matching headings against known resume patterns (experience, education, skills, etc.) using regex heuristics
- Parses experience entries — detects "Software Engineer at Google" or "Designer - Spotify" patterns
- Extracts date ranges — recognizes "Jan 2020 - Present", "2019-2021", and similar formats from free-form text
- Extracts contact info — identifies emails, phone numbers, job titles, and locations from unstructured text
- Handles edge cases — pages with no headings, page titles as names, pipe-separated contact details, multiple date formats
4. Notion Resume Template
We provide a ready-made Notion resume template that users can duplicate into their workspace. It follows a structure optimized for our parser, but the import works with any reasonable resume layout in Notion.
What This Unlocks
- Notion as your career source of truth — Update your resume in Notion, import the latest version anytime
- No re-typing — Skip the tedious form-filling; your Notion content flows directly into the editor
- AI on top of your Notion data — Import from Notion, then use AI Tailor to customize your resume for each job posting
- End-to-end workflow — Notion page -> Import -> AI Tailor -> PDF export, all in one session
The integration turns Notion from a static document store into the starting point of an AI-powered job application pipeline.
Bug Discovered: Notion OAuth Page Crashes for Logged-Out Users
During testing, we discovered a bug on Notion's side. When a user who is not logged into Notion in their browser initiates the OAuth flow (redirected to https://api.notion.com/v1/oauth/authorize), Notion's own authorization page crashes instead of showing a login prompt.
The browser console shows multiple errors originating from Notion's bundled JavaScript:
-
QuotaExceededError—Failed to execute 'setItem' on 'Storage'— Notion's code attempts to write to localStorage without checking available quota -
TypeError: Cannot read properties of undefined (reading 'name')— Internal Notion component tries to access workspace data that doesn't exist for unauthenticated visitors -
ClientError— Multiple Notion client errors logged as the page fails to initialize
These errors are entirely within Notion's minified bundles (app-*.js, mainApp-*.js, ClientFramework-*.js) and cannot be resolved by the integrating application.
Our workaround: We added a visible warning on the import page advising users to log into Notion in their browser before connecting. We also ensure our OAuth callback handler gracefully handles the case where the user returns without a valid authorization code.
Top comments (1)
I’d really appreciate your feedback after trying the platform, especially if it helps with your job applications.