Why APIs Should Return UI Components: Turning JSON into ViewModels with BFF
This article explains how treating JSON responses as UI components—by converting data models into view‑models on the server and exposing screen‑specific endpoints through a Backend‑for‑Frontend layer—solves the mismatch between REST resources and ever‑changing front‑end requirements.
JSON as Components
We start with a simple Express route that returns JSON data for a post's likes and a React LikeButton component that expects totalLikeCount, isLikedByUser and friendLikes as props.
app.get('/api/likes/:postId', async (req, res) => {
const postId = req.params.postId;
const [post, friendLikes] = await Promise.all([
getPost(postId),
getFriendLikes(postId, { limit: 2 })
]);
const json = {
totalLikeCount: post.totalLikeCount,
isLikedByUser: post.isLikedByUser,
friendLikes,
};
res.json(json);
}); function LikeButton({ totalLikeCount, isLikedByUser, friendLikes }) {
let buttonText = 'Like';
if (totalLikeCount > 0) {
buttonText = formatLikeText(totalLikeCount, isLikedByUser, friendLikes);
}
return (
<button className={isLikedByUser ? 'liked' : ''}>
{buttonText}
</button>
);
}While this works, the API is a generic "post" resource. Front‑end screens often need a different shape of data, leading to ad‑hoc extensions, extra requests, or duplicated fields.
REST vs. ViewModel
Traditional REST forces us to choose between a resource that mirrors the data model (requiring many calls to assemble a screen) or a resource that embeds view‑specific fields (making the API brittle when the UI changes). The core conflict is that a REST resource is static, while a view‑model is driven by the UI design.
API for ViewModels
Instead of generic resources, we expose endpoints that return the exact view‑model a screen needs, e.g. /screens/post-details/123. This endpoint returns everything the PostDetails screen requires in a single payload, eliminating the need for the client to stitch together multiple calls.
Backend‑for‑Frontend (BFF)
The BFF sits in front of existing REST APIs, transforms their responses into view‑models, and can even call internal data‑layer functions directly. A typical BFF endpoint looks like this:
app.get('/screen/post-details/:postId', async (req, res) => {
const { postId } = req.params;
const [post, friendLikes] = await Promise.all([
fetch(`/api/post/${postId}`).then(r => r.json()),
fetch(`/api/post/${postId}/friend-likes`).then(r => r.json())
]);
const viewModel = {
postTitle: post.title,
postContent: parseMarkdown(post.content),
postAuthor: post.author,
postLikes: {
totalLikeCount: post.totalLikeCount,
isLikedByUser: post.isLikedByUser,
friendLikes: friendLikes.likes.map(l => l.firstName),
},
};
res.json(viewModel);
});The BFF can be written in the same language as the front‑end (e.g., Node.js) to keep the mental model consistent.
Composable BFF
To avoid duplication, we extract reusable view‑model functions. For example, a PostDetailsViewModel function encapsulates the logic for a single post, and a LikeButtonViewModel encapsulates the logic for the like button.
async function PostDetailsViewModel({ postId }) {
const [post, friendLikes] = await Promise.all([
getPost(postId),
getFriendLikes(postId, { limit: 2 })
]);
return {
postTitle: post.title,
postContent: parseMarkdown(post.content),
postAuthor: post.author,
postLikes: await LikeButtonViewModel({ postId }),
};
}
async function LikeButtonViewModel({ postId, includeAvatars = false }) {
const [post, friendLikes] = await Promise.all([
getPost(postId),
getFriendLikes(postId, { limit: includeAvatars ? 5 : 2 })
]);
return {
totalLikeCount: post.totalLikeCount,
isLikedByUser: post.isLikedByUser,
friendLikes: friendLikes.likes.map(l => ({
firstName: l.firstName,
avatar: includeAvatars ? l.avatar : null,
})),
};
}
app.get('/screen/post-details/:postId', async (req, res) => {
const viewModel = await PostDetailsViewModel({ postId: req.params.postId, includeAvatars: true });
res.json(viewModel);
});Now the /screen/post-list endpoint can reuse the same functions, simply mapping over recent post IDs:
app.get('/screen/post-list', async (req, res) => {
const postIds = await getRecentPostIds();
const posts = await Promise.all(
postIds.map(id => PostDetailsViewModel({ postId: id, truncateContent: true, includeAvatars: false }))
);
res.json({ posts });
});ViewModel Parameters
View‑model functions accept parameters that are supplied by their parent view‑model, not by the client. This makes it easy to change the shape of the data for different screens (e.g., truncating content on a list view or toggling avatar inclusion on a detail view) without exposing query‑string flags to the front‑end.
Evolving ViewModels
When UI requirements change—such as adding avatars to the like button—the TypeScript type system immediately surfaces mismatches. Updating the LikeButtonViewModel to return objects with firstName and avatar fixes the error, and all BFF endpoints that rely on this view‑model automatically receive the new shape.
Connecting JSON to UI Components
The final piece is wiring the JSON payload to the React component tree. Because the server already returns the exact props each component expects, the client can simply spread the received objects into the components:
function PostDetails({ postTitle, postContent, postAuthor, postLikes }) {
return (
<article>
<h1>{postTitle}</h1>
<p>{postContent}</p>
<footer>{postAuthor.name}</footer>
<LikeButton {...postLikes} />
</article>
);
}
// In a page component
function PostDetailsPage({ data }) {
return <PostDetails {...data} />;
}Thus the server‑side view‑model tree mirrors the client‑side component tree, eliminating the need for manual data mapping and keeping the UI and API in lockstep.
Conclusion
By treating JSON as components, extracting composable view‑model functions, and exposing screen‑specific BFF endpoints, we resolve the fundamental tension between static REST resources and dynamic UI needs. The approach keeps the API flexible, reduces round‑trips, and aligns server‑side data shaping directly with front‑end component contracts.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
