TL;DR
The best way to render React components from Contentful data is to use documentToReactComponents
in conjunction with the Contentful Javascript client, however you have to use it in a very specific way that is not obvious due to poor documentation. Solution at the end.
Imagine you have a Contentful entry. One of its fields is rich text. Somewhere amongst your rich text you have inline embedded entry.
You simply want to render the rich text on a React-based Gatsby site. You can use the documentToReactComponents
function from the @contentful/rich-text-react-renderer
module to do it:
import { documentToReactComponents } from "@contentful/rich-text-react-renderer";
// Contentful client config
const contentful = require("contentful");
const client = contentful.createClient({
space: "abcd12345efgh",
accessToken: "_gdH6uD7325BnjfNSEG95HjQk75G5HnS344F90",
});
export default function CasePage({ data }) {
return (
<Container maxWidth="sm">
<Typography variant="h4" display="inline">
{data.contentfulCase.title}
</Typography>
<Typography variant="h4" display="inline" sx={{ fontWeight: 700 }}>
{data.contentfulCase.yearOfOccurence}
</Typography>
{data.contentfulCase.body?.raw &&
documentToReactComponents(JSON.parse(data.contentfulCase.body.raw))}
</Container>
);
}
That doesn't look right. You would expect it to atleast be a hyperlink. This is caused by the documentToReactComponents()
default node renderer for inline embedded entries: https://github.com/contentful/rich-text/blob/cd42e95489da4fcb9221008c7d0cee815ecf9cc3/packages/rich-text-react-renderer/src/index.tsx#L33
You need to override this default using the options
object, which you pass as the second paramater to documentToReactComponents()
. Here's the code as above, but just more focused on the bits that have changed:
...
export default function CasePage({ data }) {
const options = {
renderNode: {
[INLINES.EMBEDDED_ENTRY]: (node, children) => (
<Typography component={Link} to="/cases">
the title of the embedded entry will go here
</Typography>;
)
}
}
return (
...
{data.contentfulCase.body?.raw &&
documentToReactComponents(JSON.parse(data.contentfulCase.body.raw), options)}
...
)
}
Great. A link is rendering. Now you just need the link to be the title of the embedded entry, instead of that placeholder text. For example, Washington DC
.
Maybe the title is in the node
or children
variables? Use console.log()
to check:
The title isn't there, but that's by design - Contentful responses keep the raw body structure separate from the assets and links etc embedded in the rich text. The raw structure simply references these things by their id.
To get the entry data, the easiest way it to give its id to the getEntry()
method of the Contentful client. This does the 'id-to-entry' resolution for you:
...
export default function CasePage({ data }) {
const options = {
renderNode: {
[INLINES.EMBEDDED_ENTRY]: (node, children) => (
<Typography component={Link} to="/cases">
{client
.getEntry(node.data.target.sys.id)
.then((entry) => console.log(entry))}
</Typography>
)
}
}
return (
...
That's bad. Even though the console.log()
doesn't return anything, it seems React never looks further than getEntry()
and freaks out that a Promise is coming, instead of JSX, a string, or other valid React stuff. The good news is that you can see getEntry()
successfully resolved the entry and the title is there that you need for the link text.
Maybe the Promise issue can be solved by putting the client.getEntry()
code in the function body, then only return JSX with your title injected:
...
export default function CasePage({ data }) {
const options = {
renderNode: {
[INLINES.EMBEDDED_ENTRY]: (node, children) => {
const myentry = client
.getEntry(node.data.target.sys.id)
.then((entry) => entry.fields.title)
return (
<Typography component={Link} to="/cases">
{myentry}
</Typography>
)
}
}
}
return (
...
Same error:
Maybe if you use React state, and call its set method in the callback you can get the entry object out. That way you can use the contents of the state in the JSX:
...
export default function CasePage({ data }) {
const options = {
renderNode: {
[INLINES.EMBEDDED_ENTRY]: (node, children) => {
// Create the state
const [entry, setEntry] = React.useState({});
// Get the entry content and give it straight to setEntry()
client
.getEntry(node.data.target.sys.id)
.then((the_entry) => setEntry(the_entry));
return (
<Typography component={Link} to="/cases">
{entry.fields.title}
</Typography>
);
},
}
}
return (
...
Another error. Even if there's a way to see setEntry()
from inside the callback function, React will not let you call it.
Maybe if you move the state and client.getEntry()
code completely outside the options
object into its own function, you can call said function to get the title into your JSX:
...
export default function CasePage({ data }) {
// Create the state
const [entry, setEntry] = React.useState({ placeholder: "placeholder" });
function fetchData(entryId) {
// Get the entry content and give it straight to setEntry()
client.getEntry(entryId).then((theentry) => {
setEntry(theentry);
console.log(`The type of theentry is ${typeof theentry}`);
console.log(theentry);
console.log(`The type of entry is ${typeof entry}`);
console.log(entry);
});
}
const options = {
renderNode: {
[INLINES.EMBEDDED_ENTRY]: (node, children) => {
// fetchData sets the state to the result of client.getEntry()
fetchData(node.data.target.sys.id);
return (
<Typography component={Link} to="/cases">
{entry.fields.title}
</Typography>
);
},
}
}
return (
...
The setEntry()
call in the new function did not work for a reason unknown to me, so entry
is still what it was initialised with (as seen in the console):
Maybe if we forget about React state and use the async
and await
keywords instead of .then()
.
...
export default function CasePage({ data }) {
async function fetchData(entryId) {
const entrydata = await client.getEntry(entryId);
console.log(`The type of entrydata is ${typeof entrydata}`);
console.log(entrydata);
// Yep, the console shows that entrydata is the object we want. Let's return that.
return entrydata;
}
const options = {
renderNode: {
[INLINES.EMBEDDED_ENTRY]: (node, children) => {
const data = fetchData(node.data.target.sys.id);
console.log(`The type of data is ${typeof data}`);
console.log(data);
return (
<Typography component={Link} to="/cases">
{data.fields.title}
</Typography>
);
},
}
}
return (
...
entry.fields
is undefined because data
is a Promise.
If you came here looking for a solution, you're in luck. After 11 days of talking to people in real life about the issue, digging hard into Google, and messaging people on Discord servers, I had an idea to move the options
object outside the component, and then a conversation with someone on Discord led me to implement state inside the options
object. Here's the full code:
...
// This object is outside the component as seen in the Gatsby examples.
// See it being passed to the documentToReactComponents method in the
// CasePage component below
const options = {
renderNode: {
[INLINES.EMBEDDED_ENTRY]: (node, children) => {
// This block runs once per embedded inline entry found in the raw structure
// Create the state
const [entry, setEntry] = React.useState("");
// Get the entry, then set entry state to be the value of the Promise
// This will only run again if node.data.target.sys.id has a different value
React.useEffect(() => {
console.log("Fetching entry found embedded in the rich text");
client
.getEntry(node.data.target.sys.id)
.then((value) => setEntry(value.fields));
}, [node.data.target.sys.id]);
return (
// Use the entry state to interpolate the components
<Typography component={Link} to={`/cases/${entry.slug}`}>
{entry.title}
</Typography>
);
},
}
}
export default function CasePage({ data }) {
return (
<Container maxWidth="sm">
{documentToReactComponents(
JSON.parse(data.contentfulCase.body.raw),
options
)}
</Container>
);
}
Top comments (0)