How-to library · Developer docs

How-to library

Task-shaped pages: a title that starts with a verb, the situation in a few lines, the steps, the working code in both engines, and why this way rather than another. Found from a search, done, and left.

Submit a Core dna form from a React or Vue front end

The front end posts the visitor's answers to a route on your own server, which sends them to the form's submission API, keeps the submission token in the visitor's session, and answers with done, the errors by field, or a payment to go to. A 207 means stored but not finished: the next attempt continues it.

Context

Context. The site's front end is a React or Vue app on another host, and the contact form, the event registration or the quote request still has to land in Core dna, where editors read submissions and jobs act on them. The submission API takes the answers without credentials, but the site sends no cross-origin headers, so the browser cannot call it directly. A small route on your own server sits between the two.

Before you start

The form built in the admin, its id, and its element names, read once from GET /cdna-api/forms/form/{id} with admin credentials. A server you control with sessions: Express and express-session here, but any framework works the same way.

Steps

  1. Build the front end's fields with exactly the form's element names. If the form has a reCAPTCHA, send the widget's token as g-recaptcha-response.
  2. The front end posts the answers as JSON to your server's route, not to Core dna.
  3. The route keeps only the form's element names from the body and sends them, without an Authorization header, to POST /cdna-api/forms/submission/{form id}. These are visitor routes, and with a credential every submission would be recorded as created by that user.
  4. On 200, clear any stored token and answer "done".
  5. On 207, the answers were stored but some failed the form's rules: keep data.formSubmission.token in the visitor's session and answer with the errors, keyed by element name.
  6. On the visitor's next attempt, send every answer again with PUT /cdna-api/forms/submission/{token}, so the stored submission is completed rather than a second one started.
  7. On 409, the submission can no longer change (it was completed, or an editor published a new version of the form): drop the token and post again.
  8. On 202 with redirectUrl, send the visitor there to pay; the payment completes the submission. On 422, show the errors (a required captcha failed, nothing was stored). Anything else is a fault: log it and show a general message.

Code

JavaScript Your server
// contact.js: the route between the front end and the Core dna form
// (Express 4; app.use(session(...)) from express-session must run before it)
import express from 'express';

const SITE = process.env.COREDNA_URL;                 // https://www.example.com
const FORM_ID = process.env.COREDNA_CONTACT_FORM_ID;  // the form's id
const FIELDS = ['name', 'email', 'message'];          // its element names

export const contact = express.Router();

async function send(method, path, answers) {
  const response = await fetch(SITE + '/cdna-api/forms/' + path, {
    method,
    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },  // no credentials
    body: JSON.stringify(answers),
  });
  return { status: response.status, body: await response.json().catch(() => ({})) };
}

function errorsFrom(body) {                           // validationErrors are keyed by element name
  const errors = {};
  for (const [name, list] of Object.entries(body.validationErrors ?? {})) {
    errors[name] = [].concat(list).map((e) => e.message ?? e).join(' ');
  }
  if (Object.keys(errors).length === 0) errors._form = body.message ?? 'The form could not be sent.';
  return errors;
}

contact.post('/api/contact', express.json({ limit: '100kb' }), async (req, res) => {
  const answers = Object.fromEntries(FIELDS.filter((f) => f in req.body).map((f) => [f, req.body[f]]));
  const token = req.session.contactToken;

  let r = token
    ? await send('PUT', 'submission/' + encodeURIComponent(token), answers)
    : await send('POST', 'submission/' + FORM_ID, answers);
  if (r.status === 409) {                             // completed, or the form changed: start again
    delete req.session.contactToken;
    r = await send('POST', 'submission/' + FORM_ID, answers);
  }

  switch (r.status) {
    case 200:
      delete req.session.contactToken;
      return res.json({ status: 'done' });
    case 207:                                         // stored, not complete: continue it next time
      req.session.contactToken = r.body.data?.formSubmission?.token;
      return res.status(422).json({ status: 'invalid', errors: errorsFrom(r.body) });
    case 422:
      return res.status(422).json({ status: 'invalid', errors: errorsFrom(r.body) });
    case 202:
      delete req.session.contactToken;
      if (r.body.redirectUrl) return res.json({ status: 'pay', redirectUrl: r.body.redirectUrl });
      break;
  }
  console.error('Core dna form', r.status, r.body.message);
  res.status(502).json({ status: 'failed', errors: { _form: 'The form could not be sent. Please try again later.' } });
});
JavaScript React
// ContactForm.jsx
import { useState } from 'react';

export function ContactForm() {
  const [errors, setErrors] = useState({});
  const [state, setState] = useState('editing');     // editing, sending, done

  async function onSubmit(event) {
    event.preventDefault();
    setState('sending');
    const answers = Object.fromEntries(new FormData(event.currentTarget));
    const response = await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(answers),
    });
    const result = await response.json().catch(() => ({ errors: { _form: 'The form could not be sent.' } }));
    if (result.status === 'done') return setState('done');
    if (result.status === 'pay') return window.location.assign(result.redirectUrl);
    setErrors(result.errors ?? {});
    setState('editing');
  }

  if (state === 'done') return <p role="status">Thank you. We reply within two working days.</p>;
  return (
    <form onSubmit={onSubmit} noValidate>
      <label>Name <input name="name" /></label>
      {errors.name && <p className="field-error">{errors.name}</p>}
      <label>Email <input name="email" type="email" /></label>
      {errors.email && <p className="field-error">{errors.email}</p>}
      <label>Message <textarea name="message" /></label>
      {errors.message && <p className="field-error">{errors.message}</p>}
      {errors._form && <p className="form-error" role="alert">{errors._form}</p>}
      <button disabled={state === 'sending'}>Send</button>
    </form>
  );
}
HTML Vue
<!-- ContactForm.vue -->
<script setup>
import { ref } from 'vue';
const errors = ref({});
const state = ref('editing');

async function onSubmit(event) {
  state.value = 'sending';
  const answers = Object.fromEntries(new FormData(event.target));
  const response = await fetch('/api/contact', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(answers),
  });
  const result = await response.json().catch(() => ({ errors: { _form: 'The form could not be sent.' } }));
  if (result.status === 'done') { state.value = 'done'; return; }
  if (result.status === 'pay') { window.location.assign(result.redirectUrl); return; }
  errors.value = result.errors ?? {};
  state.value = 'editing';
}
</script>

<template>
  <p v-if="state === 'done'" role="status">Thank you. We reply within two working days.</p>
  <form v-else @submit.prevent="onSubmit" novalidate>
    <label>Name <input name="name"></label>
    <p v-if="errors.name" class="field-error">{{ errors.name }}</p>
    <label>Email <input name="email" type="email"></label>
    <p v-if="errors.email" class="field-error">{{ errors.email }}</p>
    <label>Message <textarea name="message"></textarea></label>
    <p v-if="errors.message" class="field-error">{{ errors.message }}</p>
    <p v-if="errors._form" class="form-error" role="alert">{{ errors._form }}</p>
    <button :disabled="state === 'sending'">Send</button>
  </form>
</template>

The route's behaviour follows the platform's code for these calls; this documentation site has no test form to capture real answers from. The statuses and what they mean are set out, with the body for each element type, in Submitting a form through the API.

Why this and not that

Not a request from the browser straight to Core dna: the site sends no cross-origin headers, so the browser refuses to send the JSON, and the submission token would have to live in the page, where anyone holding it can change the submission. Not a new POST on every attempt: each one leaves the previous attempt behind as an incomplete submission. Not "any 2xx means thank you": a 207 is stored but incomplete, and nothing that acts on completed submissions will see it. Not the visitor's JSON passed straight through: take only the form's element names from it, so the route cannot be used to fill elements your front end never shows. And not a retry of a POST that timed out: it may have been stored, and a second one is a second submission.

Go deeper

Was this page helpful?

Your answer goes to the docs team.