Navigation usually needs only a few attributes. Request separate sparse fieldsets for the page and navigation resources:
https://example.com/cms/pages?filter[tag]=root&include=menu&fields[pages]=name,menu&fields[navs]=parent_id,path,name,title,has
When fields[pages] is present, include menu or the requested relationship name in that fieldset.
Resolve resources in relationship order, then group only those resources by parent_id. This avoids mixing ancestors or another included relationship into the menu:
const page = payload.data?.[0];
const included = new Map(
(payload.included ?? []).map(resource => [
`${resource.type}:${resource.id}`,
resource,
]),
);
const menu = (page?.relationships?.menu?.data ?? [])
.map(reference => included.get(`${reference.type}:${reference.id}`))
.filter(Boolean);
const menuIds = new Set(menu.map(resource => resource.id));
const childrenByParent = new Map();
for (const resource of menu) {
const parentId = resource.attributes.parent_id;
const children = childrenByParent.get(parentId) ?? [];
children.push(resource);
childrenByParent.set(parentId, children);
}
const roots = menu.filter(
resource => !menuIds.has(resource.attributes.parent_id),
);
const childrenOf = parentId => childrenByParent.get(parentId) ?? [];
roots contains the top-level entries of the returned menu, while childrenOf(id) returns the next ordered level. The same approach works for subtree after replacing the relationship name.