DEV Community

Phanindhra Kondru
Phanindhra Kondru

Posted on

Match You CV — Import Your Notion Resume, Tailor It with AI, Export a Perfect PDF

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:

  1. Connect Notion — One-click OAuth to link your Notion workspace
  2. Pick a page — Search and browse your Notion pages, select the one with your resume
  3. Preview & import — See a structured preview of your resume content before importing
  4. Edit in the builder — All your Notion data (contact, experience, education, skills, projects, certifications, languages) is parsed and mapped into our resume editor
  5. AI Tailor — Paste a job description and let AI rewrite your resume to match the role — optimized for ATS
  6. 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);
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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_connections table 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:

  • QuotaExceededErrorFailed 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)

Collapse
 
phanikondru profile image
Phanindhra Kondru

I’d really appreciate your feedback after trying the platform, especially if it helps with your job applications.