DEV Community

Sike Ren
Sike Ren

Posted on

Tattoo data taught me to delete every third-party script — and build analytics from a SQLite table

The post is a casual chain, not a product pitch. Blackwork is only the evidence.


A tattoo booking is one of the most sensitive payloads a web app can touch. Not in the "payment card" sense — in the body sense. The booking form asks for placement (which arm, which rib), size, reference photos, a deposit amount, and a phone number. That's more personally identifying than a bank transfer: it describes a body and a plan to permanently modify it.

I built Blackwork, a booking/deposit/CRM system for tattoo studios. The studio's clients submit that data. And the default SaaS stack would have handed a copy of it to a dozen companies I never met.

Here's the chain of decisions that ended with: my app ships exactly one inline script.

The default stack leaks

A fresh Next.js app, following the path of least resistance, ships a pile of third-party requests:

  • GA4 or Plausible or PostHog for analytics
  • Sentry for errors
  • Hotjar or Microsoft Clarity for session replay
  • Google Fonts / a font CDN

Each of those is a company that now receives a copy of every page load — IP, user agent, behavior, and whatever ended up in the DOM. For a page that collects tattoo references, that's a leak nobody asked for.

Blackwork's landing page makes a promise: "No analytics scripts, no tracking. Your data is yours."

The mistake would be treating that as marketing copy. I treated it as an architecture constraint. Once it's a constraint, the downstream decisions stop being judgment calls.

Consequence one: exactly one inline script

Here is the entire client-side script footprint of the app — a three-line theme toggler that runs before first paint to avoid a flash:

(function () {
  try {
    var t = localStorage.getItem('blackwork_theme');
    var dark = t === 'dark';
    var el = document.documentElement;
    el.classList.toggle('dark', dark);
    el.style.colorScheme = dark ? 'dark' : 'light';
  } catch (e) {
    document.documentElement.style.colorScheme = 'light';
  }
})();
Enter fullscreen mode Exit fullscreen mode

That's it. Fonts are self-hosted at build time by next/font. No <Script>, no gtag, no error SDK, no widget. If you open DevTools → Network on the public pages, the request list is mostly your own assets.

Consequence two: analytics became a table

But I still wanted to know the same things a founder needs: does anyone land on the pricing page? Do launch channels convert? So I wrote the smallest possible first-party tracker — one route, one INSERT into the same SQLite the app already runs on:

// app/api/track/route.ts — fire-and-forget, returns 204 so the client pays nothing
export async function POST(req: Request) {
  const store = await cookies();
  let vid = store.get("vid")?.value;
  if (!vid) {
    vid = crypto.randomUUID();
    store.set("vid", vid, {
      httpOnly: true,
      sameSite: "lax",
      secure: process.env.NODE_ENV === "production",
      path: "/",
      maxAge: 60 * 60 * 24 * 365,
    });
  }

  let path = "/";
  try {
    const body = await req.json();
    if (typeof body.path === "string" && body.path) path = body.path;
  } catch {
    // malformed body — still count it as a pageview
  }

  trackEvent(eventForPath(path), path, vid);
  return new Response(null, { status: 204 });
}
Enter fullscreen mode Exit fullscreen mode

trackEvent is a prepared INSERT into a site_events table, wrapped in a try/catch so tracking can never break a page:

export function trackEvent(event: string, path: string, visitorId?: string): void {
  try {
    db()
      .prepare("INSERT INTO site_events (event, path, visitor_id, created_at) VALUES (?, ?, ?, ?)")
      .run(event, path || null, visitorId || null, Date.now());
  } catch {
    // best-effort: tracking failure is never fatal
  }
}
Enter fullscreen mode Exit fullscreen mode

The visitor id is an httpOnly cookie, so a visitor can't be fingerprinted from JS, and it's only ever stored first-party.

The dashboard reads the same table

The analytics dashboard at /analytics/[secret] is a server component gated by an env var. No client bundle, no chart library on the page — the queries do the work. The one that surprised me by being pleasant to write is distinct daily visitors:

SELECT strftime('%Y-%m-%d', created_at/1000, 'unixepoch', 'localtime') AS day,
       COUNT(DISTINCT visitor_id) AS n
FROM site_events
WHERE created_at >= ?
GROUP BY day
Enter fullscreen mode Exit fullscreen mode

Plus pageviews, signups, logins, bookings, and subscription states from the same database. The whole thing is under 200 lines of queries and a zero-filled 30-day grid.

What the constraint buys

  • No cookie banner. The vid cookie is first-party, httpOnly, functional. There's nothing to consent to.
  • A one-paragraph privacy policy. "What Blackwork stores, and what we deliberately do not do with your data" — the page honestly describes a very short list of stored things.
  • No GDPR vendor list. The privacy policy doesn't need a table of 40 processors. There is one processor: our own server.

The part I like least

The constraint has a cost, and it's real:

  • No session replay, no heatmaps, no real-time. If a visitor bounces on the pricing page, I know the count went up but not where their cursor went.
  • Crash telemetry is a console.log on a server nobody is watching. The trade: the studio's data never leaves the request path. If the app breaks, the app breaks loudly and locally.
  • Attribution is UTM query params only. "Which directory sent visitors" is answerable. "What's the LTV per channel" is a spreadsheet exercise.

If I ever need real funnel analysis, I'll outgrow this — and that's fine. The point of a constraint isn't to be the best possible analytics setup. It's to make the privacy promise structurally true instead of rhetorically true.

The loop closes

"Zero third-party scripts" reads like a slogan and works like a spec. Every feature that wants a script now has to justify crossing the boundary: it has to be first-party, or it doesn't ship.

The weirdest outcome: a privacy constraint that most products treat as a legal paragraph turned into the product's strongest feature. Studios don't ask "is this compliant?" — they ask "who sees my clients' references?" and the honest answer is one server, ours.

If you're building a vertical SaaS where users hand over data they'd be embarrassed to leak, try deleting every third-party script for a week. The panic lasts a day. The clarity lasts longer.

Top comments (0)