Skip to content

MankhongGarden/nextjs-supabase-pdpa-analytics

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 

Repository files navigation

PDPA-strict analytics on Next.js + Supabase: a defense-in-depth pattern from Thailand

In August 2025, Thailand's Personal Data Protection Committee (PDPC) issued its first major administrative fines — over ฿21.5 million across multiple companies. The "grace period" approach to PDPA is over. If you operate a Thai-facing product and you do analytics, you need to be PDPA-compliant by default, not by retrofit.

This is the pattern I use on my side-project marketplace, ported to a generic Next.js + Supabase + PostHog stack. It's stricter than GDPR in some ways (PDPA requires equal-prominence Accept/Reject UI and auditable consent logs) and structured to survive a PDPC audit without a panicked code review.

TL;DR

  • 3-layer architecture: server-side user_events (Supabase) + PostHog Cloud EU + Resend webhook
  • Anonymous-by-default tracking with HMAC-rotating session IDs
  • DB-level PII guard via CHECK constraints (defense if app validation misses)
  • DSAR (Data Subject Access Request) endpoint reading from all 3 layers
  • 3 additional defense layers: PostHog realtime transformations, reverse-proxy ingestion, identify-revert on opt-out

This guide assumes Next.js App Router + Supabase + PostHog Cloud EU.


Why now: the Aug 2025 ฿21.5M fine signal

Thailand's PDPA, enacted 2019 and enforced from 2022, mirrors GDPR's structure. But until 2025 enforcement was light. The August 2025 first-major-fine wave (one company alone hit for ฿7M+ for failures to obtain valid consent) made three things urgent:

  1. Consent UI matters as much as data handling. "Accept all" and "Reject all" must have equal prominence. Hidden reject buttons are a finding.
  2. Audit logs are required. "Demonstrable accountability" (§39) means you must be able to show, per user, what they consented to and when.
  3. Data Processors are joint liability. PostHog, Resend, Vercel — each is a data processor under your DPA. Your DSAR delete must cascade through them.

The pattern below addresses all three.


Architecture overview

Browser (consent-gated)
   │
   ├──> PostHog Cloud EU  (analytics events · identified after opt-in only)
   │
   └──> Next.js /api/analytics/event  (anon_session_id · PII guard · rate limit)
         │
         └──> Supabase user_events  (CHECK constraints · partial indexes · analyst role SELECT-only)

Resend webhook  → Next.js /api/webhooks/resend
   │
   └──> Supabase notification_events_sent.opened_at / clicked_at (idempotent)

DSAR endpoint  → /api/dsar/{fetch,delete}
   │
   ├──> Read/delete from user_events
   ├──> Delete from PostHog person  (identify revert)
   └──> Audit log to dsar_audit_log

Three independent ingestion paths · one consent gate · one DSAR exit.


Phase 1 — Lock the KPI questions first

Before writing any tracking code, lock the 8-12 questions the analytics must answer. The user (founder, PM, marketing lead) picks them. The dev team doesn't.

Categories:

  • Buyer/customer side (3-5 questions): conversion · funnel drop · time-to-action · retention
  • Provider/seller side (3-5 if multi-sided)
  • Both-sides (2-3): tier revenue · district/segment imbalance
  • North Star Metric (1): one number that defines product health
  • Reporting cadence: Weekly / Monthly / Real-time

Why this gate matters: 8 of 13 typical KPIs are answerable from existing transactional data — bookings, payments, timestamps. No new tracking needed. Locking questions first reveals which 5 events are actually worth instrumenting and tracking.

This is the discipline that prevents "track everything just in case" — which is the architecture of a PDPA finding.


Phase 2 — PDPA stance · anonymous-by-default

Default stance: all visitors are anonymous. Personally-identifying tracking only activates after explicit opt-in.

4 components:

2.1 Anonymous session ID

// src/lib/analytics/anon-session.ts
import crypto from "crypto";

export function getAnonSessionId(req: Request): string {
  const secret = process.env.ANALYTICS_HMAC_SECRET;
  if (!secret) throw new Error("ANALYTICS_HMAC_SECRET not configured");
  const ip = (req.headers.get("x-forwarded-for") ?? "").split(",")[0]?.trim() || "0.0.0.0";
  const ua = req.headers.get("user-agent") ?? "unknown";
  const dayBucket = new Date().toISOString().slice(0, 10);
  return crypto
    .createHmac("sha256", secret)
    .update(`${ip}|${ua}|${dayBucket}`)
    .digest("hex")
    .slice(0, 16);
}

HMAC over IP + User-Agent + day-bucket. Rotates daily. Not reversible without the secret. Not a cookie. Not a fingerprint. Just enough to deduplicate within a session without identifying.

2.2 Consent toggles in users.notification_prefs jsonb

ALTER TABLE public.users
  ADD COLUMN IF NOT EXISTS notification_prefs jsonb NOT NULL DEFAULT '{}'::jsonb;

Two consents (default both false):

  • product_telemetry — gates PostHog identify() and event emit
  • marketing_attribution — gates landing-page first-touch and email-link source tracking

The consent UI must offer "Accept all" and "Reject all" with equal visual prominence. Per-toggle granular control is recommended.

2.3 PII guard at DB level

ALTER TABLE public.user_events ADD CONSTRAINT user_events_no_pii_check CHECK (
  NOT (metadata ? 'email' OR metadata ? 'phone' OR metadata ? 'phone_number'
    OR metadata ? 'name' OR metadata ? 'full_name' OR metadata ? 'address'
    OR metadata ? 'line_id')
);

App-side validation catches the keys you remember. Defense-in-depth catches the ones you forget. PR review can miss; the database doesn't.

2.4 DSAR endpoint

Two routes:

  • /api/dsar/fetch — return all data we hold about the requesting user across user_events, users, bookings, etc.
  • /api/dsar/delete — purge from all 3 layers (Supabase rows · PostHog person · Resend audience)

Both require auth + audit log.


Phase 3 — Schema design

CREATE TABLE IF NOT EXISTS public.user_events (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  event_type      text NOT NULL,
  actor_user_id   uuid REFERENCES public.users(id) ON DELETE SET NULL,
  anon_session_id text,
  resource_type   text,
  resource_id     text,
  metadata        jsonb NOT NULL DEFAULT '{}'::jsonb,
  occurred_at     timestamptz NOT NULL DEFAULT now()
);

-- Whitelist event_type (extend via new migration when adding events)
ALTER TABLE public.user_events ADD CONSTRAINT user_events_event_type_check CHECK (
  event_type IN ('signup_started', 'signup_completed', 'item_viewed', 'purchase_completed' /* ... */)
);

-- Identifier required (auth user OR anon session)
ALTER TABLE public.user_events ADD CONSTRAINT user_events_actor_or_anon_check CHECK (
  actor_user_id IS NOT NULL OR anon_session_id IS NOT NULL
);

-- PII guard (see Phase 2.3 above)
ALTER TABLE public.user_events ADD CONSTRAINT user_events_no_pii_check CHECK (
  NOT (metadata ? 'email' OR metadata ? 'phone' OR metadata ? 'name' /* ... */)
);

-- Partial indexes
CREATE INDEX idx_user_events_actor_time ON public.user_events(actor_user_id, occurred_at DESC) WHERE actor_user_id IS NOT NULL;
CREATE INDEX idx_user_events_anon_time  ON public.user_events(anon_session_id, occurred_at DESC) WHERE anon_session_id IS NOT NULL;

-- RLS: deny all · analyst SELECT-only
DO $$ BEGIN CREATE ROLE analyst NOLOGIN;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

ALTER TABLE public.user_events ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.user_events FROM PUBLIC, authenticated, anon;
GRANT SELECT ON public.user_events TO analyst;
CREATE POLICY user_events_analyst_select ON public.user_events FOR SELECT TO analyst USING (true);

The CHECK constraint approach beats "validate in app code" because it survives schema drift. If a developer adds a new event without updating the validator, the DB still rejects PII. Defense-in-depth.


Phase 4 — Server-side API

// src/app/api/analytics/event/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { getAnonSessionId, isValidEventType, rejectIfPii } from "@/lib/analytics/anon-session";

export async function POST(req: NextRequest) {
  const body = await req.json();
  const { event_type, resource_type, resource_id, metadata = {} } = body;

  // Whitelist check (mirrors DB CHECK)
  if (!isValidEventType(event_type)) {
    return NextResponse.json({ error: "invalid_event_type" }, { status: 400 });
  }

  // App-side PII guard (DB is fallback)
  const pii = rejectIfPii(metadata);
  if (pii) return NextResponse.json({ error: "pii_detected", key: pii }, { status: 400 });

  // Rate limit (60/min/IP) — Vercel Edge KV or Upstash
  // ... (omitted for brevity)

  const anon = getAnonSessionId(req);
  const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);

  const { error } = await supabase.from("user_events").insert({
    event_type,
    actor_user_id: body.user_id ?? null,
    anon_session_id: body.user_id ? null : anon,
    resource_type, resource_id,
    metadata,
  });

  if (error) return NextResponse.json({ error: "db_error" }, { status: 500 });
  return NextResponse.json({ ok: true });
}

Service role bypasses RLS. The notification_prefs.product_telemetry === true gate happens on the frontend (no fetch attempt) — but if a request arrives, we still run the rate-limit and PII guard defensively.


Phase 5 — Frontend PostHog with consent gate

// src/lib/analytics/posthog.ts
"use client";
import posthog from "posthog-js";

let initialized = false;
const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://eu.posthog.com";

export function initPostHog() {
  if (initialized || typeof window === "undefined" || !POSTHOG_KEY) return;
  posthog.init(POSTHOG_KEY, {
    api_host: POSTHOG_HOST,
    capture_pageview: "history_change",
    autocapture: true,
    persistence: "localStorage+cookie",
    person_profiles: "identified_only",
  });
  initialized = true;
}
export function identifyPostHog(userId: string, traits?: Record<string, unknown>) { if (initialized) posthog.identify(userId, traits); }
export function resetPostHog() { if (initialized) posthog.reset(); }

Provider:

// src/components/analytics/PostHogProvider.tsx
"use client";
import { useEffect } from "react";
import { createClient } from "@/lib/supabase/client";
import { initPostHog, identifyPostHog, resetPostHog } from "@/lib/analytics/posthog";

export default function PostHogProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    (async () => {
      const supabase = createClient();
      const { data } = await supabase.auth.getUser();
      const user = data?.user;
      if (!user) return;  // Anonymous → no init

      const { data: row } = await supabase
        .from("users")
        .select("notification_prefs")
        .eq("id", user.id)
        .maybeSingle();

      const consented = row?.notification_prefs?.product_telemetry === true;
      if (!consented) { resetPostHog(); return; }

      initPostHog();
      identifyPostHog(user.id, { role: user.user_metadata?.role });
    })();
  }, []);

  return <>{children}</>;
}

Critical rule: posthog.init() does NOT run unless the user is authenticated AND has explicitly opted in. Anonymous visitors get nothing.


Phase 6 — DSAR endpoint (the multi-layer purge)

// src/app/api/dsar/delete/route.ts
import { NextRequest } from "next/server";
import { createClient } from "@supabase/supabase-js";

async function deletePostHogPerson(userId: string) {
  const res = await fetch(
    `https://eu.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/persons/?distinct_id=${encodeURIComponent(userId)}`,
    { method: "DELETE", headers: { Authorization: `Bearer ${process.env.POSTHOG_API_KEY}` } }
  );
  if (!res.ok && res.status !== 404) throw new Error(`PostHog delete failed: ${res.status}`);
}

export async function POST(req: NextRequest) {
  const { userId } = await req.json();
  // ... auth check ...

  const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);

  // 1. Delete from Supabase user_events
  await supabase.from("user_events").delete().eq("actor_user_id", userId);

  // 2. Delete from PostHog (identify revert)
  await deletePostHogPerson(userId);

  // 3. Audit log
  await supabase.from("dsar_audit_log").insert({
    user_id: userId,
    action: "delete",
    completed_at: new Date().toISOString(),
  });

  return Response.json({ ok: true });
}

The audit log is mandatory under PDPA §39 "demonstrable accountability". Keep it forever — it's how you prove compliance during an audit.


3 defense layers on top of the baseline

Layer A — PostHog realtime transformations

Configure in PostHog dashboard (Settings → Data Management → Transformations):

  • Property Filter — drop properties matching email, phone, etc.
  • URL Parameter Masking — strip ?email= from $current_url
  • Hash Values — SHA-256 specific properties before storage

This is a defense if your app-side rejectIfPii misses a field PostHog SDK auto-captures (e.g., a form input's name attribute).

Cost: zero code change. Defense gain: high. Apply first.

Layer B — Reverse-proxy ingestion

PostHog SDK calls https://eu.posthog.com directly. Ad blockers commonly block this. Route through your own domain instead:

// src/app/api/posthog/[...path]/route.ts
const POSTHOG_HOST = "https://eu.posthog.com";

async function proxy(req: Request, { params }: { params: Promise<{ path: string[] }> }) {
  const { path } = await params;
  const url = new URL(`${POSTHOG_HOST}/${path.join("/")}`);
  url.search = new URL(req.url).search;

  const headers = new Headers(req.headers);
  headers.delete("x-forwarded-for");
  headers.delete("cf-connecting-ip");
  headers.delete("cookie");

  const init: RequestInit = { method: req.method, headers, redirect: "follow" };
  if (req.method !== "GET" && req.method !== "HEAD") init.body = await req.arrayBuffer();

  return fetch(url, init);
}

export const GET = proxy;
export const POST = proxy;
export const OPTIONS = proxy;

Then POSTHOG_HOST = "/api/posthog" in posthog.ts.

Trade-off: PostHog loses IP-based geo. If geo is needed, derive it server-side from CF-IPCountry / Vercel headers and pass as country_code event property.

Cost: +30-80 ms per event. Apply last.

Layer C — Identify revert on opt-out

When a user toggles product_telemetry from true → false, don't just stop sending events. Delete their existing PostHog profile.

Same code as DSAR delete above. Wire it into the consent toggle handler.

Cost: runs only on opt-out / DSAR. Defense gain: PDPA completeness.

Order to apply: A → C → B. Zero-cost defense first, compliance gap second, latency-sensitive last.


Anti-patterns

  • Tracking everything "just in case" — every event is a PDPA liability. Lock KPI questions first; only track what answers a question.
  • Hidden Reject button — PDPA requires equal prominence. Reject is a finding if smaller, lower contrast, or below the fold.
  • No DSAR endpoint — having a "contact us" form isn't enough. PDPA grants the data subject the right to a programmatic access path.
  • Cookie-only consent storage — consent should be in the DB (auditable). Cookie-only loses history on browser clear.
  • App-level PII check only — without DB CHECK constraint, one missing validation lets PII through.
  • Default opt-in — PDPA requires explicit consent. Opt-in checkbox must be unticked by default.
  • No audit log — §39 "demonstrable accountability" requires you to prove what consent was given when. Reconstruction from logs is not enough.

Author note

I'm Mankhong. I build side-project marketplaces in the Thai market — this pattern came out of the real need to ship analytics on a Thai property platform without inviting a PDPC visit. The first iteration ran for 6 months in production before I extracted it into a reusable pattern.

This is not legal advice. PDPA enforcement is evolving. Consult a Thai PDPA-qualified lawyer for your specific case. The pattern here is the engineering side; the consent UI copy and DPO appointment still need a legal review.


Sources

About

PDPA-strict analytics on Next.js + Supabase + PostHog · defense-in-depth pattern from Thailand (anonymous-by-default · DB CHECK PII guard · DSAR · 3 defense layers)

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors