Introduction
Most founders treat SEO like a black box. They stare at Google Search Console dashboards full of impressions and clicks, and struggle to answer the one question that actually matters: what should I do next?
I lived this problem for years building client sites and my own SaaS products. The data was there - thousands of rows of keyword performance, page rankings, CTR trends - but extracting actionable insight required either deep SEO expertise or hours of spreadsheet manipulation. I decided to close that gap by building HeySeo: an AI-powered SEO analytics platform that lets you have a conversation with your search data.
This is the full build story. Architecture decisions, LLM prompt engineering, Google API integration, MCP server design, pricing strategy, and what I learned shipping it. I will share the real code and real mistakes.
The Problem: SEO Data Is Hard to Use
Google Search Console is free, comprehensive, and almost completely impractical for most website owners.
The interface gives you:
- A flat table of queries ranked by impression volume
- Page-level performance broken down by device
- Coverage reports full of confusing error codes
- Core Web Vitals and PageSpeed data buried behind multiple clicks
What it does not give you is answers. You still have to know enough about SEO to look at a 0.8% CTR on a position-4 keyword and reason: "That title tag is underperforming for this intent. Rewrite it."
The professional SEO tools, Ahrefs, Semrush, Moz, solve part of the problem by adding backlink analysis, competitor research, and keyword difficulty scores. But they cost $99–$500 per month, have steep learning curves, and still require significant manual interpretation.
There is a gap between "raw data" and "clear next action" that no tool was filling well for indie developers, small marketing teams, and content creators. That is the gap HeySeo was built for.
The Idea: Chat With Your Data
The insight that kicked everything off was simple. LLMs are genuinely good at reasoning over structured data when you give them the right context. What if instead of building another SEO dashboard, I let users just ask questions?
- "Which pages lost the most ranking in the last 30 days?"
- "What keywords am I ranking 6th to 15th for that I should prioritize?"
- "Why is my homepage CTR so low compared to my blog posts?"
- "Give me a list of SEO tasks I should tackle this week, sorted by expected impact."
That last one is the killer use case. Not just analysis - prioritized recommendations. The LLM can reason about SEO best practices, look at the data, and produce a concrete task list. No expertise required on the user's side.
From that core idea, the feature set expanded:
- Natural language chat interface over Search Console data
- Automated weekly SEO reports delivered to email or Slack
- PageSpeed monitoring with trend tracking and LLM-generated commentary
- Indexing management - check which pages Google has indexed, submit URLs for crawling
- Kanban board for SEO tasks, pre-populated by AI analysis
- MCP server for connecting HeySeo to Claude, Cursor, and Windsurf
Architecture Overview
HeySeo System Architecture
Request flow from browser to LLM and back
I chose Nuxt 3 as the full-stack framework because I know it well, it handles SSR and API routes in a single codebase cleanly, and the Nitro server layer (H3) is genuinely fast. No separate Express backend needed.
The database is Supabase. Google Search Console data is cached aggressively in Redis because the GSC API is rate-limited and relatively slow - you do not want LLM response latency to include a cold GSC fetch on every message.
Building the Chat Interface
The chat UI was the first thing I built because it was the core differentiator. Getting the interaction model right early shaped everything else.
The key design decisions:
Streaming responses. Waiting 5–10 seconds for a complete LLM response kills the conversational feel. I stream tokens from the API route directly to the browser using Server-Sent Events.
// server/api/chat.post.ts
import { streamText } from 'ai'
import { anthropic } from '@ai-sdk/anthropic'
export default defineEventHandler(async (event) => {
const { messages, siteId } = await readBody(event)
const user = await requireAuth(event)
const context = await buildSeoContext(siteId, user.id)
const result = streamText({
model: anthropic('claude-3-5-sonnet-20241022'),
system: buildSystemPrompt(context),
messages,
maxTokens: 2048,
})
return result.toDataStreamResponse()
})Composable context building. Before every chat message, I build a structured SEO context object containing the site's recent data. This gets serialized into the system prompt. I will cover the prompt engineering in detail below.
Message history. I persist the conversation in Supabase so users can return to a thread and continue. Each site has its own conversation history keyed by site_id + user_id.
On the Vue side, the chat component uses the Vercel AI SDK's useChat composable:
<!-- components/SeoChat.vue -->
<script setup lang="ts">
import { useChat } from '@ai-sdk/vue'
const props = defineProps<{ siteId: string }>()
const { messages, input, handleSubmit, isLoading } = useChat({
api: '/api/chat',
body: { siteId: props.siteId },
initialMessages: await loadConversationHistory(props.siteId),
})
</script>
<template>
<div class="flex h-full flex-col">
<ChatMessageList :messages="messages" :is-loading="isLoading" />
<ChatInput
v-model="input"
:disabled="isLoading"
@submit="handleSubmit"
/>
</div>
</template>The ChatMessageList component renders markdown from the assistant - important because the LLM naturally formats SEO recommendations as bullet points, tables, and headers. I used @nuxtjs/mdc for markdown rendering inside the stream.
Connecting Google Search Console
OAuth 2.0 with Google is straightforward in theory and painful in practice. The specific pain point with GSC is that access tokens expire after one hour and you need to handle refresh tokens correctly across long-running sessions.
The Google Search Console API returns data as time-series rows. For a given site and date range, you can query by:
query(the search term)page(the URL that ranked)countrydevice
Each row returns clicks, impressions, ctr, and position. That is the raw material.
// server/utils/gsc.ts
import { google } from 'googleapis'
export async function fetchSearchAnalytics(
accessToken: string,
siteUrl: string,
dateRange: { startDate: string; endDate: string },
dimensions: string[],
): Promise<SearchAnalyticsRow[]> {
const auth = new google.auth.OAuth2()
auth.setCredentials({ access_token: accessToken })
const searchconsole = google.searchconsole({ version: 'v1', auth })
const response = await searchconsole.searchanalytics.query({
siteUrl,
requestBody: {
startDate: dateRange.startDate,
endDate: dateRange.endDate,
dimensions,
rowLimit: 1000,
},
})
return response.data.rows ?? []
}The caching strategy matters a lot here. GSC data for past dates never changes, so I cache at the date level with a 24-hour TTL for the current day and indefinitely for all prior days. This means most chat messages resolve context from Redis in under 50ms rather than making live API calls.
// server/utils/gsc-cache.ts
export async function getCachedAnalytics(
siteId: string,
dateRange: DateRange,
): Promise<SearchAnalyticsRow[]> {
const cacheKey = `gsc:${siteId}:${dateRange.startDate}:${dateRange.endDate}`
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
const rows = await fetchFromGSC(siteId, dateRange)
const ttl = isCurrentDay(dateRange.endDate) ? 3600 : 0
await redis.set(cacheKey, JSON.stringify(rows), ttl > 0 ? { ex: ttl } : undefined)
return rows
}One gotcha: the GSC API returns data with a 3–4 day lag. I learned this the hard way when users asked "what happened to my traffic yesterday" and the LLM confidently said nothing changed - because the data literally was not there yet. I now include a note about the lag in the system prompt and display it in the UI.
LLM Prompt Engineering for SEO Data
This is where most of the iteration happened. The quality of HeySeo's answers depends entirely on giving the LLM the right context in the right format.
The context object. Before each conversation turn, I build a context snapshot containing:
interface SeoContext {
site: {
url: string
verified: boolean
addedAt: string
}
summary: {
last28Days: PerformanceSummary
previous28Days: PerformanceSummary
deltaClicksPct: number
deltaImpressionsPct: number
}
topQueries: SearchAnalyticsRow[] // top 50 by clicks
decliningQueries: SearchAnalyticsRow[] // biggest click drops YoY
topPages: SearchAnalyticsRow[] // top 20 pages by clicks
quickWins: SearchAnalyticsRow[] // pos 4-15, CTR below average
pagespeed: PagespeedSummary | null
pendingTasks: SeoTask[]
recentInsights: Insight[]
}I deliberately limit the data passed to the LLM. Sending all 1000 GSC rows would exceed context limits and add noise. The pre-processing step - computing deltas, surfacing quick wins, flagging declines - does the heavy lifting so the LLM can focus on reasoning and recommendations.
The system prompt. I spent weeks iterating on this. The final version has four sections:
- Role and framing - what HeySeo is, what the assistant's job is
- SEO knowledge base - core principles the LLM should apply (CTR benchmarks by position, quick win identification logic, indexing best practices)
- Current data context - the serialized
SeoContextobject - Response guidelines - how to format answers, when to suggest tasks, tone
function buildSystemPrompt(context: SeoContext): string {
return `You are an expert SEO analyst assistant for HeySeo. Your job is to help the user understand their search performance and take concrete actions to improve it.
## SEO Principles You Apply
- Position 1-3: expected CTR 15-30%. Below 10% suggests a title/meta issue.
- Position 4-15: high-opportunity zone. These keywords rank but don't convert to clicks.
- Quick wins: keywords in positions 4-15 with above-average impressions and below-average CTR.
- A click decline with stable impressions usually means a CTR problem (title, meta description).
- A click decline with impression decline usually means a ranking drop (content, backlinks, technical).
## Current Site Data
Site: ${context.site.url}
Last 28 days: ${context.summary.last28Days.clicks} clicks, ${context.summary.last28Days.impressions} impressions
Change vs prior period: ${context.summary.deltaClicksPct > 0 ? '+' : ''}${context.summary.deltaClicksPct.toFixed(1)}% clicks
Top queries by clicks:
${formatQueryTable(context.topQueries)}
Quick win opportunities (pos 4-15, low CTR):
${formatQueryTable(context.quickWins)}
Pages with biggest click declines:
${formatPageTable(context.decliningQueries)}
## Response Guidelines
- Be specific. Reference actual keywords, URLs, and numbers from the data.
- When suggesting tasks, format them as a numbered list with estimated impact (High / Medium / Low).
- If the user asks something you cannot answer from the available data, say so clearly.
- Keep answers concise. Use markdown tables for data-heavy responses.
- Current data lag: GSC reports with a 3-4 day delay. Mention this when relevant.`
}The formatQueryTable helper converts rows to compact markdown tables that the LLM handles well without wasting tokens on verbose JSON.
Structured output for task generation. When the user asks the assistant to generate a task list, I switch to a structured output call that returns typed SeoTask[] objects which get written directly to the Kanban board:
import { generateObject } from 'ai'
import { z } from 'zod'
const SeoTaskSchema = z.object({
tasks: z.array(z.object({
title: z.string(),
description: z.string(),
category: z.enum(['content', 'technical', 'onpage', 'indexing']),
impact: z.enum(['high', 'medium', 'low']),
effort: z.enum(['high', 'medium', 'low']),
targetUrl: z.string().optional(),
targetKeyword: z.string().optional(),
})),
})
export async function generateSeoTasks(
context: SeoContext,
): Promise<SeoTask[]> {
const { object } = await generateObject({
model: anthropic('claude-3-5-sonnet-20241022'),
schema: SeoTaskSchema,
prompt: buildTaskGenerationPrompt(context),
})
return object.tasks.map((task) => ({
...task,
id: crypto.randomUUID(),
status: 'todo' as const,
createdAt: new Date().toISOString(),
}))
}The Kanban Board for SEO Tasks
One of the features users latched onto immediately was the Kanban board. The chat interface is great for exploration and Q&A, but you need somewhere to capture and track the actual work.
The board has four columns: Todo, In Progress, Done, and Dismissed. Tasks can be generated by the AI, added manually, or created from within a chat conversation (the assistant can say "I've added this to your task board" and actually do it via a tool call).
<!-- components/SeoKanban.vue -->
<script setup lang="ts">
import { useSeoTasks } from '~/composables/useSeoTasks'
const { tasks, moveTask, updateTask, deleteTask } = useSeoTasks()
const columns = [
{ id: 'todo', label: 'To Do' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'done', label: 'Done' },
{ id: 'dismissed', label: 'Dismissed' },
] as const
function onDrop(taskId: string, targetStatus: SeoTask['status']) {
moveTask(taskId, targetStatus)
}
</script>The moveTask function in the composable calls a Supabase RPC that updates the task status and records a timestamped history entry. This history is passed back to the LLM as context so it can see which tasks the user has already completed when generating future recommendations.
Automated Reports and Slack Integration
Not every user wants to open the app to stay informed. Automated reports deliver a weekly summary directly to their inbox or Slack channel.
The report generation runs as a BullMQ job every Monday morning:
// server/jobs/weekly-report.ts
export async function generateWeeklyReport(siteId: string): Promise<void> {
const [context, user, reportSettings] = await Promise.all([
buildSeoContext(siteId, { days: 28 }),
getUserBySiteId(siteId),
getReportSettings(siteId),
])
const { text: reportMarkdown } = await generateText({
model: anthropic('claude-3-5-sonnet-20241022'),
system: REPORT_SYSTEM_PROMPT,
prompt: buildReportPrompt(context),
})
const reportRecord = await saveReport(siteId, reportMarkdown)
if (reportSettings.emailEnabled) {
await sendReportEmail(user.email, reportMarkdown, reportRecord.id)
}
if (reportSettings.slackWebhookUrl) {
await sendSlackReport(reportSettings.slackWebhookUrl, reportMarkdown)
}
}The Slack integration uses incoming webhooks, which keeps the setup friction low - users paste a webhook URL into the settings page and it just works. I considered building a proper Slack app with OAuth, but for the use case (one-way report delivery) webhooks are simpler and more reliable.
The report prompt asks the LLM to structure the output as:
- Performance summary (traffic change, top movers)
- Top 3 wins from the last week
- Top 3 concerns or declines
- Recommended focus areas for the coming week
Short, opinionated, and actionable. Users told me in early feedback that the previous tool reports they received were too long and data-heavy. They wanted a CFO-style briefing, not a spreadsheet.
PageSpeed Monitoring
PageSpeed is the SEO metric most site owners ignore until Google penalizes them for it. HeySeo polls the PageSpeed Insights API weekly for each registered URL and stores the time-series data in Supabase.
The monitoring dashboard shows Core Web Vitals trends (LCP, CLS, FID/INP) with a spark-line chart and a color-coded status badge. When a score drops below a threshold, the system generates an LLM explanation of what likely caused it and what to fix.
// server/utils/pagespeed.ts
export async function fetchPagespeedScore(
url: string,
strategy: 'mobile' | 'desktop' = 'mobile',
): Promise<PagespeedResult> {
const apiUrl = new URL('https://www.googleapis.com/pagespeedonline/v5/runPagespeed')
apiUrl.searchParams.set('url', url)
apiUrl.searchParams.set('strategy', strategy)
apiUrl.searchParams.set('key', process.env.GOOGLE_PAGESPEED_API_KEY!)
const response = await $fetch<PagespeedApiResponse>(apiUrl.toString())
return {
url,
strategy,
score: Math.round((response.lighthouseResult.categories.performance.score ?? 0) * 100),
lcp: response.lighthouseResult.audits['largest-contentful-paint'].numericValue,
cls: response.lighthouseResult.audits['cumulative-layout-shift'].numericValue,
fid: response.lighthouseResult.audits['total-blocking-time'].numericValue,
fetchedAt: new Date().toISOString(),
}
}One thing I learned: the PageSpeed API has its own rate limits and sometimes returns 500 errors for completely valid URLs. I added exponential backoff with 3 retries and silently skip failures rather than surfacing noisy errors to users.
Indexing Management
The indexing section integrates both the Google Search Console Coverage report and the Google Indexing API.
Users can see which pages are indexed, which have errors (404s, redirect chains, blocked by robots.txt), and submit URLs directly for recrawling. For most small sites, the manual submission workflow in GSC is clunky - you have to navigate to URL Inspection, paste the URL, click Request Indexing, and repeat for every page.
HeySeo's indexing manager lets users paste or import a list of URLs, see their current index status in bulk, and queue submission with one click.
// server/api/indexing/submit.post.ts
export default defineEventHandler(async (event) => {
const { urls, siteId } = await readBody<{ urls: string[]; siteId: string }>(event)
const user = await requireAuth(event)
const accessToken = await getGoogleAccessToken(user.id)
const results = await Promise.allSettled(
urls.map((url) => submitUrlForIndexing(accessToken, url)),
)
const succeeded = results
.filter((r): r is PromiseFulfilledResult<IndexingResult> => r.status === 'fulfilled')
.map((r) => r.value)
const failed = results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map((_, i) => urls[i])
await recordIndexingSubmissions(siteId, succeeded)
return { succeeded: succeeded.length, failed: failed.length, failedUrls: failed }
})The Indexing API has a quota of 200 URL submissions per day per property. I surface this limit in the UI and track submissions against it so users do not hit unexpected API errors.
MCP Server Integration
This is the feature I am most excited about. Model Context Protocol (MCP) lets AI coding assistants like Claude Desktop, Cursor, and Windsurf call external tools. I built a HeySeo MCP server that exposes the entire platform's functionality as a set of tools.
The practical result: developers can stay in their editor, ask Claude "what SEO issues does my site have today?" and get a live answer pulled from Google Search Console - without opening a browser.
The MCP server is a standalone Node.js process that wraps the HeySeo API:
// mcp/index.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
const server = new McpServer({
name: 'heyseo',
version: '1.0.0',
})
server.tool(
'get_search_performance',
'Fetch search performance data for a site from Google Search Console',
{
siteUrl: z.string().describe('The site URL registered in HeySeo'),
days: z.number().default(28).describe('Number of days to look back'),
},
async ({ siteUrl, days }) => {
const data = await heyseoClient.getSearchPerformance(siteUrl, { days })
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
}
},
)
server.tool(
'get_seo_recommendations',
'Get AI-generated SEO recommendations for a site',
{ siteUrl: z.string() },
async ({ siteUrl }) => {
const recommendations = await heyseoClient.getRecommendations(siteUrl)
return {
content: [{ type: 'text', text: recommendations.markdown }],
}
},
)
server.tool(
'create_seo_task',
'Add a task to the HeySeo Kanban board',
{
siteUrl: z.string(),
title: z.string(),
description: z.string(),
impact: z.enum(['high', 'medium', 'low']),
},
async (params) => {
const task = await heyseoClient.createTask(params)
return {
content: [{ type: 'text', text: `Task created: ${task.id}` }],
}
},
)
const transport = new StdioServerTransport()
await server.connect(transport)Users install the MCP server via npx heyseo-mcp and add it to their Claude/Cursor config with their API key. The onboarding takes about 2 minutes.
This was the single highest-retention feature I shipped. Users who set up the MCP integration have a 40% lower churn rate than those who only use the web app. The friction of "open browser, navigate to tool, ask question" is real, and removing it changes how often people actually engage with their SEO data.
Pricing Strategy
I spent longer on pricing than on any single technical feature. Here is what I landed on after testing multiple models.
Free tier: One site, 30 days of history, 20 chat messages per month, weekly report summary only. Enough to demonstrate value without giving away the product.
Starter ($19/month): Three sites, 12 months of history, unlimited chat, automated weekly reports, PageSpeed monitoring. This is the personal project / small business tier.
Growth ($49/month): Ten sites, full history, automated reports on any schedule, Slack integration, MCP server access, indexing management. This is the target tier for marketing teams and agencies.
Agency ($149/month): Unlimited sites, white-label reporting, team seats, priority support.
The key insight was that the MCP server and Slack integration needed to be paid features. They are the highest-engagement features and serve users who are clearly getting recurring professional value from the tool. Putting them behind a paywall also creates a clear upgrade moment: when a user is actively using the product in their coding environment, they understand the value proposition concretely.
I also offer an annual discount of 20%, which improves cash flow and reduces churn simultaneously. Roughly 35% of paid users choose annual billing.
Launch and Early Traction
I launched HeySeo on Product Hunt in January 2026 after about 3 months of building. The results were better than expected for a niche B2B tool:
- 847 upvotes, #3 product of the day
- 312 signups in the first 48 hours
- 23 paid conversions in the first week
- $1,100 MRR at end of launch month
The Product Hunt launch drove initial awareness, but the sustained growth has come from two sources:
Content marketing. I published detailed guides on using AI for SEO analysis, integrating Search Console with AI tools, and understanding Core Web Vitals. These rank for long-tail SEO keywords and drive signups from people actively looking for the exact problem HeySeo solves.
MCP ecosystem visibility. When I submitted the HeySeo MCP server to the Claude.ai MCP registry and the Cursor plugins page, organic discovery picked up meaningfully. Developers looking for MCP tools find HeySeo without any paid promotion.
What did not work: cold outreach to marketing agencies. The buying cycle is too long and the decision maker is rarely the person who evaluates technical tools. I shelved agency outreach in favor of inbound content.
Lessons Learned
LLM costs are predictable with the right caching. I was worried about runaway API costs before launch. In practice, the combination of aggressive GSC data caching and pre-computed context objects means the LLM call is the only expensive step, and it is only triggered on actual user messages. My current LLM cost is about $0.08 per active user per month at typical usage levels.
Rate limits from Google APIs require defensive coding from day one. Not just exponential backoff - you also need circuit breakers, per-user rate limit tracking, and graceful degradation when the API is unavailable. I was too optimistic about API reliability early on and had several incidents before adding proper resilience.
The Kanban board surprised me. I built it as a secondary feature to give chat outputs somewhere to land. It turned out to be the primary reason many users stay subscribed - the board gives them a persistent to-do list that carries state across sessions and becomes more valuable the longer they use it.
Streaming matters more than raw response quality. Users who see tokens appearing within 500ms perceive the product as fast and responsive even when the full response takes 8 seconds. Users who wait for a complete response perceive even 3-second responses as slow. Invest in streaming before investing in latency optimization.
Build for your best users first. The MCP integration required meaningful engineering effort and serves a minority of users. But those users are power users who recommend the product loudly, have low churn, and upgrade to higher tiers. The ROI is much better than features that improve the median experience slightly.
What Is Next
HeySeo is at $3,800 MRR as of March 2026 and growing steadily. The roadmap for the next quarter:
- Competitor tracking (compare your rankings vs. identified competitors)
- AI-generated meta title and description suggestions with A/B testing support
- Automated alerting when rankings drop significantly
- GA4 integration to correlate search traffic with conversion data
- White-label reports for the Agency tier
The core insight, that most website owners have data they cannot use, and LLMs can close that gap, holds up at every stage of the product. The platform becomes more valuable as Google adds more data to Search Console and as LLMs get better at structured data reasoning.
If you are building something similar or want to talk through the architecture, I would be glad to hear from you.
Want to work together on a project like this? I build AI-powered SaaS products and data-driven tools for founders and development teams. If you have a problem worth solving, let's talk.