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';
}
})();
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 });
}
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
}
}
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
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
vidcookie 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.logon 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)