diff --git a/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Factory.php b/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Factory.php
index 403360853e1c37192b83d8d6aa09ca31ad5f4877..ca21f2b09721e0984e0715ad02aceeebd291bb0b 100644
--- a/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Factory.php
+++ b/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Factory.php
@@ -59,9 +59,12 @@ class Factory
         $filters = isset($hierarchyFilters->HierarchyTree->filterQueries)
           ? $hierarchyFilters->HierarchyTree->filterQueries->toArray()
           : [];
+        $solr = $sm->getServiceLocator()->get('VuFind\Search\BackendManager')
+            ->get('Solr')->getConnector();
+        $formatterManager = $sm->getServiceLocator()
+            ->get('VuFind\HierarchyTreeDataFormatterPluginManager');
         return new Solr(
-            $sm->getServiceLocator()->get('VuFind\Search'),
-            rtrim($cacheDir, '/') . '/hierarchy',
+            $solr, $formatterManager, rtrim($cacheDir, '/') . '/hierarchy',
             $filters
         );
     }
diff --git a/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php b/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php
index bff8f2fc3b3a98d0589ee27b900e885cbd5706e2..80097c1ae3b3f68d2b9be3c269e9df71e5954052 100644
--- a/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php
+++ b/module/VuFind/src/VuFind/Hierarchy/TreeDataSource/Solr.php
@@ -26,8 +26,9 @@
  * @link     http://vufind.org/wiki/vufind2:hierarchy_components Wiki
  */
 namespace VuFind\Hierarchy\TreeDataSource;
+use VuFind\Hierarchy\TreeDataFormatter\PluginManager as FormatterManager;
 use VuFindSearch\Query\Query;
-use VuFindSearch\Service as SearchService;
+use VuFindSearch\Backend\Solr\Connector;
 use VuFindSearch\ParamBag;
 
 /**
@@ -46,9 +47,16 @@ class Solr extends AbstractBase
     /**
      * Search service
      *
-     * @var SearchService
+     * @var Connector
      */
-    protected $searchService;
+    protected $solrConnector;
+
+    /**
+     * Formatter manager
+     *
+     * @var FormatterManager
+     */
+    protected $formatterManager;
 
     /**
      * Cache directory
@@ -67,14 +75,16 @@ class Solr extends AbstractBase
     /**
      * Constructor.
      *
-     * @param SearchService $search   Search service
-     * @param string        $cacheDir Directory to hold cache results (optional)
-     * @param array         $filters  Filters to apply to Solr tree queries
+     * @param Connector        $connector Solr connector
+     * @param FormatterManager $fm        Formatter manager
+     * @param string           $cacheDir  Directory to hold cache results (optional)
+     * @param array            $filters   Filters to apply to Solr tree queries
      */
-    public function __construct(SearchService $search, $cacheDir = null,
-        $filters = []
+    public function __construct(Connector $connector, FormatterManager $fm,
+        $cacheDir = null, $filters = []
     ) {
-        $this->searchService = $search;
+        $this->solrConnector = $connector;
+        $this->formatterManager = $fm;
         if (null !== $cacheDir) {
             $this->cacheDir = rtrim($cacheDir, '/');
         }
@@ -94,102 +104,97 @@ class Solr extends AbstractBase
      */
     public function getXML($id, $options = [])
     {
-        $top = $this->searchService->retrieve('Solr', $id)->getRecords();
-        if (!isset($top[0])) {
-            return '';
-        }
-        $top = $top[0];
-        $cacheFile = (null !== $this->cacheDir)
-            ? $this->cacheDir . '/hierarchyTree_' . urlencode($id) . '.xml'
-            : false;
+        return $this->getFormattedData($id, 'xml', $options, 'hierarchyTree_%s.xml');
+    }
 
-        $useCache = isset($options['refresh']) ? !$options['refresh'] : true;
-        $cacheTime = $this->getHierarchyDriver()->getTreeCacheTime();
+    /**
+     * Search Solr.
+     *
+     * @param string $q    Search query
+     * @param int    $rows Max rows to retrieve (default = int max)
+     *
+     * @return array
+     */
+    protected function searchSolr($q, $rows = 2147483647)
+    {
+        $params = new ParamBag(
+            [
+                'q'  => [$q],
+                'fq' => $this->filters,
+                'hl' => ['false'],
+                'fl' => ['title,id,hierarchy_parent_id,hierarchy_top_id,'
+                    . 'is_hierarchy_id,hierarchy_sequence,title_in_hierarchy'],
+                'wt' => ['json'],
+                'json.nl' => ['arrarr'],
+                'rows' => [$rows], // Integer max
+                'start' => [0]
+            ]
+        );
+        $response = $this->solrConnector->search($params);
+        return json_decode($response);
+    }
 
-        if ($useCache && file_exists($cacheFile)
-            && ($cacheTime < 0 || filemtime($cacheFile) > (time() - $cacheTime))
-        ) {
-            $this->debug("Using cached data from $cacheFile");
-            $xml = file_get_contents($cacheFile);
-        } else {
-            $starttime = microtime(true);
-            $isCollection = $top->isCollection() ? "true" : "false";
-            $xml = '<root><item id="' .
-                htmlspecialchars($id) .
-                '" isCollection="' . $isCollection . '">' .
-                '<content><name>' . htmlspecialchars($top->getTitle()) .
-                '</name></content>';
-            $count = 0;
-            $xml .= $this->getChildren($id, $count);
-            $xml .= '</item></root>';
-            if ($cacheFile) {
-                if (!file_exists($this->cacheDir)) {
-                    mkdir($this->cacheDir);
+    /**
+     * Retrieve a map of children for the provided hierarchy.
+     *
+     * @param string $id Record ID
+     *
+     * @return array
+     */
+    protected function getMapForHierarchy($id)
+    {
+        // Static cache of last map; if the user requests the same map twice
+        // in a row (as when generating XML and JSON in sequence) this will
+        // save a Solr hit.
+        static $map;
+        static $lastId = null;
+        if ($id === $lastId) {
+            return $map;
+        }
+        $lastId = $id;
+
+        $results = $this->searchSolr('hierarchy_top_id:"' . $id . '"');
+        if ($results->response->numFound < 1) {
+            return [];
+        }
+        $map = [$id => []];
+        foreach ($results->response->docs as $current) {
+            $parents = isset($current->hierarchy_parent_id)
+                ? $current->hierarchy_parent_id : [];
+            foreach ($parents as $parentId) {
+                if (!isset($map[$parentId])) {
+                    $map[$parentId] = [$current];
+                } else {
+                    $map[$parentId][] = $current;
                 }
-                file_put_contents($cacheFile, $xml);
             }
-            $this->debug(
-                "Hierarchy of $count records built in " .
-                abs(microtime(true) - $starttime)
-            );
         }
-        return $xml;
+        return $map;
     }
 
     /**
-     * Get Solr Children
+     * Get a record from Solr (return false if not found).
      *
-     * @param string $parentID The starting point for the current recursion
-     * (equivlent to Solr field hierarchy_parent_id)
-     * @param string $count    The total count of items in the tree
-     * before this recursion
+     * @param string $id ID to fetch.
      *
-     * @return string
+     * @return array|bool
      */
-    protected function getChildren($parentID, &$count)
+    protected function getRecord($id)
     {
-        $query = new Query(
-            'hierarchy_parent_id:"' . addcslashes($parentID, '"') . '"'
-        );
-        $results = $this->searchService->search(
-            'Solr', $query, 0, 10000,
-            new ParamBag(['fq' => $this->filters, 'hl' => 'false'])
-        );
-        if ($results->getTotal() < 1) {
-            return '';
-        }
-        $xml = [];
-        $sorting = $this->getHierarchyDriver()->treeSorting();
-
-        foreach ($results->getRecords() as $current) {
-            ++$count;
-
-            $titles = $current->getTitlesInHierarchy();
-            $title = isset($titles[$parentID])
-                ? $titles[$parentID] : $current->getTitle();
-
-            $this->debug("$parentID: " . $current->getUniqueID());
-            $xmlNode = '';
-            $isCollection = $current->isCollection() ? "true" : "false";
-            $xmlNode .= '<item id="' . htmlspecialchars($current->getUniqueID()) .
-                '" isCollection="' . $isCollection . '"><content><name>' .
-                htmlspecialchars($title) . '</name></content>';
-            $xmlNode .= $this->getChildren($current->getUniqueID(), $count);
-            $xmlNode .= '</item>';
-
-            // If we're in sorting mode, we need to create key-value arrays;
-            // otherwise, we can just collect flat strings.
-            if ($sorting) {
-                $positions = $current->getHierarchyPositionsInParents();
-                $sequence = isset($positions[$parentID]) ? $positions[$parentID] : 0;
-                $xml[] = [$sequence, $xmlNode];
-            } else {
-                $xml[] = $xmlNode;
-            }
+        // Static cache of last record; if the user requests the same map twice
+        // in a row (as when generating XML and JSON in sequence) this will
+        // save a Solr hit.
+        static $record;
+        static $lastId = null;
+        if ($id === $lastId) {
+            return $record;
         }
+        $lastId = $id;
 
-        // Assemble the XML, sorting it first if necessary:
-        return implode('', $sorting ? $this->sortNodes($xml) : $xml);
+        $recordResults = $this->searchSolr('id:"' . $id . '"', 1);
+        $record = isset($recordResults->response->docs[0])
+            ? $recordResults->response->docs[0] : false;
+        return $record;
     }
 
     /**
@@ -205,13 +210,26 @@ class Solr extends AbstractBase
      */
     public function getJSON($id, $options = [])
     {
-        $top = $this->searchService->retrieve('Solr', $id)->getRecords();
-        if (!isset($top[0])) {
-            return '';
-        }
-        $top = $top[0];
+        return $this->getFormattedData($id, 'json', $options, 'tree_%s.json');
+    }
+
+    /**
+     * Get formatted data for the specified hierarchy ID.
+     *
+     * @param string $id            Hierarchy ID.
+     * @param string $format        Name of formatter service to use.
+     * @param array  $options       Additional options for JSON generation.
+     * (Currently one option is supported: 'refresh' may be set to true to
+     * bypass caching).
+     * @param string $cacheTemplate Template for cache filenames
+     *
+     * @return string
+     */
+    public function getFormattedData($id, $format, $options = [],
+        $cacheTemplate = 'tree_%s'
+    ) {
         $cacheFile = (null !== $this->cacheDir)
-            ? $this->cacheDir . '/tree_' . urlencode($id) . '.json'
+            ? $this->cacheDir . '/' . sprintf($cacheTemplate, urlencode($id))
             : false;
 
         $useCache = isset($options['refresh']) ? !$options['refresh'] : true;
@@ -225,19 +243,23 @@ class Solr extends AbstractBase
             return $json;
         } else {
             $starttime = microtime(true);
-            $json = [
-                'id' => $id,
-                'type' => $top->isCollection()
-                    ? 'collection'
-                    : 'record',
-                'title' => $top->getTitle()
-            ];
-            $children = $this->getChildrenJson($id, $count);
-            if (!empty($children)) {
-                $json['children'] = $children;
+            $map = $this->getMapForHierarchy($id);
+            if (empty($map)) {
+                return '';
             }
+            // Get top record's info
+            $formatter = $this->formatterManager->get($format);
+            $formatter->setRawData(
+                $this->getRecord($id), $map,
+                $this->getHierarchyDriver()->treeSorting(),
+                $this->getHierarchyDriver()->getCollectionLinkType()
+            );
+            $encoded = $formatter->getData();
+            $count = $formatter->getCount();
+
+            $this->debug('Done: ' . abs(microtime(true) - $starttime));
+
             if ($cacheFile) {
-                $encoded = json_encode($json);
                 // Write file
                 if (!file_exists($this->cacheDir)) {
                     mkdir($this->cacheDir);
@@ -245,101 +267,13 @@ class Solr extends AbstractBase
                 file_put_contents($cacheFile, $encoded);
             }
             $this->debug(
-                "Hierarchy of $count records built in " .
+                "Hierarchy of {$count} records built in " .
                 abs(microtime(true) - $starttime)
             );
             return $encoded;
         }
     }
 
-    /**
-     * Get Solr Children for JSON
-     *
-     * @param string $parentID The starting point for the current recursion
-     * (equivlent to Solr field hierarchy_parent_id)
-     * @param string $count    The total count of items in the tree
-     * before this recursion
-     *
-     * @return string
-     */
-    protected function getChildrenJson($parentID, &$count)
-    {
-        $query = new Query(
-            'hierarchy_parent_id:"' . addcslashes($parentID, '"') . '"'
-        );
-        $results = $this->searchService->search(
-            'Solr', $query, 0, 10000,
-            new ParamBag(['fq' => $this->filters, 'hl' => 'false'])
-        );
-        if ($results->getTotal() < 1) {
-            return '';
-        }
-        $json = [];
-        $sorting = $this->getHierarchyDriver()->treeSorting();
-
-        foreach ($results->getRecords() as $current) {
-            ++$count;
-
-            $titles = $current->getTitlesInHierarchy();
-            $title = isset($titles[$parentID])
-                ? $titles[$parentID] : $current->getTitle();
-
-            $this->debug("$parentID: " . $current->getUniqueID());
-            $childNode = [
-                'id' => $current->getUniqueID(),
-                'type' => $current->isCollection()
-                    ? 'collection'
-                    : 'record',
-                'title' => $title
-            ];
-            if ($current->isCollection()) {
-                $children = $this->getChildrenJson(
-                    $current->getUniqueID(),
-                    $count
-                );
-                if (!empty($children)) {
-                    $childNode['children'] = $children;
-                }
-            }
-
-            // If we're in sorting mode, we need to create key-value arrays;
-            // otherwise, we can just collect flat values.
-            if ($sorting) {
-                $positions = $current->getHierarchyPositionsInParents();
-                $sequence = isset($positions[$parentID]) ? $positions[$parentID] : 0;
-                $json[] = [$sequence, $childNode];
-            } else {
-                $json[] = $childNode;
-            }
-        }
-
-        return $sorting ? $this->sortNodes($json) : $json;
-    }
-
-    /**
-     * Sort Nodes
-     * Convert an unsorted array of [ key, value ] pairs into a sorted array
-     * of values.
-     *
-     * @param array $array The array of arrays to sort
-     *
-     * @return array
-     */
-    protected function sortNodes($array)
-    {
-        // Sort arrays based on first element
-        $sorter = function ($a, $b) {
-            return strcmp($a[0], $b[0]);
-        };
-        usort($array, $sorter);
-
-        // Collapse array to remove sort values
-        $mapper = function ($i) {
-            return $i[1];
-        };
-        return array_map($mapper, $array);
-    }
-
     /**
      * Does this data source support the specified hierarchy ID?
      *
@@ -354,10 +288,7 @@ class Solr extends AbstractBase
         if (!isset($settings['checkAvailability'])
             || $settings['checkAvailability'] == 1
         ) {
-            $results = $this->searchService->retrieve(
-                'Solr', $id, new ParamBag(['fq' => $this->filters])
-            );
-            if ($results->getTotal() < 1) {
+            if (!$this->getRecord($id)) {
                 return false;
             }
         }