# How to Integrate Bolt.new with Google Search Console

- Tool: Bolt.new
- Difficulty: Intermediate
- Time required: 35 minutes
- Last updated: April 2026

## TL;DR

Integrate Google Search Console with Bolt.new by creating a Next.js API route that calls the Search Console API v3 using OAuth 2.0 or a service account. This provides authoritative first-party keyword data — clicks, impressions, positions, and CTR for sites you own. The API is free with a 1,200 queries/minute rate limit. OAuth redirect requires a deployed URL, not Bolt's preview.

## Build a Search Performance Dashboard with Google Search Console and Bolt.new

Google Search Console API provides the most authoritative SEO data available for sites you own: actual clicks and impressions from Google's search index, average ranking positions, click-through rates, and breakdowns by query, page, country, and device. Unlike third-party SEO tools that estimate this data from panels and sampling, Search Console data is Google's own record of how your site performs in their search results. The API is completely free with a generous rate limit of 1,200 queries per minute — sufficient for any dashboard use case.

The integration requires handling Google's OAuth 2.0 authentication, which adds some complexity compared to simple API key integrations. For building internal dashboards or agency tools where you control which properties are accessed, a Google Cloud service account is the simpler approach — you add the service account email as a user in Search Console, and the server authenticates server-to-server without per-user OAuth flows. For multi-tenant tools where different users connect their own Search Console properties, OAuth 2.0 with user consent is the correct approach.

One critical constraint for Bolt.new development: OAuth 2.0 redirect URIs must be pre-registered in Google Cloud Console, and they must be stable, predictable URLs. Bolt's WebContainer preview uses dynamic URLs like `https://[hash].local.credentialless.webcontainer-api.io/` that change between sessions and cannot be registered as OAuth redirect URIs. For the service account approach, this doesn't matter — there's no OAuth callback. For OAuth 2.0 with user consent, you must deploy to Netlify or Bolt Cloud first, then register your deployed URL as an authorized redirect URI.

## Before you start

- A Google account with verified properties in Google Search Console
- A Google Cloud project with the Search Console API enabled
- Either: a service account with the service account email added as a Search Console property user
- Or: OAuth 2.0 client credentials (requires a deployed URL for redirect URI registration)
- A Bolt.new project using Next.js (required for server-side Google API authentication)

## Step-by-step guide

### 1. Enable the Search Console API and Create Service Account Credentials

Go to console.cloud.google.com and create a new project (or select an existing one). In the left sidebar, click 'APIs & Services' → 'Library'. Search for 'Google Search Console API' and click 'Enable'. Now create a service account: go to 'APIs & Services' → 'Credentials' → 'Create Credentials' → 'Service Account'. Give it a name like 'search-console-bot', click 'Create and Continue'. Skip role assignment for now (Search Console manages access separately) and click 'Done'. Click on the newly created service account, go to the 'Keys' tab, click 'Add Key' → 'Create new key' → select JSON → 'Create'. A JSON file downloads — this is your credential file. Keep it secure and never commit it to git. Open the JSON file and copy the `client_email` field value. Now go to Google Search Console (search.google.com/search-console), click Settings → Users and permissions → Add user, paste the service account email, select 'Restricted' or 'Full' permission, and click 'Add'. The service account now has programmatic access to that property. Store the entire JSON content as a single-line string in your .env file as GOOGLE_SERVICE_ACCOUNT_JSON.

```
// lib/google-auth.ts
// Requires: npm install jose
import { SignJWT, importPKCS8 } from 'jose';

interface ServiceAccount {
  client_email: string;
  private_key: string;
  token_uri: string;
}

let cachedToken: { token: string; expires: number } | null = null;

export async function getGoogleAccessToken(): Promise<string> {
  // Return cached token if still valid (with 60s buffer)
  if (cachedToken && Date.now() < cachedToken.expires - 60000) {
    return cachedToken.token;
  }

  const serviceAccountJson = process.env.GOOGLE_SERVICE_ACCOUNT_JSON;
  if (!serviceAccountJson) {
    throw new Error('GOOGLE_SERVICE_ACCOUNT_JSON not configured');
  }

  const sa: ServiceAccount = JSON.parse(serviceAccountJson);
  const privateKey = await importPKCS8(sa.private_key, 'RS256');

  const now = Math.floor(Date.now() / 1000);
  const jwt = await new SignJWT({
    iss: sa.client_email,
    sub: sa.client_email,
    aud: sa.token_uri,
    scope: 'https://www.googleapis.com/auth/webmasters.readonly',
    iat: now,
    exp: now + 3600,
  })
    .setProtectedHeader({ alg: 'RS256' })
    .sign(privateKey);

  const tokenRes = await fetch(sa.token_uri, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      assertion: jwt,
    }),
  });

  const tokenData = await tokenRes.json();
  if (!tokenData.access_token) {
    throw new Error(`Token exchange failed: ${JSON.stringify(tokenData)}`);
  }

  cachedToken = {
    token: tokenData.access_token,
    expires: Date.now() + tokenData.expires_in * 1000,
  };

  return cachedToken.token;
}
```

**Expected result:** The getGoogleAccessToken() function exchanges the service account JSON for a valid Google OAuth access token. Subsequent calls within 3600 seconds return the cached token.

### 2. Fetch Keyword Search Analytics Data

With authentication working, build the API route that queries Search Console's `searchAnalytics/query` endpoint. This endpoint is the core of the Search Console API — it returns keyword performance data for any verified property with flexible filtering and grouping. Key parameters: `startDate` and `endDate` define the reporting window (Search Console data has a 3-day delay, so yesterday's data may not yet be available), `dimensions` array controls how data is grouped ('query' for keywords, 'page' for URLs, 'country', 'device'), `rowLimit` caps the number of results (maximum 25,000), and `orderBy` controls sorting. The endpoint URL format is: `https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/searchAnalytics/query`. Note that the siteUrl must be URL-encoded — a property like `https://example.com/` must become `https%3A%2F%2Fexample.com%2F`. The site URL must exactly match the property URL as it appears in Search Console — including the trailing slash if your property was added with one. Case sensitivity matters: `https://Example.com` and `https://example.com` are different properties.

```
// app/api/gsc/keywords/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getGoogleAccessToken } from '@/lib/google-auth';

export async function GET(request: NextRequest) {
  const siteUrl = request.nextUrl.searchParams.get('siteUrl')
    ?? process.env.GSC_SITE_URL;
  const days = Math.min(parseInt(request.nextUrl.searchParams.get('days') ?? '28'), 90);

  if (!siteUrl) {
    return NextResponse.json({ error: 'siteUrl required' }, { status: 400 });
  }

  const endDate = new Date(Date.now() - 3 * 86400000); // 3-day delay
  const startDate = new Date(endDate.getTime() - days * 86400000);
  const fmt = (d: Date) => d.toISOString().split('T')[0];

  try {
    const token = await getGoogleAccessToken();
    const encodedSite = encodeURIComponent(siteUrl);

    const res = await fetch(
      `https://www.googleapis.com/webmasters/v3/sites/${encodedSite}/searchAnalytics/query`,
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${token}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          startDate: fmt(startDate),
          endDate: fmt(endDate),
          dimensions: ['query'],
          rowLimit: 20,
          orderBy: [{ fieldName: 'clicks', sortOrder: 'DESCENDING' }],
        }),
      }
    );

    if (!res.ok) {
      const err = await res.json();
      throw new Error(err.error?.message ?? `HTTP ${res.status}`);
    }

    const data = await res.json();
    const keywords = (data.rows ?? []).map((row: any) => ({
      keyword: row.keys[0],
      clicks: row.clicks,
      impressions: row.impressions,
      ctr: Math.round(row.ctr * 1000) / 10,
      position: Math.round(row.position * 10) / 10,
    }));

    return NextResponse.json({
      siteUrl,
      dateRange: { startDate: fmt(startDate), endDate: fmt(endDate) },
      keywords,
    });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    return NextResponse.json({ error: message }, { status: 500 });
  }
}
```

**Expected result:** Calling /api/gsc/keywords?siteUrl=https://yourdomain.com returns the top 20 keywords by clicks for the last 28 days, matching what you see in the Google Search Console web interface.

### 3. Add Page-Level and Date Comparison Analytics

Extend the integration with two additional analysis views. First, add a page-level route using `dimensions: ['page']` instead of `['query']` — this returns which URLs on your site receive the most organic search traffic. Page-level data is critical for content audits: pages ranking on page 2 (position 11-20) with significant impressions are prime candidates for content updates that could bring them to page 1. Second, add date range comparison by running two parallel requests — one for the current period and one for the comparison period — then merging the results to calculate click and position changes. Promise.all runs both requests simultaneously, halving the wait time compared to sequential requests. The comparison logic joins the two datasets by keyword, calculates the delta for clicks and position, and surfaces the biggest movers. This is the same 'compare dates' feature visible in Search Console's UI, now available programmatically in your Bolt dashboard.

```
// app/api/gsc/compare/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getGoogleAccessToken } from '@/lib/google-auth';

async function querySearchConsole(
  siteUrl: string,
  startDate: string,
  endDate: string,
  token: string
) {
  const res = await fetch(
    `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`,
    {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        startDate,
        endDate,
        dimensions: ['query'],
        rowLimit: 50,
      }),
    }
  );
  const data = await res.json();
  const map: Record<string, { clicks: number; position: number }> = {};
  for (const row of data.rows ?? []) {
    map[row.keys[0]] = { clicks: row.clicks, position: row.position };
  }
  return map;
}

export async function GET(request: NextRequest) {
  const siteUrl = request.nextUrl.searchParams.get('siteUrl') ?? process.env.GSC_SITE_URL ?? '';
  const days = parseInt(request.nextUrl.searchParams.get('days') ?? '28');
  const fmt = (d: Date) => d.toISOString().split('T')[0];

  const now = Date.now() - 3 * 86400000;
  const currentEnd = new Date(now);
  const currentStart = new Date(now - days * 86400000);
  const prevEnd = new Date(now - days * 86400000 - 86400000);
  const prevStart = new Date(now - days * 2 * 86400000 - 86400000);

  try {
    const token = await getGoogleAccessToken();
    const [current, previous] = await Promise.all([
      querySearchConsole(siteUrl, fmt(currentStart), fmt(currentEnd), token),
      querySearchConsole(siteUrl, fmt(prevStart), fmt(prevEnd), token),
    ]);

    const keywords = Object.entries(current).map(([keyword, curr]) => {
      const prev = previous[keyword] ?? { clicks: 0, position: 0 };
      return {
        keyword,
        clicks: curr.clicks,
        clickChange: curr.clicks - prev.clicks,
        position: Math.round(curr.position * 10) / 10,
        positionChange: Math.round((prev.position - curr.position) * 10) / 10,
      };
    }).sort((a, b) => Math.abs(b.positionChange) - Math.abs(a.positionChange));

    return NextResponse.json({ siteUrl, keywords });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    return NextResponse.json({ error: message }, { status: 500 });
  }
}
```

**Expected result:** The comparison route returns keywords sorted by magnitude of position change. Big movers (both improvements and drops) appear at the top, making algorithm impact and content wins immediately visible.

### 4. Deploy and Handle OAuth Redirect Constraints

For the service account integration described in this guide, deployment is straightforward: connect Netlify via Settings → Applications → Netlify OAuth → click Publish, or use Bolt Cloud's Publish button. After deploying, add these environment variables in your hosting platform: `GOOGLE_SERVICE_ACCOUNT_JSON` (the full JSON content as a string, properly escaped), and `GSC_SITE_URL` (e.g., https://yourdomain.com). For Netlify: Site Configuration → Environment Variables. For Bolt Cloud: Secrets panel before publishing. After adding variables, trigger a redeploy. The service account approach works in Bolt's WebContainer during development because it makes outbound HTTPS calls to Google's token endpoint and API — no incoming OAuth callbacks needed. However, if you later add per-user OAuth (so individual users can connect their own Search Console accounts), that OAuth flow requires a deployed URL for the redirect URI. Bolt's WebContainer preview URL (e.g., https://[hash].local.credentialless.webcontainer-api.io/) is dynamic and cannot be registered in Google Cloud Console's Authorized redirect URIs. For per-user OAuth, always test on the deployed site, not the preview.

```
// app/dashboard/search-console/page.tsx
'use client';
import { useState, useEffect } from 'react';

interface KeywordRow {
  keyword: string;
  clicks: number;
  impressions: number;
  ctr: number;
  position: number;
}

export default function SearchConsoleDashboard() {
  const [days, setDays] = useState(28);
  const [data, setData] = useState<KeywordRow[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');

  useEffect(() => {
    setLoading(true);
    fetch(`/api/gsc/keywords?days=${days}`)
      .then((r) => r.json())
      .then((d) => {
        setData(d.keywords ?? []);
        setLoading(false);
      })
      .catch((e) => {
        setError(e.message);
        setLoading(false);
      });
  }, [days]);

  const positionColor = (pos: number) =>
    pos <= 3 ? '#22c55e' : pos <= 10 ? '#eab308' : '#ef4444';

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-4">Search Performance</h1>
      <div className="flex gap-2 mb-4">
        {[7, 28, 90].map((d) => (
          <button
            key={d}
            onClick={() => setDays(d)}
            className={`px-3 py-1 rounded ${days === d ? 'bg-blue-600 text-white' : 'bg-gray-100'}`}
          >
            Last {d} days
          </button>
        ))}
      </div>
      {error && <p className="text-red-500">{error}</p>}
      {loading ? (
        <p>Loading...</p>
      ) : (
        <table className="w-full text-sm border-collapse">
          <thead>
            <tr className="bg-gray-50">
              {['Keyword', 'Clicks', 'Impressions', 'CTR', 'Position'].map((h) => (
                <th key={h} className="border p-2 text-left">{h}</th>
              ))}
            </tr>
          </thead>
          <tbody>
            {data.map((row, i) => (
              <tr key={i} className="hover:bg-gray-50">
                <td className="border p-2">{row.keyword}</td>
                <td className="border p-2">{row.clicks.toLocaleString()}</td>
                <td className="border p-2">{row.impressions.toLocaleString()}</td>
                <td className="border p-2">{row.ctr}%</td>
                <td className="border p-2" style={{ color: positionColor(row.position) }}>
                  {row.position}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </div>
  );
}
```

**Expected result:** A functional Search Console dashboard renders with keyword data, date range tabs, and color-coded position values. Data matches what appears in the Google Search Console web interface.

## Best practices

- Use a service account for internal dashboards — it's simpler than per-user OAuth and works during development in Bolt's WebContainer preview
- Never commit the service account JSON key file to git — store it as GOOGLE_SERVICE_ACCOUNT_JSON in environment variables
- Account for Search Console's 3-day data delay in date calculations to avoid confusing empty results for recent date ranges
- Cache API responses for at least 6 hours — Search Console data updates daily, not in real time, and the API rate limit is generous but unnecessary polling wastes it
- For OAuth user flow (multi-tenant apps), always test on your deployed URL since Bolt's WebContainer cannot receive OAuth callbacks
- URL-encode the siteUrl parameter exactly as it appears in Search Console — including protocol, slashes, and case — to avoid 403 permission errors
- Use dimensions: ['query', 'page'] to get keyword + page combinations for the most actionable SEO insights: which keyword on which page

## Use cases

### Keyword Performance Dashboard

Display your site's top-performing search queries with clicks, impressions, average position, and CTR for any date range. Add week-over-week or month-over-month comparison to visualize SEO progress. Marketing teams use this for weekly reporting without manually exporting from Search Console.

Prompt example:

```
Build a search performance dashboard using Google Search Console API with service account authentication. Show my site's top 20 keywords by clicks for the last 28 days. Display each query with clicks, impressions, average position, and CTR. Add a date picker to change the lookback period. Color-code position values: green for 1-3, yellow for 4-10, red for 11+.
```

### Page-Level Traffic Analysis

Analyze which pages on your site drive the most organic search traffic and which are underperforming. Fetch data grouped by page instead of by query to understand content performance at the URL level — useful for content audits and identifying pages that need optimization.

Prompt example:

```
Create a page performance report using Google Search Console API. Fetch my site's top 20 pages by clicks for the last 90 days with their total clicks, impressions, average position, and CTR. Display as a table with the page path (strip the domain, show just /path) sortable by any column. Highlight pages with position > 20 in orange as optimization opportunities.
```

### Keyword Ranking Drop Alert Tool

Compare keyword rankings between two time periods to identify significant position drops. Enter a site URL and compare the last 7 days against the previous 7 days. Surface keywords where position has dropped by 5+ positions — a signal of potential algorithm impact, crawl issues, or content quality problems.

Prompt example:

```
Build a ranking change detector using Google Search Console API. Compare keyword rankings for the last 7 days vs the previous 7 days. Show keywords where average position dropped by 5 or more positions with the before/after position and click change. Sort by largest drop first. This helps me detect algorithm impacts quickly.
```

## Troubleshooting

### API returns 403 'The caller does not have permission'

Cause: The service account email has not been added as a user to the Search Console property, or the Search Console API is not enabled in Google Cloud Console.

Solution: In Google Search Console, go to Settings → Users and permissions, and verify the service account's client_email is listed with at least Restricted access. In Google Cloud Console, verify the Search Console API is enabled for your project. Both steps are required.

### Token exchange fails with 'invalid_grant' or 'unauthorized_client'

Cause: The service account JSON private_key is malformed, the system clock is significantly skewed (JWT timestamps must be within ±5 minutes of Google's time), or the service account has been deleted or deactivated.

Solution: Re-download a fresh JSON key from Google Cloud Console. Verify system time is accurate. If storing the JSON in environment variables, ensure newlines in the private_key are properly escaped as \n and the JSON remains valid after encoding.

```
// Ensure private key newlines are preserved when parsing
const sa = JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_JSON!.replace(/\\n/g, '\n'));
```

### Search Console returns empty rows even though data exists in the web interface

Cause: The siteUrl parameter doesn't exactly match the property URL as registered in Search Console. The URL format (with or without trailing slash, http vs https) must be identical.

Solution: Copy the exact property URL from the Search Console property selector dropdown — including protocol and trailing slash. URL-encode it before including in the API request. Domain properties (sc-domain:example.com) use a different format than URL-prefix properties (https://example.com/).

```
// For URL-prefix properties: encode the full URL
const encoded = encodeURIComponent('https://example.com/');
// For domain properties: use sc-domain: prefix
const domainProperty = encodeURIComponent('sc-domain:example.com');
```

### OAuth 2.0 flow fails with 'redirect_uri_mismatch' during testing

Cause: Bolt's WebContainer preview URL is not registered as an authorized redirect URI in Google Cloud Console. The preview URL is dynamic and cannot be pre-registered.

Solution: Deploy your Bolt app to Netlify or Bolt Cloud first. Register your deployed URL (e.g., https://myapp.netlify.app/api/auth/callback) as an Authorized redirect URI in Google Cloud Console → Credentials → OAuth 2.0 Client ID. Use the service account approach for development instead of OAuth user flow.

## Frequently asked questions

### Is the Google Search Console API free?

Yes. The Search Console API has no usage cost and a rate limit of 1,200 queries per minute. The only requirement is a verified Google Search Console property and a Google Cloud project with the API enabled. This makes it the best starting point for any SEO data integration in Bolt.

### Why can't I test the OAuth flow in Bolt's WebContainer preview?

OAuth 2.0 requires pre-registering redirect URIs in Google Cloud Console. Bolt's preview uses dynamic URLs that change between sessions — these cannot be registered as stable redirect URIs. For the service account integration in this guide, there's no OAuth callback, so the preview works fine. For per-user OAuth, deploy to Netlify or Bolt Cloud first, then register your deployed URL.

### What's the difference between a service account and OAuth 2.0 for Search Console API?

A service account lets your server authenticate directly to Google without any user involvement — you add the service account email as a property user in Search Console. This is ideal for internal dashboards. OAuth 2.0 lets real users authorize your app to access their own Search Console data — required for multi-tenant tools where different users connect their own properties.

### How much historical data does Search Console API provide?

Search Console provides 16 months of data via the API. The rowLimit parameter caps results at 25,000 rows per request, but you can paginate through all data using startRow. Note the 3-day delay for the most recent data — if you request data through yesterday, the last 2-3 days may show incomplete numbers.

### Can I access Search Console data for sites I don't own?

No. Search Console data is private and only accessible to verified property owners. You must be added as a property user (either directly or as a service account user) to access the data. This is why Search Console is best for analyzing your own properties — for competitor research, use tools like SEMrush, Ahrefs, or Moz that provide third-party data estimates.

---

Source: https://www.rapidevelopers.com/bolt-ai-integrations/google-search-console
© RapidDev — https://www.rapidevelopers.com/bolt-ai-integrations/google-search-console
