@c1m-public/dash-site-kit
v0.17.0
Published
Shared transport, domain and UI layer for MML dash-scoped public sites
Readme
@c1m-public/dash-site-kit
The shared transport, domain and presentation layer for MML dash-scoped public sites — the marketing site a dash publishes at its own domain, backed by a dash-scoped SaaSDash Channel token.
It exists because that layer was being copy-pasted. Across the maintained
dash-site base and its commissioned sites, roughly 49 of 69 shared-path source
files were byte-identical, and the copies had begun to drift: one site missed a
fix and served the first visitor's currency to everyone for 60 seconds. This
package is that identical set, published once.
Not for workspace sites. A workspace site's surface (cart, catalog, giving, session, subscriptions) shares almost nothing with this one — measured at 3 of 16 files. Do not widen this package to cover it.
Entry points
The three subpaths are the layer boundary, made structural:
design (app/, components/) → logic → access
/ui . /next| Import | Layer | Contents |
| --- | --- | --- |
| @c1m-public/dash-site-kit | logic | domain models, ports, validation, URL/image policy, formatMoney, createSiteConfig |
| @c1m-public/dash-site-kit/next | access | channel client, providers, createDashDataLayer |
| @c1m-public/dash-site-kit/ui | design | structural primitives |
The root is framework-neutral — no server-only, no Next, no React — so it is
safe to import from anywhere, including a client component. /next is
server-only.
Forbid /next in your design layer. A page that can call the door directly
makes the backend un-swappable and starts leaking the channel token into things
that render:
// eslint.config.mjs
{
files: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": ["error", {
paths: [{
name: "@c1m-public/dash-site-kit/next",
message: "The design layer must not talk to the API. Use the data layer you composed in lib/.",
}],
}],
},
}Composing a site
Two calls, in one lib/ module the rest of the site imports:
// lib/site.ts
import "server-only";
import { createSiteConfig } from "@c1m-public/dash-site-kit";
import { createDashDataLayer } from "@c1m-public/dash-site-kit/next";
import siteDefaults from "@/site.defaults.json";
import { isLocalPreviewMode, localPreviewBootstrap, localPreviewProfile } from "@/lib/preview";
export const data = createDashDataLayer({
leadSource: siteDefaults.leadSource ?? siteDefaults.name,
preview: {
enabled: isLocalPreviewMode,
bootstrap: localPreviewBootstrap,
profile: localPreviewProfile,
},
});
export const site = createSiteConfig(siteDefaults, {
loadBootstrap: data.loadDashBootstrap,
});data exposes loadDashBootstrap, loadDashProfile, listDirectoryRegions,
getDirectoryRegion, listDirectory, getDirectoryProfile, loadDashPlans,
submitLead, requestPortalLink and viewPortal. Every read is wrapped in
React cache, so the shell and the page share one request-scoped fetch.
Why these two arguments and no others
They are the only things that legitimately differ between two dash sites:
leadSourceis recorded on every capture so a dash's CRM can tell its sites apart. It is required, not defaulted — a wrong source silently mis-attributes real leads, which is worse than a startup error.previewis the credential-free local preview. This is the seam that used to force every site to hand-write its ownlib/{bootstrap,directory,profile}/index.ts, and those files were the single largest source of drift between sites that were otherwise identical.
Everything else — the transport, the normalisation, the validation, the cache policy — is the same everywhere by definition, which is why it is in here.
Configuration
The access layer reads the channel token from the environment. SAASDASH_* is
preferred; C1M_* is accepted for older sites:
| Variable | Required | Meaning |
| --- | --- | --- |
| SAASDASH_CHANNEL_TOKEN / C1M_CHANNEL_TOKEN | yes | dash-scoped channel key, sk_chan_… |
| SAASDASH_API_URL / C1M_API_URL | no | override only for local or self-hosted |
The token is server-only. A NEXT_PUBLIC_* form of either variable is
rejected at startup rather than ignored.
Error reporting
The kit owns the shared channel-site Sentry options and validation, but the
site owns the SDK import. Keep the static import in the special Next.js file so
Turbopack can include @sentry/nextjs in both server and edge outputs:
// instrumentation.ts, or src/instrumentation.ts when the app uses src/app
import * as Sentry from "@sentry/nextjs";
import {
createChannelSiteRequestErrorHandler,
initializeChannelSiteSentry,
} from "@c1m-public/dash-site-kit/next";
export function register() {
initializeChannelSiteSentry(Sentry.init);
}
export const onRequestError = createChannelSiteRequestErrorHandler(
Sentry.captureRequestError,
);The consuming site must install @sentry/nextjs. The kit deliberately does not
list or dynamically discover it: an indirect runtime import can be omitted from
a Vercel function bundle and turn a packaging error into silent telemetry loss.
| Variable | Required | Meaning |
| --- | --- | --- |
| CHANNEL_SITE_SENTRY_DSN | no | Shared channel-sites Sentry DSN; absence disables reporting |
| CHANNEL_SITE_NAME | no | Backwards-compatible site-tag override; otherwise the Vercel production hostname is used |
| CHANNEL_SITE_CHANNEL_ID | no | Immutable channel identifier added as the channel_id tag |
| SENTRY_ENVIRONMENT | no | Explicit Sentry environment; otherwise VERCEL_ENV is used |
Browser capture uses the framework-neutral builder from the package root. The
consumer passes literal NEXT_PUBLIC_* values so Next.js can inline them:
// instrumentation-client.ts
import * as Sentry from "@sentry/nextjs";
import { resolveChannelSiteSentryOptions } from "@c1m-public/dash-site-kit";
const options = resolveChannelSiteSentryOptions({
dsn: process.env.CHANNEL_SITE_BROWSER_SENTRY_DSN,
environment: process.env.NEXT_PUBLIC_VERCEL_ENV,
release: process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA,
site: globalThis.location.hostname,
});
if (options) Sentry.init(options);
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;The browser DSN is public by design. Source-map upload credentials remain
build-only and must never use NEXT_PUBLIC_*.
A configured but malformed DSN or missing site identity fails fast. On the
server the kit uses CHANNEL_SITE_NAME when present, then derives site from
VERCEL_PROJECT_PRODUCTION_URL. Browser composition roots use
location.hostname. Every event also receives site_kind=dash-site, so one
shared Sentry project and one Grafana dashboard can separate future site
families without another per-deployment variable. This prevents a deployment
from claiming error reporting while sending no attributable events. The
observability.grafana_dashboard event context links directly back to the same
site selection in the shared Grafana dashboard; it is derived from site and
does not need configuration.
Use CHANNEL_SITE_CHANNEL_ID only when one deployment always represents one
channel. If a process can serve multiple channels, attach channel_id inside a
request-local Sentry scope after resolving the request instead of setting a
global initialization tag.
Cache policy
Two clients, deliberately:
- A shared, revalidating client for reads that are identical for every visitor — profile, directory, regions.
- A
no-storeclient for anything whose answer depends on who is asking — geo detection (which picks the pricing region and therefore the displayed currency) and lead capture.
The npm channel client forces no-store only for mutations and
customer-session reads, so a signed-visitor GET on the shared client would be
answered from the first visitor's cache entry for everyone that follows. That is
a real bug this package exists to stop repeating, and it is covered by tests
that fail if the two clients are merged.
Styling contract
/ui ships structural primitives, not a design system. They render Tailwind
utility classes that resolve against CSS custom properties the site owns:
--ink, --muted, --subtle, --border, --accent, --accent-foreground,
--accent-text
A site rebrands by redefining those tokens in its own globals.css, not by
forking the components. If a primitive needs a variant that cannot be expressed
through the tokens, add the variant here rather than copying the file back into
a site — that copy is how the drift started.
Tailwind v4 does not scan node_modules, so every site must add this line
after @import "tailwindcss"; in its globals.css. Without it, kit
components render only the classes the site happens to use elsewhere:
@source "../node_modules/@c1m-public/dash-site-kit/dist";Landing pages
DashLandingPage renders a typed LandingContent around any call to action:
the hero, then optional how it works, benefits, audiences, FAQ and closing
sections. DashWaitlistLanding puts the waitlist form in it. Sites own the
copy; the kit owns the structure. Use the generic DashWaitlistPage when a
Dash has no launch copy yet.
Site modes
The Dash's mode arrives in dash.init's signals as the server-decided
gate, and createDashSiteProxy enforces it before anything renders, through
the same siteDecision workspace sites use:
- Waitlist (page placement): every non-exempt path gets a 307 to
/waitlist. Signups are stored as CRM leads. - Maintenance: every non-exempt path is served
/maintenancein place with 503,Retry-After(when the owner announced an end) andnoindex. Add anapp/maintenance/page.tsxthat rendersDashMaintenancePage.
Legal pages, framework routes and crawler files are always exempt
(GATE_EXEMPT_PATHS); pass exempt for site-specific paths. A mode this kit
version does not know is ignored, so the server can add modes without
breaking older sites.
Legal pages
Legal pages stay reachable in every site mode, so every site needs them before
it collects signups. Build them from the site's facts and render them with
DashLegalPage:
import { DashLegalPage } from "@c1m-public/dash-site-kit/ui";
import { privacyNotice } from "@c1m-public/dash-site-kit";
const facts = { siteName: "SimpleVan", contactHref: "/contact", sharesWithProviders: true, waitlist: true, updated: "2026-09-22" };
export default function PrivacyPage() {
return <DashLegalPage document={privacyNotice(facts)} siteName={facts.siteName} />;
}cookieNotice and termsOfUse take the same facts. The text states what the
platform collects (requests, waitlist sign-ups, the c1m_ft attribution
cookie); pass operator with the registered company name and review the text
before relying on it.
Peer dependencies
@c1m-public/channel-client (>=0.36.0), next 16, react 19.
The moving quote form, validation, server data layer and customer lead portal
are shared across Dash sites. A site supplies its own channel client and
server actions, then renders DashMovingQuoteForm and DashLeadPortalPage from
/ui. Quote submission uses one attempt ID as an idempotency key; address
lookup and distance calls go through the site's server route so the channel
credential stays server-side. Other industries can add a typed intake model
without changing the broker's delivery and portal infrastructure.
Admin host links
A Dash's providers sign up, sign in and manage their workspace on the Dash's
admin host (https://admin.<dash domain>, or https://<slug>.saasdash.app
before it has a domain), never on saasdash.app. Set adminUrl in
site.defaults.json and build links with createSiteConfig().adminUrl(path).
DashAdminLink swaps its label while the admin host's c1m_signed_in hint is
set; the session itself never leaves the admin host.
