How to Connect Google Analytics 4 to OpenSEO: Complete Integration Guide

Connect Google Analytics 4 to OpenSEO using Better Auth OAuth in two modes—hosted SaaS or self-hosted with Google Cloud OAuth credentials—to enable read-only analytics reports.

OpenSEO integrates Google Analytics 4 through a dedicated Better Auth OAuth provider called google-analytics. The connection is read-only: encrypted OAuth tokens are stored in the Better Auth account table, and your selected property metadata is saved on the project record. This guide covers both hosted and self-hosted deployment modes using the actual source code implementation from the every-app/open-seo repository.

Prerequisites for Google Analytics 4 Connection

Before connecting GA4 to OpenSEO, ensure you have:

  • A Google Cloud project with the Google Analytics Admin API (analyticsadmin.googleapis.com) and Google Analytics Data API (analyticsdata.googleapis.com) enabled
  • For self-hosted deployments: a Google OAuth 2.0 client ID and secret

The required OAuth scope is https://www.googleapis.com/auth/analytics.readonly, defined in src/shared/ga4.ts alongside the provider constant GA4_OAUTH_PROVIDER_ID.

Hosted Mode vs. Self-Hosted Mode

Hosted Mode (SaaS)

In hosted mode, OpenSEO's SaaS infrastructure handles the entire OAuth flow. You only need to click "Connect with Google" in the UI—no environment configuration required.

Self-Hosted Mode

Self-hosted deployments require manual OAuth client setup:

  1. Create a Google Cloud OAuth client with redirect URI: https://<your-domain>/api/ga4/oauth/callback

  2. Set three environment variables:

    GOOGLE_CLIENT_ID=your_client_id
    GOOGLE_CLIENT_SECRET=your_client_secret
    BETTER_AUTH_SECRET=your_random_32_char_secret
  3. Restart the service to load variables

See docs/SELF_HOSTING_GOOGLE_ANALYTICS.md for the complete step-by-step guide including API enablement and consent screen configuration.

Step-by-Step Connection Flow

1. Trigger the OAuth Flow in the UI

Navigate to Project → Settings → Integrations → Analytics and click "Connect with Google". This invokes startGoogleLink("ga4", …) from src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx:

// Inside GoogleAnalyticsConnectionCard.tsx
const handleConnect = () => void startGoogleLink("ga4", window.location.href);

<button
  type="button"
  onClick={handleConnect}
  className="inline-flex items-center …"
>
  <GoogleGlyph className="size-[18px]" />
  Connect with Google
</button>

The startGoogleLink function builds the authorization URL for either hosted or self-hosted mode and redirects to Google's consent screen.

2. Handle the OAuth Callback

After user consent, Google redirects to /api/ga4/oauth/callback. In self-hosted mode, src/server/features/google/selfHostedOAuth.ts exchanges the code for tokens:

export async function createSelfHostedGoogleAuthorizationUrl({
  providerId,
  callbackURL,
}: { providerId: string; callbackURL: string }) {
  const clientId = process.env.GOOGLE_CLIENT_ID!;
  const scope = encodeURIComponent(
    "openid email profile https://www.googleapis.com/auth/analytics.readonly"
  );
  const redirectUri = encodeURIComponent(callbackURL);
  return `https://accounts.google.com/o/oauth2/v2/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&access_type=offline`;
}

The server handler stores tokens via Better Auth and redirects back to the UI.

3. Select Your GA4 Property

The UI fetches available properties through listGa4Properties in src/serverFunctions/ga4.ts:

export const listGa4Properties = createServerFn({ method: "POST" })
  .middleware(requireProjectContext)
  .validator(projectScopedSchema)
  .handler(async ({ context }) => {
    const [propertyList, connection] = await Promise.all([
      Ga4Service.listPropertiesForUserWithGrantStatus(context.userId),
      Ga4Service.getConnection(context.projectId),
    ]);

    return {
      accounts: propertyList.accounts.map(grant => ({
        ...grant,
        properties: grant.properties.map(property => ({
          ...property,
          isSelected:
            connection?.ga4AccountId === grant.accountId &&
            connection.propertyId === property.propertyId,
        })),
      })),
    };
  });

This server function aggregates the user's GA4 accounts, marks any currently selected property, and returns data for the picker UI.

4. Save the Property Selection

When you choose a property, setGa4Property persists the linkage:

export const setGa4Property = createServerFn({ method: "POST" })
  .middleware(requireProjectContext)
  .validator(setPropertySchema)
  .handler(async ({ data, context }) => {
    const connection = await Ga4Service.setProperty({
      projectId: context.projectId,
      organizationId: context.organizationId,
      accountId: data.accountId,
      propertyId: data.propertyId,
      userId: context.userId,
    });

    waitUntil(
      captureServerEvent({
        distinctId: context.userId,
        event: "ga4:property_select",
        organizationId: context.organizationId,
        properties: { project_id: context.projectId },
      })
    );

    return {
      connected: true as const,
      propertyId: connection.propertyId,
      propertyDisplayName: connection.propertyDisplayName,
    };
  });

Ga4Service.setProperty stores the selection, and the event is tracked for analytics.

Core Implementation Architecture

Component Role Key File
OAuth provider & scopes Defines Better Auth provider ID and required scopes src/shared/ga4.ts
Connection UI React component for status, property picker, disconnect src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx
Server functions API endpoints for connection lifecycle src/serverFunctions/ga4.ts
GA4 service layer Google API calls, caching, property storage src/server/features/ga4/services/*
Self-hosted OAuth URL builder and callback validation src/server/features/google/selfHostedOAuth.ts

Using Connected GA4 Data

Once connected, all analytics reports—dashboard cards, organic overview, traffic acquisition, and more—read from the GA4 Data API using Ga4Service with the stored OAuth token. Server functions in src/serverFunctions/ga4.ts wrap these calls for the UI.

If the connection expires or is revoked, the UI automatically falls back to the connection card, prompting re-authentication.

Summary

  • OpenSEO uses Better Auth with a dedicated google-analytics OAuth provider for GA4 integration
  • Two modes: hosted (managed) or self-hosted (requires Google Cloud OAuth client)
  • Required scope: https://www.googleapis.com/auth/analytics.readonly (read-only access)
  • Key environment variables: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, BETTER_AUTH_SECRET
  • Core files: src/shared/ga4.ts, src/serverFunctions/ga4.ts, src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx, and src/server/features/ga4/services/Ga4Service

Frequently Asked Questions

What happens to my GA4 data after connecting OpenSEO?

OpenSEO stores only encrypted OAuth tokens and property metadata (property ID, account ID, display name). No actual analytics data is retained long-term; reports are fetched live from Google's Data API on each request using your stored credentials.

Can I connect multiple GA4 properties to one OpenSEO project?

No. Each OpenSEO project links to one GA4 property at a time. You can change properties by disconnecting and reconnecting, or create separate OpenSEO projects for different properties.

Why does my self-hosted connection fail with "redirect_uri_mismatch"?

This error occurs when the redirect URI in your Google Cloud OAuth client doesn't match your actual deployment URL. Ensure you've added both your production URL (https://<your-domain>/api/ga4/oauth/callback) and any local development URLs (e.g., http://localhost:3000/api/ga4/oauth/callback) to the authorized redirect URIs in Google Cloud Console.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →