A static build has no server, so the two things every content site needs — a contact form and a comment thread — are the first casualties of a migration. Contentrain solves them with one public, unauthenticated API and two clients that speak it:
Same endpoints, same payloads, same rules. The emitted version inlines its client into src/lib/embed.ts precisely so a migrated site depends on nothing but Astro.
These endpoints are called from a visitor's browser, on a page anyone can view.
A page cannot keep a secret
There is no session and no API key in any of this — by design, not by omission. Studio's CORS for these routes allows only Content-Type, so an Authorization header would fail the preflight before the request was ever sent.
The emitter never writes a credential into a generated site. If you find yourself wanting to pass one here, the design has gone wrong somewhere upstream.
What the binding carries instead is public information: an origin and a project id.
json
// src/data/runtime.json — the only place the binding lives{ "base_url": "https://studio.contentrain.io", "project_id": "proj_…"}
Components read it at build time. When the project id does not exist at emit time — a Studio project created after the migration — write this one file and rebuild. No re-emit, and the component files do not change.
GET {base}/{projectId}/{modelId}/config → FormConfigPOST {base}/{projectId}/{modelId}/submit → FormSubmitResult
A form names a Contentrain model; the model's exposed fields are the form.
ts
const form = client.form()const config = await form.config('contact')// { modelId, locale, fields, captcha, captchaSiteKey, successMessage, honeypotField }const result = await form.submit('contact', { name, email, message }, { captchaToken, // when config.captcha is set honeypot: '', // the hidden input — a human leaves it empty})// { success: true, message } · { success: false, errors: [{ field, message }] }
fields is a map keyed by field id, carrying the model's own FieldDef shape — so the renderer gets the field type, and validation on the server is the same validation the model already declares. locale is the project default: what a submission is validated against and written to.
Submitted values are wrapped in data so the control fields (captchaToken, _hp) can never collide with a model field named captchaToken.
GET {base}/{projectId}/{modelId}/{entryId}?locale&page&limit&sort → CommentThreadPOST {base}/{projectId}/{modelId}/{entryId}?locale → CommentSubmitResult
A thread is keyed on the entry address — model, entry id, locale — which is exactly what EmitPost.entry carries onto each generated page. A translated post has its own thread, because it is its own entry address.
Each comment comes back as { id, parentId, depth, author, body, type, createdAt, replies }, already nested under its root.
Two rules the renderer must not break
Only approved comments are returned. A pending comment lives on the provider and is never in the public response — so a submission answering status: 'pending' must tell the visitor their comment is awaiting moderation, not optimistically render it as published.
body is plain text. Render it escaped. It is visitor-authored content arriving over an unauthenticated endpoint; treating it as markup is a stored-XSS hole with a queue in front of it.
Email, IP, user agent and referrer never leave the server. author.url is public and nullable; author.isModerator is set when a workspace member wrote the comment from Studio.
The thread's config tells the renderer what to draw:
WordPress comments come across in the import. contentrain import writes a comments-export.json — a contentrain-comments@1 payload built from the RawIR and the EntrySourceMap, because the WP-id → entry-address mapping only exists at conversion time.
That file is an intake payload for a live comments service, not a content store. Loading it is what makes the old threads appear under the new pages.
Comments that point nowhere
The import warns when comments reference posts outside the import — a partial REST import orphans them. Check that warning before loading the export, or those threads land under nothing.
Then, at emit time:
a comments component with a runtime binding becomes <cr-comments> on the entry pages
a form component becomes <cr-form> and must name its model (ComponentDef.model)
without a binding — or a form without a model — the component stays a placeholder, and each one is named in result.warnings
A placeholder looks fine in a screenshot. Read the warnings.
Forms & Comments
A static build has no server, so the two things every content site needs — a contact form and a comment thread — are the first casualties of a migration. Contentrain solves them with one public, unauthenticated API and two clients that speak it:
<cr-form>/<cr-comments>custom elements@contentrain/emitter-astroFormsClient/CommentsClient@contentrain/query/cdnSame endpoints, same payloads, same rules. The emitted version inlines its client into
src/lib/embed.tsprecisely so a migrated site depends on nothing but Astro.No credential ever travels
These endpoints are called from a visitor's browser, on a page anyone can view.
A page cannot keep a secret
There is no session and no API key in any of this — by design, not by omission. Studio's CORS for these routes allows only
Content-Type, so anAuthorizationheader would fail the preflight before the request was ever sent.The emitter never writes a credential into a generated site. If you find yourself wanting to pass one here, the design has gone wrong somewhere upstream.
What the binding carries instead is public information: an origin and a project id.
Components read it at build time. When the project id does not exist at emit time — a Studio project created after the migration — write this one file and rebuild. No re-emit, and the component files do not change.
Forms
A form names a Contentrain model; the model's exposed fields are the form.
fieldsis a map keyed by field id, carrying the model's ownFieldDefshape — so the renderer gets the field type, and validation on the server is the same validation the model already declares.localeis the project default: what a submission is validated against and written to.Submitted values are wrapped in
dataso the control fields (captchaToken,_hp) can never collide with a model field namedcaptchaToken.The two spam defences
honeypotFieldnames a hidden input to render and leave empty. A filled one is dropped silently, server-side — a bot learns nothingcaptcha: 'turnstile'with acaptchaSiteKey, when the operator configured one. A failed token comes back as an ordinary field error oncaptchaBoth follow the project's configuration. Neither is invented by the emitter.
Comments
A thread is keyed on the entry address — model, entry id, locale — which is exactly what
EmitPost.entrycarries onto each generated page. A translated post has its own thread, because it is its own entry address.Each comment comes back as
{ id, parentId, depth, author, body, type, createdAt, replies }, already nested under its root.Two rules the renderer must not break
Only approved comments are returned. A pending comment lives on the provider and is never in the public response — so a submission answering
status: 'pending'must tell the visitor their comment is awaiting moderation, not optimistically render it as published.bodyis plain text. Render it escaped. It is visitor-authored content arriving over an unauthenticated endpoint; treating it as markup is a stored-XSS hole with a queue in front of it.Email, IP, user agent and referrer never leave the server.
author.urlis public and nullable;author.isModeratoris set when a workspace member wrote the comment from Studio.The thread's
configtells the renderer what to draw:closedrequireApprovalrequireEmailmaxDepth0is flatmaxBodyLengthcaptcha/captchaSiteKeyOn a migrated site
WordPress comments come across in the import.
contentrain importwrites acomments-export.json— acontentrain-comments@1payload built from theRawIRand theEntrySourceMap, because the WP-id → entry-address mapping only exists at conversion time.That file is an intake payload for a live comments service, not a content store. Loading it is what makes the old threads appear under the new pages.
Comments that point nowhere
The import warns when comments reference posts outside the import — a partial REST import orphans them. Check that warning before loading the export, or those threads land under nothing.
Then, at emit time:
commentscomponent with a runtime binding becomes<cr-comments>on the entry pagesformcomponent becomes<cr-form>and must name its model (ComponentDef.model)result.warningsA placeholder looks fine in a screenshot. Read the warnings.
The acceptance test
A migration is not finished because the components render. The gate is the round trip:
Both halves matter. The second one is the one that is easy to get wrong and expensive to discover later.
Related Pages