| All sites — multi-account, sparklines, Bing/IndexNow | Dashboard — sparkline cards |
|---|---|
![]() |
![]() |
Local self-hosted multi-account hub for Google Search Console. Connect several Google accounts via OAuth, view all Search Console sites in a single table, aggregate queries and pages across accounts, see per-site dashboards with sparklines and period-over-period deltas, drill into 16-month query history with one click. Since 0.6.0 it also ships a full SEO analytics suite — per-site deep-dives (striking distance, cannibalization, CTR benchmark, content decay, branded split, site health) and a portfolio-wide view across all sites — plus Bing/IndexNow and optional multi-user login. No external service, no GSC data leaves your machine, only OAuth tokens (and a little per-site config) persist locally in SQLite. All analytics are live-fetched.
Built as a personal alternative to seogets-style SaaS tools when you have multiple Google accounts (personal, work, clients) and don't want to log in to each Search Console separately.
Status: working MVP, used daily on macOS. Ships with a Docker Compose + OrbStack deploy served at
https://gsc.local. Accounts are connected overhttp://localhost:5173because Google rejects OAuth redirects to.localdomains (see Deploying).
- Connect any number of Google accounts. Each click on Connect Google account runs a normal Google consent flow (
webmastersread-write scope,access_type=offline,prompt=consent). The read-write scope is required to submit sitemaps; if you only need read access, changesrc/auth.tsback towebmasters.readonly. - Tokens are stored in a local SQLite file (
./data/gsc-hub.db). Refresh tokens auto-rotate; access tokens are refreshed transparently 60 seconds before they expire. - Revoked tokens detected via the first 401 from Google: account is marked
revoked, UI prompts reconnect, no silent failures.
- All Search Console properties from all connected accounts, in one full-bleed table.
- Per-site live aggregate for the selected period: Clicks / Impressions / CTR / Avg Pos, plus a CSV export column (queries or pages, configurable period).
- A totals bar above the table sums Sites / Impressions / Clicks across all non-hidden sites for the selected period.
- URL-driven sort and filter:
?days=1|3|7|28|60&sort=clicks|impressions|ctr|position|site|account&dir=asc|desc. Bookmark, share, browser-back work as expected. - A "G" badge next to each site opens a Google
site:search for a quick manual indexation check. - Hide sites you don't care about (kept in localStorage per browser; doesn't sync).
- Domain properties displayed as
example.com(thesc-domain:GSC prefix is stripped for display, link still goes tohttps://example.com/). - Click any site row to expand a quick URL Inspection report for its top 10 URLs (verdict, coverage, robots, last crawl, canonical mismatch). Uses Google's URL Inspection API; daily quota is 2000 per Google account. Results are cached for 12 hours in SQLite (key: account + site + url-set hash). Re-clicking the same site reuses the cache; Force refresh button bypasses it. The cache exists because URL Inspection has a hard 2000-call/day quota per Google account. Sites with no/low impressions in the current period fall back to URLs from their sitemap: gsc-hub asks Search Console which sitemaps the site submitted, downloads the first one (following sitemapindex if needed), and uses up to 10 page URLs from there. The homepage is always included.
- Submit sitemap button per site: resubmits every sitemap Search Console already knows for that property, falling back to a guessed
/sitemap.xmlif none are registered. Inline status (✓ N/ failure reason in the tooltip). - Submit all sitemaps button in the toolbar: fans out across all visible (non-hidden) sites in parallel, with progress and an aggregate result.
- Requires the read-write
webmastersscope (see Multi-account OAuth).
- Aggregated query-level rollup across all visible (non-hidden) sites for the selected period, broken down by query × page × country. Clicks, impressions, CTR (computed), Avg Pos (impression-weighted).
- Click the position cell to open the Google SERP for that query in the matching country.
- Click a row to expand a 16-month inline history chart for that exact query: red position line + blue impression bars, gridlines, axis range labels. Aggregated by day across visible sites only.
- Client-side column sort (Query / Clicks / Impressions / CTR / Avg Pos), independent from the sites-table sort.
- Same pattern as Top queries, but at page-URL granularity. Useful for finding the URL that pulled a sudden spike.
Click through to a single property for a full SEO deep-dive. Tabs:
- Striking Distance — queries ranking at positions 4–20 with real impressions: the fastest wins to page 1.
- Keyword Cannibalization — queries where several of your own URLs compete, with a clear winner/loser breakdown.
- CTR Benchmark — your actual click-through rate by position vs an industry-reference curve, plus the pages that under-perform it.
- Content Decay — pages losing clicks or impressions vs the previous period.
- Branded vs non-branded — traffic split, with editable brand terms per site.
- Site Health — SSL (expiry / issuer / grade), Google Safe Browsing, and Core Web Vitals (PageSpeed Insights, mobile). Run on demand, cached per site. Needs a Google API key (see Configuration); the tab stays hidden without one.
All tabs are computed live from Search Console; nothing is persisted beyond the editable brand terms and the health cache.
The same lenses across every non-hidden site at once, computed from a single query fan-out:
- Tabbed Striking / Cannibalization / CTR / Branded / Decay, all URL-addressable via
?tab=. - Country (Geo) filter and copy-queries-to-clipboard, both respecting the current filter.
- Per-decaying-page index status via the URL Inspection API, with a direct link into the owning Google account's inspection panel.
- Async streaming (the shell renders immediately) plus a short in-memory result cache, so re-opens and period switches are instant.
- Bing performance data alongside GSC, with merged GSC/Bing keyword rollups.
- Submit sitemap to Bing and push URLs to IndexNow, plus an IndexNow-key indicator per site.
- Requires
BING_API_KEY(see Configuration).
- Off by default — the tool stays single-user and loopback-only. Set
ADMIN_EMAIL/ADMIN_PASSWORDto turn on a login form, server sessions (argon2-hashed passwords), a user-management page, and roles (admin / manager) with per-owner account scoping. - Intended for when you expose the app beyond
127.0.0.1; secure cookies switch on automatically onceORIGINstarts withhttps://.
- Grid of per-site cards: account label, site, sparkline of daily clicks for the current period, four metrics with deltas vs the previous period of the same length (e.g., last 7 days vs the 7 days before that). Green/red, also dual-encoded with
+/−so colour-blind users get the signal. - Configurable density: 2 / 4 / 6 columns via URL
?cols=. Same period filter as Sites. - Stable order across reloads (clicks desc, impressions tiebreaker, then alphabetical).
- Queries or pages, last N days (matches the period filter), sanitized filename. Streams
text/csv; charset=utf-8withContent-Disposition: attachment. Downloaded directly from/properties/export?account=...&site=...&days=...&dim=query|page.
- Sparklines of daily clicks on the site cards.
- Privacy Blur — one click blurs PII (emails, domains, metrics) for screenshots and screen-sharing.
- Refresh preserves all URL state (period, sort, dir, cols) via SvelteKit's
invalidateAll(). No<form method="POST">redirect dance. - All numbers in tables are tabular-nums for vertical alignment.
- Light hover affordance on rows. Sortable headers show ↑ / ↓.
- No data is cached server-side. Every page load is a live fan-out across active accounts. The cost is honest latency. The benefit is you see exactly what GSC sees, right now.
- No background jobs, no cron, no queues, no email.
Requirements: Node 22+, pnpm.
git clone https://github.com/izzipizzy/gsc-hub.git
cd gsc-hub
pnpm install
pnpm devOpen http://localhost:5173 — the setup wizard opens on first run and asks for your Google Client ID/Secret; AUTH_SECRET is generated automatically, no manual .env editing needed. (Copying .env.example to .env is optional, only needed for automated/CI setups — see Configuration.) Click Connect Google account, complete consent, repeat for each account you want to connect.
For a long-running local deploy, use Docker Compose instead — see Deploying with Docker Compose + OrbStack.
Connect accounts over
http://localhost:5173, nothttps://gsc.local. Google's OAuth policy rejects redirects to the.localTLD (Error 400: invalid_request). The Compose setup exposes a loopback port specifically so the consent flow can run on localhost; day-to-day you can still usehttps://gsc.local. The SQLite DB is shared, so a token obtained on localhost works ongsc.localtoo.
docker compose up -d --build- Open the app (
http://localhost:5173for connecting Google accounts). - The setup wizard opens automatically: paste your Google Client ID and
Client Secret (the page shows the exact redirect URI to register in GCP),
choose access mode (loopback-only or exposed-with-login), and save. No manual
.envediting —AUTH_SECRETis generated for you and stored in SQLite.
If you're exposing the app on a public URL, set GOOGLE_*/ADMIN_* in env before
exposing it, or complete /setup yourself first — until setup completes, /setup
is open with no authentication and the first visitor to reach it could claim the
admin account.
The detailed GCP OAuth walkthrough below is only needed to obtain the two values the wizard asks for. Everything under "Configuration" is optional / for automated deploys (env vars take precedence over wizard values).
- Open https://console.cloud.google.com/apis/credentials.
- Create credentials → OAuth client ID → Application type Web application, name
gsc-hub. - Authorized redirect URIs:
http://localhost:5173/auth/callback/google(dev)https://your-domain.example/auth/callback/google(only if you deploy)
- Enable the Search Console API in the same project: https://console.cloud.google.com/apis/library/searchconsole.googleapis.com.
- OAuth consent screen (now under Google Auth Platform → Audience): set User Type to External. Either Publish the app (any Google account can sign in) or keep it in Testing and add your Google emails as Test users.
- Copy the Client ID and Client Secret — paste them into the setup wizard when you first open the app.
AUTH_SECRETneeds no manual step; the wizard generates and stores it in SQLite.
The webmasters scope is a "sensitive scope" in Google's classification, but Google does not require formal verification for personal-tier usage (the OAuth user cap allows up to 100 consenting users for unverified sensitive scopes). You'll see an "unverified app" warning on the consent screen; click Advanced → Go to gsc-hub (unsafe) to proceed. Only register http://localhost:5173/auth/callback/google as the redirect URI — Google will not accept a .local redirect.
.env keys (see .env.example):
| key | required | description |
|---|---|---|
GOOGLE_CLIENT_ID |
yes | OAuth Web Application client ID |
GOOGLE_CLIENT_SECRET |
yes | OAuth Web Application client secret |
AUTH_SECRET |
yes | Random 32-byte base64 secret for Auth.js (openssl rand -base64 32) |
AUTH_TRUST_HOST |
recommended | true for self-hosted/proxy setups |
DB_PATH |
optional | Path to SQLite file. Defaults to ./data/gsc-hub.db |
PAGESPEED_KEY |
optional | Google API key for the Site Health tab (Core Web Vitals via PageSpeed Insights). Enable the PageSpeed Insights API. One Google API key can serve both Health checks. |
GOOGLE_SAFE_BROWSING_KEY |
optional | Google API key for the Site Health tab's Safe Browsing check. Enable the Safe Browsing API — the same key as PAGESPEED_KEY works. Without these two, the Health tab stays hidden; nothing else needs them. |
BING_API_KEY |
optional | Bing Webmaster API key — enables Bing data, "Submit to Bing", and IndexNow push. |
ADMIN_EMAIL / ADMIN_PASSWORD |
optional | Set both to enable multi-user login/roles (seeds an admin on first start). Leave unset for the default single-user, loopback-only mode. |
ORIGIN |
optional | Public origin (e.g. https://your-domain.example). Enables secure cookies when it starts with https://. |
AUTH_URL |
optional | Public URL Auth.js uses to build the OAuth redirect; must match the GCP OAuth redirect base. |
[browser] ──┬─ https://gsc.local (OrbStack proxy, TLS) ──┐
└─ http://localhost:5173 (loopback, for OAuth)┴─> [SvelteKit (Node) :3000] ──> Google Search Console API
│
└──> ./data/gsc-hub.db (only OAuth tokens)
One Node process. One SQLite file. The DB schema is small — tokens plus a little per-site config and an external-check cache:
CREATE TABLE google_accounts (
id TEXT PRIMARY KEY, -- google sub
email TEXT NOT NULL,
label TEXT,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
expires_at INTEGER NOT NULL,
scope TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', -- active | revoked | error
last_error TEXT,
added_at INTEGER NOT NULL
);
CREATE TABLE url_inspection_cache (
account_id TEXT NOT NULL,
site_url TEXT NOT NULL,
urls_hash TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (account_id, site_url, urls_hash)
);
CREATE TABLE site_branded_keywords (
site_url TEXT PRIMARY KEY,
terms TEXT,
updated_at INTEGER
);
CREATE TABLE site_health (
site_url TEXT PRIMARY KEY,
data TEXT,
checked_at INTEGER
);All four tables are created by the startup migration. When the optional login is enabled (ADMIN_EMAIL/ADMIN_PASSWORD set), two more tables — users and sessions — are seeded on first start.
No GSC analytics data is persisted. Every request to /properties, the per-site and portfolio analytics, /dashboard, the query-history endpoint or the CSV export does a fresh fan-out to Google. The database holds only OAuth tokens plus small per-site config (brand terms in site_branded_keywords) and a cache of the external health checks (site_health). Hidden sites are kept in browser localStorage only.
This keeps things simple, eliminates a "stale data" UX class, and means the database file you care about is tiny (a few KB per account). The trade-off is page-load latency proportional to the number of active sites: roughly 2N parallel API calls for the sites view, 3N for the dashboard, 1N for query history.
Auth.js v5 is wired with a custom signIn callback that, instead of creating an app session, upserts the Google profile + tokens into google_accounts keyed by profile.sub, then returns '/' to redirect cleanly without setting a session cookie. The app has no concept of "logged-in user" beyond local trust (single-user tool, defended by 127.0.0.1 binding in dev).
- SvelteKit (Svelte 5 runes) + TypeScript, Node adapter
- Auth.js (
@auth/sveltekit) for the Google OAuth dance - better-sqlite3 for the local token store
- TailwindCSS v3 for styles (no custom palette extension; tokens documented in DESIGN.md)
- Vitest for unit tests
No chart libraries: every sparkline and the query-history chart are hand-rolled SVG (<rect> + <path>). No icon fonts: every icon is a 14×14 inline SVG.
gsc-hub/
├── PRODUCT.md — strategic context (users, principles, anti-references)
├── DESIGN.md — visual system (colors, typography, components, rules)
├── DESIGN.json — sidecar with HTML/CSS snippets per component
├── Dockerfile — multi-stage production image (Node runtime)
├── compose.yaml — Docker Compose: OrbStack domain + loopback port 5173
├── src/
│ ├── auth.ts — SvelteKitAuth config + custom signIn callback
│ ├── hooks.server.ts
│ ├── app.css — Tailwind + Operator Console utilities
│ ├── lib/
│ │ ├── server/
│ │ │ ├── db.ts — SQLite open + migration
│ │ │ ├── accounts.ts — CRUD over google_accounts (only file with SQL for that table)
│ │ │ ├── inspection_cache.ts — 12h SQLite cache for URL Inspection responses
│ │ │ ├── google.ts — GSC client, refresh, fan-out, search analytics
│ │ │ ├── analytics.ts — pure SEO analytics (striking / cannibalization / CTR benchmark / branded split / decay)
│ │ │ ├── health.ts — Site Health (SSL / Safe Browsing / Core Web Vitals)
│ │ │ ├── branded.ts — per-site brand terms (site_branded_keywords)
│ │ │ ├── bing.ts — Bing Webmaster API client
│ │ │ ├── indexnow.ts — IndexNow submit + key handling
│ │ │ └── csv.ts — RFC 4180 CSV writer
│ │ └── utils/
│ │ ├── site.ts — strip sc-domain: prefix, build proper href + Google site: search
│ │ └── country.ts — map GSC country codes to Google SERP URLs
│ └── routes/
│ ├── +page.svelte — Accounts list (/)
│ ├── auth/[...auth]/ — Auth.js catch-all
│ ├── accounts/[id]/ — delete, relabel
│ └── properties/
│ ├── +page.svelte — Sites table + Top queries + Top pages
│ ├── [site]/ — per-site analytics detail (analytics / decay / health / branded sub-endpoints)
│ ├── striking/ — portfolio analytics + decay endpoint
│ ├── export/+server.ts — CSV stream
│ ├── inspect/+server.ts — URL Inspection (cached)
│ ├── refresh/+server.ts — force-refresh helpers
│ ├── sitemap-urls/+server.ts — fetch sitemap URLs for inspection fallback
│ ├── sitemap-submit/+server.ts — (re)submit sitemaps to GSC
│ └── query-history/+server.ts — 16-month per-query history
├── tests/ — Vitest, mocks fetch/env
└── data/gsc-hub.db — gitignored, created on first run
| command | description |
|---|---|
pnpm dev |
Dev server on http://localhost:5173 |
pnpm build |
Production build (Node target) |
pnpm preview |
Preview the production build |
pnpm test |
Run Vitest |
pnpm test:watch |
Watch mode |
pnpm check |
svelte-check type-check |
docker compose up -d --build |
Build and run the production container (OrbStack) |
docker compose logs -f gsc |
Follow container logs |
docker compose down |
Stop and remove the container (data in ./data persists) |
62 unit tests cover the server modules:
- SQLite migration and schema, including
url_inspection_cachetable (tests/db.test.ts) - SEO analytics pure functions: striking distance, cannibalization, CTR benchmark, branded split, content decay (
tests/analytics.test.ts) - URL Inspection cache: hash determinism, miss, hit, TTL expiry, upsert, delete (
tests/inspection_cache.test.ts) - Accounts CRUD including
markActive/markRevoked/markError(tests/accounts.test.ts) - GSC client: token refresh skew window, 5xx → markError, 401/invalid_grant → markRevoked, fan-out aggregation, per-site queries / pages / daily breakdown / query-history (
tests/google.test.ts) - Sitemap URL fetching and parsing (
tests/sitemap.test.ts) - RFC 4180 CSV escaping (
tests/csv.test.ts)
Routes are not unit-tested; smoke-tested via curl against pnpm dev.
- The only data persisted on your machine is the OAuth token row per connected Google account.
- No Search Console data (queries, clicks, impressions, page URLs, ranking positions) is ever written to disk.
- No analytics, no telemetry, no outbound calls except to Google's APIs.
- The SQLite file lives in
./data/(gitignored). Delete it to wipe all connections. - Tokens are stored in plaintext. This is acceptable for a local single-user tool; encrypt at rest if you ever expose this beyond
127.0.0.1. - The exception is the URL Inspection cache (
url_inspection_cachetable) which stores response payloads keyed by(account_id, site_url, urls_hash)for 12 hours, to avoid burning the daily 2000-call quota on repeat clicks. Deletedata/gsc-hub.dbto wipe it. - Two more small tables hold per-site config, not GSC analytics:
site_branded_keywords(your editable brand terms) andsite_health(a cache of external SSL / Safe Browsing / Core Web Vitals checks). Both are wiped with the same DB file.
The repo ships a Dockerfile (multi-stage, Node runtime) and a compose.yaml tuned for OrbStack on macOS:
docker compose up -d --build # build and start
docker compose logs -f gsc # follow logs
docker compose restart gsc # restart
docker compose down # stop (data in ./data persists)- Served at
https://gsc.localvia the OrbStack proxy (automatic TLS) — set by thedev.orbstack.domainslabel. Also reachable athttps://gsc.orb.local(auto domain by container name). restart: unless-stoppedbrings the container back after a reboot (OrbStack starts on login).- The container also binds
127.0.0.1:5173 → 3000(loopback only). This exists solely so the OAuth flow can run onhttp://localhost:5173— Google rejects.localredirects. Use this URL to connect accounts; usehttps://gsc.localfor everyday work. .envis read viaenv_file; secrets are injected at runtime through$env/dynamic/private, not baked into the image../datais bind-mounted, so the SQLite token store survives rebuilds and is shared withpnpm dev.
Security note: the app has no built-in authentication (single-user tool). The port is intentionally bound to 127.0.0.1 only — do not expose it on 0.0.0.0 or the network, since the DB holds Google OAuth tokens. To publish on a public hostname, front it with an identity-aware proxy (e.g. Cloudflare Access) limited to your email, and add the production callback URL to your Google OAuth client.
The SEO analytics suite (per-site + portfolio: striking distance, cannibalization, CTR benchmark, content decay, branded split, site health), Bing/IndexNow, and optional login/roles all shipped in 0.6.0. Still on the list:
- Daily background pull of aggregates into Postgres for trends and period comparisons that span weeks/months without re-querying GSC each time.
- Charts with hover tooltips on the dashboard cards.
- Sitemap change monitoring.
- Alerts on traffic drops.
Analytics stay intentionally live-fetched; a persistent cache will come only when usage shows it's needed. Until then, every load is fresh.
MIT. See LICENSE.




