@dylanmerigaud/microsaas-kit
v0.12.1
Published
The invariant machinery of the micro-SaaS template, imported (never copied) by the template and every bet: logger, env factory, oRPC/db/stripe/email seams, structured-data, shadcn ui primitives, and the Claude Code CLI structured-output transport.
Readme
@dylanmerigaud/microsaas-kit
The invariant machinery of the micro-SaaS template, imported (never copied) by the template and every bet as a versioned dependency (the pre-eject react-scripts model). A bet keeps only bet-specific code; shared machinery lives here once. No more copy divergence.
What's in it (subpath exports)
Each subpath is imported directly, e.g. import { cn } from "@dylanmerigaud/microsaas-kit/utils".
| Subpath | What it is |
| ------------------ | ----------------------------------------------------------------------------------------------- |
| /logger | createLogger(isDev) -> the log object (the sanctioned console user) |
| /utils | cn (clsx + tailwind-merge) |
| /email | sendPaymentConfirmation, createEmailDeps({ apiKey, from }), the receipt template |
| /site-url | resolveSiteOrigin(configuredUrl) |
| /stripe | createGetStripe(secretKey) -> memoized getStripe, talking the pinned STRIPE_API_VERSION. Unpinned, the client speaks whatever the installed SDK defaults to, so a minor bump under the ^22 peer range moves the money path's wire version with no diff to review |
| /stripe-events | handleStripeEvent(event, deps, warn) + its types (the webhook money path) |
| /stripe-doctor | runStripeDoctor(config, io?) -> a StripeDoctorReport (six read-only checks that the money path is WIRED: the deployed build's origin, the price mode against the key mode, charges_enabled, the webhook endpoint's subscribed events, the deployed signing secret, and the enabled payment methods against what the handler can settle). No bin on purpose: a binary in node_modules/.bin cannot reach the bet's @/lib/stripe-events, so it would have to re-derive HANDLED_STRIPE_EVENTS and reintroduce the desync that constant prevents. The bet passes its own set in, and keeps the env reading, the printing and the process.exit |
| /structured-data | softwareApplicationJsonLd(input), faqPageJsonLd(entries), websiteJsonLd(input) (Google's site-name signal), organizationJsonLd(input) (a stable @id, an absolute logo, sameAs), serializeJsonLd |
| /blog | BlogPost/BlogSection types, blogPostingJsonLd(input) (optional imageUrl/organizationId), newestFirst(posts), formatBlogDate(isoDate) (the typed content model + Article JSON-LD for a bet's /blog surface) |
| /theme-meta | ThemeMeta type + DEFAULT_THEME_META |
| /maker-handle | makerXHandle(url) |
| /orpc | createContextFactory({ getSession }), createOrpcClient<Router>({ siteOrigin }) |
| /db | createGetDb({ connectionString, schema }) -> memoized getDb (drizzle over Neon) |
| /env | baseServerSchema, baseClientSchema, betEnvBaseOptions to spread into a bet's createEnv |
| /indexnow | submitSitemap(input, fetchImpl?), submitIndexNow, buildIndexNowPayload, parseSitemapUrls (POST changed pages to Bing/IndexNow so AI search re-crawls in hours, not weeks) |
| /pseo | thinEntries, assertNotThin, pageUrls, sitemapEntries on a PseoPageType (one data entry = one static page; refuses thin near-duplicate bodies and duplicate slugs, and requires a funnelEvent on every page type) |
| /pins | pinTargets, renderPins(targets, io), pngDimensions, PNG_MAGIC, MIN_PIN_WIDTH (fetch each entry's non-public /pin/<category> route off a running dev server, check the PNG magic and the 1000px Pinterest minimum, write the bytes; the entry list and the output directory are parameters, and publishing stays out of the kit, a Python-plus-MCP procedure in growth-cockpit) |
| /claude-cli | runClaudeCliJson, runClaudeCliVision, pickTransport, extractJsonObject, ... (subscription-billed structured output) |
| /ui | shadcn primitives: Button, Card, Input, Label |
| /components | ProductDemo, QueryProvider (the pricing CTA stays per-bet: it wires a bet's billing procedure) |
| /analytics | Geo-scoped consent (resolveConsentGate, isConsentGated, shouldShowConsentBanner), the PostHog browser event queue (capturePostHogEvent, ...), the same-origin proxy helpers (POSTHOG_PROXY_PATH, posthogRewrites, posthogTrailingSlashRedirect), the dev-host guard (shouldCaptureFromHost, DEFAULT_SKIPPED_HOSTS), the href-to-event resolver (resolveClickEvent), trackMetaEvent, createReplayScope(prefixes, options?) (builds the fail-closed route predicate PostHogClient's isReplayAllowedPath prop expects), the AI-crawler counter (reportAiCrawlerHit, detectAiCrawler, AI_CRAWLERS: one ai_crawler_hit PostHog event per GPTBot/ClaudeBot/PerplexityBot-class request, sent from proxy.ts with a bare fetch, no person, no consent branch). Browser and Middleware safe: contains no Node built-ins |
| /analytics/server | createServerAnalytics, createMetaCapi. Node-only (posthog-node, node:crypto): kept OUT of /analytics so browser and Middleware code importing that barrel never asks a bundler for node:fs |
| /analytics/consent-banner | ConsentBanner. Its own subpath because it is the only analytics component needing the shadcn /ui; importing it from the components barrel forced shadcn-free sites to stub four packages |
| /analytics/components | PostHogClient, MetaPixel, TrackClicks ("use client"; take site/host/the route-allowlist predicate/pixel id as props, never hardcoded), CheckoutCtaLink (a styled anchor that fires a PostHog event, checkout_click by default, tagged with a surface the bet declares its own union for) |
Three things to know before wiring analytics into a bet
One project, sliced by site. Every bet reports into ONE PostHog project (the free plan
allows a single one), so every event carries site and every insight filters on it. Filter at
the DASHBOARD level, not per tile: a tile filter on the same key overrides the dashboard filter
rather than adding to it. extraProperties exists only to let a site mid-rename keep emitting
its old property while insights migrate.
Run ingestion through your own origin. Every ad-blocker list ships the PostHog hostname by
name, and on a developer audience that is 40 to 60 percent of visitors. Pass
host={POSTHOG_PROXY_PATH} to PostHogClient, but next.config.ts CANNOT import the kit to get
that path or its rewrites: Next transpiles next.config.ts through SWC targeting CommonJS, the kit
ships ESM-only with no require condition, and both a static import and a dynamic await import()
throw ERR_PACKAGE_PATH_NOT_EXPORTED there (SWC downlevels the dynamic form back into a
require() call). Both shipped consumers (wedpalette, merigaud.com) inline the three rewrite rules
as local literals in next.config.ts instead, catch-all last, since a catch-all placed first would
swallow /static and /array:
const POSTHOG_PROXY_PATH = "/hue";
const POSTHOG_ASSET_HOST = "https://us-assets.i.posthog.com";
const POSTHOG_INGESTION_HOST = "https://us.i.posthog.com";
rewrites: () => [
{ source: `${POSTHOG_PROXY_PATH}/static/:path*`, destination: `${POSTHOG_ASSET_HOST}/static/:path*` },
{ source: `${POSTHOG_PROXY_PATH}/array/:path*`, destination: `${POSTHOG_ASSET_HOST}/array/:path*` },
{ source: `${POSTHOG_PROXY_PATH}/:path*`, destination: `${POSTHOG_INGESTION_HOST}/:path*` },
],posthogRewrites still lives in /analytics, but not as something a config file can import: it is
the executable REFERENCE for this exact shape, pinned by src/analytics/proxy.test.ts, so diff
your next.config.ts literals against it by hand whenever either one changes.
proxy.ts has no such restriction: it bundles through the normal Turbopack app pipeline instead of
Next's special-cased config loader, so import posthogTrailingSlashRedirect and
POSTHOG_PROXY_PATH from @dylanmerigaud/microsaas-kit/analytics there normally (static or
dynamic both resolve fine). next.config.ts's skipTrailingSlashRedirect: true (PostHog endpoints
end in a slash) switches OFF Next's redirect for real pages, so proxy.ts must re-apply it with
posthogTrailingSlashRedirect or every page answers on two URLs and splits its own SEO.
One more place the same path has to agree: proxy.ts's middleware matcher must exclude the proxy
path, and Next requires that matcher to be a statically analysable array of literals, so it cannot
interpolate POSTHOG_PROXY_PATH into the exclusion. Assert the literal against the constant at
runtime instead, the way merigaud.com/proxy.ts does it. Skip that assertion and a drift between
the two goes unnoticed: ingestion blackholes silently, with no error anywhere.
Keep uiHost at its default unless the org moves region, or the toolbar breaks.
The banner's chrome is yours if you want it. ConsentBanner renders shadcn markup by
default, which needs /ui. A site without shadcn passes a children render prop and gets only
the decision plus the two posthog-js consent calls. A site that wants neither can compose its own
from shouldShowConsentBanner, readConsentCookie, getPostHogInstance and
POSTHOG_READY_EVENT directly.
Consumption pattern (why the template keeps thin shims)
The template (and each bet) keeps THIN SHIM FILES at the existing paths that re-export or bind the
kit: lib/logger.ts becomes export const log = createLogger(env.NODE_ENV !== "production"),
lib/utils.ts becomes export * from "@dylanmerigaud/microsaas-kit/utils", and so on. So
@/lib/logger imports keep working everywhere, the repo rules keep their enforcement anchor
(only lib/logger.ts may console, only lib/env.ts touches process.env), and a bet that must
diverge on one module deletes the shim and owns the file again, explicitly. That is THE divergence
mechanism.
Env-bound and per-bet-typed modules (logger, site-url, stripe, email, db, orpc, structured-data,
analytics's createMetaCapi/createServerAnalytics) are exported as FACTORIES the bet binds;
pure modules (utils, theme-meta, maker-handle, stripe-events, ui, components, analytics's
consent-geo/posthog-consent/posthog-browser/meta-pixel-client, analytics/components) are bare
re-exports.
Consumer requirements
- Tailwind v4 must scan the kit's dist. Add to the consumer's
globals.css:@source "../node_modules/@dylanmerigaud/microsaas-kit/dist"; - The kit ships
peerDependencies(react, next, stripe, drizzle-orm, radix-ui, ...); the consumer already owns them, so nothing is bundled twice.
The update flow
- Edit the source here (
packages/microsaas-kit/src/...), with its tests. pnpm build && pnpm test(both are gates;prepublishOnlyruns them).- Bump
version, thennpm publish(access is public). - Bump
@dylanmerigaud/microsaas-kitin each consumer (the template and every bet) and reinstall.
During development in THIS repo the template links the kit through the pnpm workspace (a file:
dependency), so edits are picked up on the next pnpm build without publishing. In a bet clone the
kit resolves from npm, so packages/ here is inert source there.
Build
ESM only (type: module), NodeNext, tsc to dist/ with .d.ts. The React subpaths keep their
"use client" directive through the build (verified in dist output).
