TL;DR
apps/forms (forms.tuturuuu.com, port 7828) owns the whole forms product.
Three surfaces are worth knowing about before you touch it:
- Question types — twenty block types, of which fifteen collect an answer.
- Embedding — six modes driven by a dependency-free SDK at
/embed.js,
rendered by the /embed/<shareCode> route.
- SEO — per-form overrides stored in
private.forms.seo, applied to
/f/<shareCode> metadata.
- Realtime collaboration — presence and broadcast on a private
form-studio-<formId> channel, never postgres_changes.
Question types
Registered in FORM_QUESTION_TYPE_VALUES (schema.ts) and grouped in
block-utils.ts. Adding a type means adding it to the enum, to the right
grouping, to the icon map, and to the studio’s block catalog — the type system
enforces the icon map, but not the other three.
Text inputs
short_text, email, phone, number and url render one line and differ
only in what the browser is told. That mapping is most of the mobile
experience: inputMode picks the on-screen keyboard and autoComplete decides
whether the field can be filled from saved contact details.
number uses inputMode="decimal", not "numeric". numeric hides the
decimal point on iOS, which silently makes non-integer answers untypeable.
These types validate themselves. getIntrinsicValidationMode() maps the type
to a rule that applies whether or not the author opened the validation panel; an
author-chosen validationMode layers on top rather than replacing it, so a
number with a regex still has to be a number.
A url must be http(s) with a dotted host. Bare new URL() accepts
javascript: and mailto:, which are not links a respondent can follow.
number also takes a numberStep, measured from the minimum rather than
from zero, so a “1, 3, 5…” question works. The multiple-of check compares
against both ends of the step because floating-point modulo is inexact —
0.3 % 0.1 is 0.0999…, and a naive check rejects a value the author considers
exact.
NPS
Fixed at 0-10 by the metric’s definition, so unlike linear_scale the bounds
are not author-editable — only the anchor labels are. A scale labelled NPS
that runs 1-5 is not comparable to one that does not.
Scores band the standard way: 0-6 detractor, 7-8 passive, 9-10 promoter.
Analytics reports the real score — promoters minus detractors as a percentage
of all responses, with passives counting toward the denominator only — under
analytics.nps, separate from meanScore because it is not an average.
The runtime renders a native <fieldset> of radios rather than styled buttons
with ARIA roles, so arrow-key navigation, the group label and the disabled
cascade come from the platform.
Ranking
Reorderable options whose answer is the resulting order, stored as an ordered
array of option values.
The answer is written only once the respondent moves something. Seeding it
with the author’s order would mark an untouched ranking as answered — a
required question would pass with nobody having ranked anything, and every
indifferent response would silently agree with the author.
Reordering uses dnd-kit (already the studio’s idiom) rather than native HTML5
drag, which does not fire on touch at all. Move up/down buttons sit alongside
the drag handle so the question works by keyboard and screen reader, and only
the handle is draggable so taps on those buttons are not swallowed.
Stored answers survive the author editing the options later: unknown values are
dropped and new options appended, so an old answer still renders instead of
collapsing to an empty list.
Analytics ranks by mean position, best first, and sorts an option nobody ranked
to the bottom — reporting its average as 0 would float it to the top as if it
were everyone’s first choice. Formatted answers are numbered
(1. Price, 2. Speed) because the order is the entire answer, and a plain
comma-separated list renders “B, A” and “A, B” identically in an export.
Embedding
The studio’s embed panel generates the snippet; this is what it emits and why.
Script SDK (preferred)
The SDK scans for data-tuturuuu-form elements, mounts an iframe pointed at
/embed/<shareCode>, and marks the container data-tuturuuu-form-mounted so a
re-run cannot double-mount it.
inline and fullpage render in place. popup, slider, popover, and
sidetab are overlay modes: they render a launcher and only mount the form when
it opens.
Iframe fallback
Hosts that forbid third-party scripts (locked-down CMSes, most email-adjacent
tools) can use a plain iframe. It loses auto-resize, so give it a height:
The framing exception
Framing is denied platform-wide by createTuturuuuNextConfig
(frame-ancestors 'none' plus X-Frame-Options: DENY). X-Frame-Options has no
“allow from any origin” value, so a later permissive header cannot override the
deny — the header has to be absent instead.
createTuturuuuNextConfig therefore accepts framablePathPatterns, and builds
the anti-framing source as a negative lookahead over every framable path:
Opt in per route, never per app. Everything not listed stays denied.
apps/forms/src/proxy.ts must also treat /embed/ as public, or the embed
redirects to login inside the host’s iframe.
Host-page messages
The embed posts window.postMessage events the SDK listens for. The contract
lives in apps/forms/src/features/forms/embed/protocol.ts and is shared by the
React page and public/embed.js so the two cannot drift.
Every message carries source: 'tuturuuu-forms'; verify it before acting, since
a host page may run other iframes.
private.forms.seo is a jsonb column (constrained to an object) holding
optional overrides. Every key is optional, and an empty string or false means
“fall back to the value derived from the form’s content”:
title, description — override the derived page and social title/description
image — { storagePath, url, alt } for the social card
keywords — text[], replaces the derived list
canonicalUrl — points crawlers at a different canonical
noIndex — emits noindex,nofollow
It is a single jsonb column, matching the existing settings and theme
columns on the same table, so adding a field later needs no migration.
The studio’s SEO panel renders a live search-result preview showing where Google
truncates each field.
Studio collaboration
The studio joins a private Supabase Realtime channel per form, topic
form-studio-<formId>, carrying presence (who is here, which block they are on)
and broadcasts (someone saved).
It uses broadcast and presence rather than postgres_changes because the forms
tables live in the Postgres private schema with service-role-only access. They
are deliberately off the public Data API, so a browser client cannot subscribe to
row changes on them at all.
Access is authorized by private.can_join_form_realtime_topic(topic, user_id),
which resolves the form from the topic suffix and defers to
public.has_workspace_permission(ws_id, user_id, 'manage_forms'). Presence is
gated on the same permission the studio itself requires: analysts who can only
read responses have no reason to appear in an editing session, and would leak
their identity to editors if they did.
apps/forms is a registered satellite app, so the internal-app-auth guard
forbids supabase.auth.* inside it. Resolve identity server-side with
getSatelliteAppSessionUser('forms') and pass the id down as a prop, and take
the realtime client from @tuturuuu/supabase/next/realtime-browser.
When a remote save arrives, the studio refetches immediately only if your copy
is clean. If you have unsaved edits it raises a reload prompt instead —
refetching blindly would discard your work.
Gotchas worth carrying elsewhere
packages/satellite is not in Tailwind’s content scan set. Classes used only
from that package emit no CSS at all, so the markup renders unstyled and
invisible rather than failing loudly. Shared presentational code that needs
Tailwind belongs in packages/ui — that is why the marketing kit lives at
@tuturuuu/ui/marketing.
Calling connection() at the root of a marketing page forces the entire page
dynamic. Scope it to the session-dependent subtree instead, so the static shell
still prerenders.