Skip to content

Strapi Transform Pipeline

Strapi stores questionnaire data in a structured format optimized for its CMS editing experience — dynamic zones, nested components, and relational fields. The app needs a different, flatter format. The transform pipeline bridges these two worlds.

There are two directions:

  • Forward transform: Strapi format to canonical format (used when syncing from CMS to app)
  • Reverse transform: Canonical format back to Strapi format (used when pushing changes from app to CMS)

Strapi’s editing UI works best with:

  • Dynamic zones — flexible lists of different component types
  • Component nesting — rich structure for complex fields
  • Relational fields — links between content types by ID

But the app and qnr-framework work best with:

  • Flat arrays — simple lists with a type field
  • Plain objects — key-value pairs instead of arrays of {key, value}
  • Resolved references — names and values instead of IDs

The transform pipeline handles this mismatch so that each side gets the format it works best with.

The forward transform happens in Strapi’s questionnaire.js service. When the API returns a questionnaire, this service reshapes the data before sending it.

Strapi format — an array of key-value objects:

[
{ "key": "minAge", "value": "18" },
{ "key": "maxCoverage", "value": "50000" }
]

Canonical format — a flat object:

{
"minAge": "18",
"maxCoverage": "50000"
}

Why: A flat object is simpler to look up (constants.minAge) than searching through an array.

Strapi format — a questionList array plus separate nestedQuestionIds entries:

{
"questionList": ["intro", "dateOfBirth", "address"],
"nestedQuestionIds": [
{ "parentId": "address", "children": ["street", "city", "postalCode"] }
]
}

Canonical format — merged into a single list with nested arrays:

["intro", "dateOfBirth", ["address", "street", "city", "postalCode"]]

Why: The framework needs a single ordered list to determine question sequence, with nesting expressed inline.

Strapi format — array of step definitions:

[
{ "initialQuestionId": "intro", "title": "Personal Info" },
{ "initialQuestionId": "coverage", "title": "Coverage" }
]

Canonical format — two mapping objects:

{
"questionnaireIdMapping": { "intro": 0, "coverage": 1 },
"questionGroupsMapping": { 0: "Personal Info", 1: "Coverage" }
}

Why: The framework needs fast lookups — given a questionId, which progress step are we on?

Strapi format — dynamic zone components with __component field:

[
{
"__component": "question.radio",
"questionId": "plan",
"options": [...]
}
]

Canonical format — flat objects with type field:

[
{
"type": "RADIO",
"questionId": "plan",
"options": [...]
}
]

Why: The __component format is a Strapi internal concept. The app just needs a type string to select the right React component.

Strapi format — rule components with verbose field names:

[
{
"questionId": "dateOfBirth",
"condition": ["dateOfBirth", "lessThan", "18", "year", "dateDiff"],
"outcome": { "goTo": "ageBlocker" }
}
]

Canonical format — simplified:

[
{
"id": "dateOfBirth",
"if": ["dateOfBirth", "lessThan", "18", "year", "dateDiff"],
"then": { "goTo": "ageBlocker" }
}
]

Why: The compact id/if/then/else format is what the rules engine expects and is easier to read at a glance.

The reverse transform (reverseTransform.js service) undoes all the forward transforms. It is used by:

  • Reverse sync — pushing a locally edited .ts file back to Strapi
  • Write-transformed endpoint — API endpoint that accepts canonical format and writes it to Strapi

Each forward transform is inverted:

Forward Reverse
Constants: array to object Object back to array of {key, value}
Questions: __component to type type back to __component format
Rules: id/if/then to questionId/condition/outcome Restore verbose field names
Progress bar: array to mappings Mappings back to array

One of the more complex parts of the reverse transform is relation resolution. In canonical format, relations are often stored as names or slugs:

{ "vertical": "household" }

But Strapi needs the internal database ID:

{ "vertical": 42 }

The reverse transform looks up these IDs by querying Strapi for the matching entity (quote-page by name, vertical by slug, option-list by identifier, etc.).

flowchart LR
    A["<b>Strapi DB</b><br/>Components,<br/>dynamic zones,<br/>relation IDs"] -->|"Forward transform<br/>(questionnaire.js)"| B["<b>Canonical Format</b><br/>Flat types,<br/>plain objects,<br/>resolved names"]
    B -->|"Sync script<br/>+ t() wrapping"| C["<b>Generated .ts</b><br/>TypeScript file<br/>in app repo"]
    C -->|"Reverse sync<br/>+ parse"| B
    B -->|"Reverse transform<br/>(reverseTransform.js)"| A

The pipeline is designed to be lossless — a questionnaire that goes through forward transform and then reverse transform should produce the same result in Strapi. This is important for the reverse sync workflow, where Product teams may edit the generated TypeScript file locally and push changes back to the CMS.

Scenario What happens How to fix
New component type added to Strapi Forward transform may not know how to map __component Update the type mapping in questionnaire.js
Relation not found during reverse sync Reverse transform cannot resolve a name to an ID Ensure the referenced entity exists in Strapi first
Custom fields on a component May be lost in round-trip if not handled by both transforms Add handling in both forward and reverse transform services