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.
The labels, buttons and messages a visitor sees — "Post comment", "Your comment is awaiting moderation", "Send" — are the site's content, not its code. They live in a dictionary in the site's own store, .contentrain/content/site/ui-strings/{locale}.json, and the components read the page language's file at build time:
Edit it in Studio or through MCP, rebuild, and the site says the new text — no re-emit.
A key the dictionary lacks shows its English default. A page in another language with no dictionary shows English throughout, and the build log says so — once per language, naming the file or the missing keys.
The key list, the English defaults and the model definition are exported from @contentrain/emitter-astro as UI_STRING_DEFAULTS and UI_STRINGS_MODEL, so a migration creates exactly the dictionary the site reads. Declare the languages it wrote in options.uiStrings.locales and the emit report names any page language left without one.
Studio answers 402 payment_required on the public forms and comments endpoints when the workspace's billing is locked (trial or grace period over, subscription canceled). The body's data.code is payment_required, and its message is written for the workspace owner, not for a visitor.
<cr-form> and <cr-comments> take themselves off the page when they get it, whether loading, submitting or loading more comments:
The element is emptied and hidden (hidden plus an inline display: none, so a site stylesheet cannot bring back an empty box). The rest of the page is untouched, and the visitor sees no error.
The request is not retried: nothing changes until the owner updates billing.
One console.debug line tells the owner what happened.
Every other failure (a 403 *.upgrade, a rate limit, validation) is shown as before. The emitted isPaymentRequired() checks the status or the code; it does not read billingState.
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.
What the thread and the form say
The labels, buttons and messages a visitor sees — "Post comment", "Your comment is awaiting moderation", "Send" — are the site's content, not its code. They live in a dictionary in the site's own store,
.contentrain/content/site/ui-strings/{locale}.json, and the components read the page language's file at build time:@contentrain/emitter-astroasUI_STRING_DEFAULTSandUI_STRINGS_MODEL, so a migration creates exactly the dictionary the site reads. Declare the languages it wrote inoptions.uiStrings.localesand the emit report names any page language left without one.When the workspace's subscription is inactive
Studio answers
402 payment_requiredon the public forms and comments endpoints when the workspace's billing is locked (trial or grace period over, subscription canceled). The body'sdata.codeispayment_required, and itsmessageis written for the workspace owner, not for a visitor.<cr-form>and<cr-comments>take themselves off the page when they get it, whether loading, submitting or loading more comments:hiddenplus an inlinedisplay: none, so a site stylesheet cannot bring back an empty box). The rest of the page is untouched, and the visitor sees no error.console.debugline tells the owner what happened.Every other failure (a 403
*.upgrade, a rate limit, validation) is shown as before. The emittedisPaymentRequired()checks the status or the code; it does not readbillingState.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