---
title: "Read hosted analytics in Next.js"
description: "Call hosted analytics in a route handler and render a server page."
canonical_url: "https://gscdump.com/gscdump-sdk/guides/build-integrations/nextjs"
last_updated: "2026-10-03T07:15:32.071Z"
---

# Read hosted analytics in Next.js

Keep the user API key in the server environment. Check the caller before a route or page reads Site data.

::pre{tabindex="0" svg="<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 593.804 116.9" width="593.804" height="116.9" style="--bg:var(--ui-bg-muted);--fg:var(--ui-text-highlighted);--accent:var(--ui-link)">
<style>
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap');
  text { font-family: 'Inter', system-ui, sans-serif; }
  svg {
    /* Derived from --bg and --fg (overridable via --line, --accent, etc.) */
    --_text:          var(--fg);
    --_text-sec:      var(--muted, color-mix(in srgb, var(--fg) 60%, var(--bg)));
    --_text-muted:    var(--muted, color-mix(in srgb, var(--fg) 40%, var(--bg)));
    --_text-faint:    color-mix(in srgb, var(--fg) 25%, var(--bg));
    --_line:          var(--line, color-mix(in srgb, var(--fg) 50%, var(--bg)));
    --_arrow:         var(--accent, color-mix(in srgb, var(--fg) 85%, var(--bg)));
    --_node-fill:     var(--surface, color-mix(in srgb, var(--fg) 3%, var(--bg)));
    --_node-stroke:   var(--border, color-mix(in srgb, var(--fg) 20%, var(--bg)));
    --_group-fill:    var(--bg);
    --_group-hdr:     color-mix(in srgb, var(--fg) 5%, var(--bg));
    --_inner-stroke:  color-mix(in srgb, var(--fg) 12%, var(--bg));
    --_key-badge:     color-mix(in srgb, var(--fg) 10%, var(--bg));
  }
</style>
<defs>
  <marker id="arrowhead" markerWidth="8" markerHeight="5" refX="7" refY="2.5" orient="auto">
    <polygon points="0 0, 8 2.5, 0 5" fill="var(--_arrow)" stroke="var(--_arrow)" stroke-width="0.75" stroke-linejoin="round" />
  </marker>
  <marker id="arrowhead-start" markerWidth="8" markerHeight="5" refX="1" refY="2.5" orient="auto-start-reverse">
    <polygon points="8 0, 0 2.5, 8 5" fill="var(--_arrow)" stroke="var(--_arrow)" stroke-width="0.75" stroke-linejoin="round" />
  </marker>
</defs>
<polyline class="edge" data-from="Browser" data-to="Route" data-style="solid" data-arrow-start="false" data-arrow-end="true" points="133.82,58.45 181.82,58.45" fill="none" stroke="var(--_line)" stroke-width="1" marker-end="url(#arrowhead)" />
<polyline class="edge" data-from="Route" data-to="Hosted" data-style="solid" data-arrow-start="false" data-arrow-end="true" points="345.294,58.45 393.294,58.45" fill="none" stroke="var(--_line)" stroke-width="1" marker-end="url(#arrowhead)" />
<g class="node" data-id="Browser" data-label="Browser" data-shape="rectangle">
  <rect x="40" y="40" width="93.82" height="36.900000000000006" rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="0.75" />
  <text x="86.91" y="58.45" text-anchor="middle" font-size="13" font-weight="500" fill="var(--_text)" dy="4.55">Browser</text>
</g>
<g class="node" data-id="Route" data-label="Next.js route handler" data-shape="rectangle">
  <rect x="181.82" y="40" width="163.474" height="36.900000000000006" rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="0.75" />
  <text x="263.557" y="58.45" text-anchor="middle" font-size="13" font-weight="500" fill="var(--_text)" dy="4.55">Next.js route handler</text>
</g>
<g class="node" data-id="Hosted" data-label="gscdump hosted API" data-shape="rectangle">
  <rect x="393.294" y="40" width="160.51" height="36.900000000000006" rx="0" ry="0" fill="var(--_node-fill)" stroke="var(--_node-stroke)" stroke-width="0.75" />
  <text x="473.549" y="58.45" text-anchor="middle" font-size="13" font-weight="500" fill="var(--_text)" dy="4.55">gscdump hosted API</text>
</g>
</svg>"}
```mermaid
flowchart LR
  Browser --> Route[Next.js route handler]
  Route --> Hosted[gscdump hosted API]
```
::

## Before you start

Complete [Hosted first result](/gscdump-sdk/guides/start/hosted-first-result). Configure [Auth.js](https://authjs.dev/getting-started/installation) so `auth()` returns a verified user email. Install `@gscdump/sdk`. Set `GSCDUMP_API_KEY`, `GSCDUMP_SITE_ID`, and `GSCDUMP_ALLOWED_EMAIL` on the server. This example grants that user access to one configured Site.

## Create a route handler

Add `app/api/search/route.ts`:

::pre{tabindex="0"}
```ts
import { createGscdumpV1Client, isGscdumpV1Error } from '@gscdump/sdk/v1'
import { auth } from '@/auth'

export const dynamic = 'force-dynamic'

export async function GET() {
  const session = await auth()
  if (!session?.user?.email) {
    return Response.json({ error: 'Sign in required' }, { status: 401 })
  }
  if (!process.env.GSCDUMP_ALLOWED_EMAIL
    || session.user.email !== process.env.GSCDUMP_ALLOWED_EMAIL) {
    return Response.json({ error: 'Site access denied' }, { status: 403 })
  }

  const apiKey = process.env.GSCDUMP_API_KEY
  const siteId = process.env.GSCDUMP_SITE_ID
  if (!apiKey || !siteId)
    return Response.json({ error: 'Search data is unavailable' }, { status: 503 })

  const client = createGscdumpV1Client({ credential: () => apiKey })
  try {
    const result = await client.queryAnalyticsRows({
      params: { siteId },
      body: { dimensions: ['query'], metrics: ['clicks'], rowLimit: 10 },
    })
    return Response.json({ rows: result.data.rows }, {
      headers: { 'Cache-Control': 'private, no-store' },
    })
  }
  catch (error) {
    if (isGscdumpV1Error(error))
      console.error('gscdump request', error.code, error.requestId)
    else
      throw error
    return Response.json({ error: 'Search data is unavailable' }, { status: 502 })
  }
}
```
::

## Render rows in a server component

A server page can call the same SDK function directly. If you use the route above, fetch it from a client component instead. The route is useful when other same-origin clients need the data.

::pre{tabindex="0"}
```tsx
import { createGscdumpV1Client, isGscdumpV1Error } from '@gscdump/sdk/v1'
import { auth } from '@/auth'

export default async function SearchPage() {
  const session = await auth()
  if (!session?.user?.email) {
    return <p>Sign in required.</p>
  }
  if (!process.env.GSCDUMP_ALLOWED_EMAIL
    || session.user.email !== process.env.GSCDUMP_ALLOWED_EMAIL) {
    return <p>Site access denied.</p>
  }

  const apiKey = process.env.GSCDUMP_API_KEY
  const siteId = process.env.GSCDUMP_SITE_ID
  if (!apiKey || !siteId)
    return <p>Search data is unavailable.</p>

  const client = createGscdumpV1Client({ credential: () => apiKey })
  try {
    const result = await client.queryAnalyticsRows({
      params: { siteId },
      body: { dimensions: ['query'], metrics: ['clicks'], rowLimit: 10 },
    })
    return result.data.rows.length
      ? <pre>{JSON.stringify(result.data.rows, null, 2)}</pre>
      : <p>No matching rows.</p>
  }
  catch (error) {
    if (isGscdumpV1Error(error))
      console.error('gscdump request', error.code, error.requestId)
    else
      throw error
    return <p>Search data is unavailable.</p>
  }
}
```
::

## Handle failure

Both examples check the session and Site allowlist before calling the SDK. They log [typed error](/gscdump-sdk/guides/operate/errors-and-retry) IDs on the server and return safe states. Decide caching deliberately because Site access and recent data can change.

## Check the result

Open `/api/search` and the page as the allowed user and a different user. The route must return `403` for the second user. Confirm the API key is absent from responses and browser bundles. Use [Hosted analytics](/gscdump-sdk/guides/read-data/hosted-analytics) for Report totals and sync metadata.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
