TryGhost/Ghost · error · ValidationError
Failed to convert HTML to Lexical
Error message
Failed to convert HTML to Lexical
What it means
Same HTML-to-Lexical conversion guard as pages, thrown by the posts input serializer when `source=html` and `htmlToLexicalConverter` throws. The post's HTML is converted to a Lexical JSON string; on failure a ValidationError wraps the underlying error. Used for HTML import into posts.
Source
Thrown at ghost/core/core/server/api/endpoints/utils/serializers/input/posts.js:215
defaultRelations(frame);
}
},
add(apiConfig, frame, options = {add: true}) {
debug('add');
if (_.get(frame,'options.source')) {
const html = frame.data.posts[0].html;
if (frame.options.source === 'html' && !_.isEmpty(html)) {
if (process.env.CI) {
console.time('htmlToLexicalConverter (post)'); // eslint-disable-line no-console
}
try {
frame.data.posts[0].lexical = JSON.stringify(lexical.htmlToLexicalConverter(html));
} catch (err) {
throw new ValidationError({
message: tpl(messages.failedHtmlToLexical),
err
});
}
if (process.env.CI) {
console.timeEnd('htmlToLexicalConverter (post)'); // eslint-disable-line no-console
}
}
}
frame.data.posts[0] = url.forPost(Object.assign({}, frame.data.posts[0]), frame.options);
// @NOTE: force adding post
if (options.add) {
frame.data.posts[0].type = 'post';
}
View on GitHub (pinned to 47d8b0e2ad)
Solutions
- Pre-clean the HTML with a sanitizer before submitting the post.
- Reduce the HTML to a minimal subset to isolate the offending element.
- Examine the nested `err` returned by the API for the precise parse failure.
- Send Lexical JSON directly (omit `source=html`) if you control the editor format.
Example fix
// before
await api.posts.add({posts: [{html: rawHtml}], options: {source: 'html'}});
// after
const lexical = convertHtmlToLexicalClientSide(rawHtml); // or sanitize first
await api.posts.add({posts: [{lexical: JSON.stringify(lexical)}]}) Defensive patterns
Strategy: validation
Validate before calling
function isValidHtmlForConversion(html: string): boolean {
try {
new DOMParser().parseFromString(html, 'text/html');
return true;
} catch { return false; }
} Try / catch
try {
await api.posts.add({posts: [{html}], options: {source: 'html'}});
} catch (err) {
if (/Failed to convert HTML to Lexical/i.test(JSON.stringify((err as any).response?.body))) {
html = sanitizeHtml(html);
await api.posts.add({posts: [{html}], options: {source: 'html'}});
} else { throw err; }
} Prevention
- Pre-clean external HTML with a sanitizer before importing.
- Reduce to a minimal HTML subset to isolate unsupported elements.
- Submit Lexical JSON directly if you do not need HTML import.
When it happens
Trigger: A post add/edit request carries `source=html` with a non-empty `html` field that the Lexical converter rejects. The serializer catches the converter exception and rethrows as a 422 ValidationError.
Common situations: Importing posts from external sources with malformed HTML; embedding unsupported markup (iframes, scripts stripped by the converter); encoding artifacts; converter library regression after an upgrade.
Related errors
- Failed to convert HTML to Lexical
- Email content is required
- Lexical must be a valid JSON string
- Cannot disconnect Stripe whilst you have active subscription
- The NQL filter you passed was invalid.
AI-assisted analysis of TryGhost/Ghost@47d8b0e2ad (2026-08-13).
Data as JSON: /api/errors/dcb192ed1adf7029.
Report an issue: GitHub.