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

> Connect Google Analytics 4 to OpenSEO via OAuth for read-only reports. Follow our guide for SaaS or self-hosted integration using Google Cloud credentials.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: how-to-guide
- Published: 2026-08-20

---

**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](https://github.com/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`](https://github.com/every-app/open-seo/blob/main/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:

   ```bash
   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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx):

```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`](https://github.com/every-app/open-seo/blob/main/src/server/features/google/selfHostedOAuth.ts) exchanges the code for tokens:

```ts
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`](https://github.com/every-app/open-seo/blob/main/src/serverFunctions/ga4.ts):

```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:

```ts
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`](https://github.com/every-app/open-seo/blob/main/src/shared/ga4.ts) |
| **Connection UI** | React component for status, property picker, disconnect | [`src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx`](https://github.com/every-app/open-seo/blob/main/src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx) |
| **Server functions** | API endpoints for connection lifecycle | [`src/serverFunctions/ga4.ts`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/src/shared/ga4.ts), [`src/serverFunctions/ga4.ts`](https://github.com/every-app/open-seo/blob/main/src/serverFunctions/ga4.ts), [`src/client/features/ga4/GoogleAnalyticsConnectionCard.tsx`](https://github.com/every-app/open-seo/blob/main/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.