Skip to content

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:

DoorWho uses itShips in
<cr-form> / <cr-comments> custom elementsA generated site, with no dependencies@contentrain/emitter-astro
FormsClient / CommentsClientAn app you are writing yourself@contentrain/query/cdn

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.

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 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.

Forms ​

GET  {base}/{projectId}/{modelId}/config  → FormConfig
POST {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.

The two spam defences ​

DefenceHow it behaves
HoneypothoneypotField names a hidden input to render and leave empty. A filled one is dropped silently, server-side — a bot learns nothing
Turnstilecaptcha: 'turnstile' with a captchaSiteKey, when the operator configured one. A failed token comes back as an ordinary field error on captcha

Both follow the project's configuration. Neither is invented by the emitter.

Comments ​

GET  {base}/{projectId}/{modelId}/{entryId}?locale&page&limit&sort → CommentThread
POST {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.

ts
const comments = client.comments()

const thread = await comments.thread('posts', entryId, {
  locale: 'en', page: 1, limit: 20, sort: 'oldest',
})

const result = await comments.submit('posts', entryId, {
  author: { name, email, url },
  body,
  parentId: thread.comments[0]?.id,   // omit for a root comment
})
// { success: true, status: 'pending' | 'approved', comment }

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:

FieldEffect
closedRender the comments, hide the form
requireApprovalWhether a new comment appears immediately
requireEmailWhether the email field is mandatory
maxDepthReply nesting cap for new submissions — 0 is flat
maxBodyLengthClient-side length limit
captcha / captchaSiteKeySame Turnstile contract as forms

On a migrated site ​

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.

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:

json
{
  "comments.title": "Yorumlar",
  "comments.post": "Yorum gönder",
  "form.send": "Gönder"
}
  • 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.

When the workspace's subscription is inactive ​

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.

The acceptance test ​

A migration is not finished because the components render. The gate is the round trip:

generated site → submit → provider stores it → moderation → public GET shows it — and a pending item is not visible to the public before approval.

Both halves matter. The second one is the one that is easy to get wrong and expensive to discover later.