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.
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:
- Consent UI matters as much as data handling. "Accept all" and "Reject all" must have equal prominence. Hidden reject buttons are a finding.
- Audit logs are required. "Demonstrable accountability" (§39) means you must be able to show, per user, what they consented to and when.
- 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.
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.
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.
Default stance: all visitors are anonymous. Personally-identifying tracking only activates after explicit opt-in.
4 components:
// 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.
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS notification_prefs jsonb NOT NULL DEFAULT '{}'::jsonb;Two consents (default both false):
product_telemetry— gates PostHogidentify()and event emitmarketing_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.
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.
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.
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.
// 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.
// 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.
// 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.
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.
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.
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.
- 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.
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.