diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
index 9a0aebbd4c08b9e0e84b5038353ced4ed10c5341..31e00082d2c8fb1913d2a8319b78730c28eb1653 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/client/VOSpaceClient.java
@@ -14,6 +14,7 @@ import java.net.http.HttpResponse.BodyHandlers;
 import java.util.Scanner;
 import java.util.concurrent.CompletionException;
 import java.util.function.Function;
+import javax.xml.bind.JAXB;
 import net.ivoa.xml.vospace.v2.Node;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -25,6 +26,9 @@ public class VOSpaceClient {
 
     private static final Logger LOG = LoggerFactory.getLogger(VOSpaceClient.class);
 
+    @Value("${use-json}")
+    private boolean useJson;
+
     private static final ObjectMapper MAPPER = new ObjectMapper();
 
     private final HttpClient httpClient;
@@ -45,8 +49,8 @@ public class VOSpaceClient {
 
     public Node getNode(String path) {
 
-        HttpRequest request = getRequest("/nodes/" + path)
-                .header("Accept", "application/json")
+        HttpRequest request = getRequest("/nodes" + path)
+                .header("Accept", useJson ? "application/json" : "text/xml")
                 .build();
 
         return call(request, BodyHandlers.ofInputStream(), 200, res -> parseJson(res, Node.class));
@@ -84,9 +88,13 @@ public class VOSpaceClient {
         return HttpRequest.newBuilder(URI.create(baseUrl + path));
     }
 
-    private static <T> T parseJson(InputStream in, Class<T> type) {
+    private <T> T parseJson(InputStream in, Class<T> type) {
         try {
-            return MAPPER.readValue(in, type);
+            if (useJson) {
+                return MAPPER.readValue(in, type);
+            } else {
+                return JAXB.unmarshal(in, type);
+            }
         } catch (IOException ex) {
             LOG.error("Invalid JSON for class {}", type.getCanonicalName());
             throw new UncheckedIOException(ex);
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
index 5b94a115a05770a940fdd7a89085c7998ec81fc7..af16e5202a176e391d2eafda7dabee7d5fe397da 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/controller/NodesController.java
@@ -1,7 +1,7 @@
 package it.inaf.ia2.vospace.ui.controller;
 
 import it.inaf.ia2.vospace.ui.service.NodesService;
-import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletRequest;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -18,16 +18,22 @@ public class NodesController {
      * This is the only API endpoint that returns HTML code instead of JSON. The
      * reason is that JavaScript frameworks are not very efficient in handling
      * very long lists and tables, so this part of the code is generated
-     * server-side.
+     * server-side. The content type is set to text/plain even if it is an HTML
+     * fragment to avoid browser parsing issues since it is not a complete HTML
+     * document.
      */
-    @GetMapping(value = {"/nodes", "/nodes/{path}"}, produces = MediaType.TEXT_HTML_VALUE)
-    public void listNodes(@PathVariable(value = "path", required = false) String path, HttpServletResponse response) throws Exception {
+    @GetMapping(value = {"/nodes", "/nodes/**"}, produces = MediaType.TEXT_PLAIN_VALUE)
+    public String listNodes(HttpServletRequest request) throws Exception {
 
-        if (path == null || path.isBlank()) {
-            path = "/";
+        String requestURL = request.getRequestURL().toString();
+        String[] split = requestURL.split("/nodes/");
+
+        String path = "/";
+        if (split.length == 2) {
+            path += split[1];
         }
 
-        nodesService.generateNodesHtml(path, response.getOutputStream());
+        return nodesService.generateNodesHtml(path);
     }
 
     @GetMapping(value = "/download/{path}")
diff --git a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java
index 524ea4fc2f25e8620bee7b47f54667243b158fbd..2edbb77063bbe38af44bdf6a4b6d5006af615d79 100644
--- a/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java
+++ b/vospace-ui-backend/src/main/java/it/inaf/ia2/vospace/ui/service/NodesService.java
@@ -1,8 +1,9 @@
 package it.inaf.ia2.vospace.ui.service;
 
 import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
-import java.io.OutputStream;
-import java.io.PrintWriter;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.UncheckedIOException;
 import net.ivoa.xml.vospace.v2.ContainerNode;
 import net.ivoa.xml.vospace.v2.Node;
 import org.slf4j.Logger;
@@ -22,20 +23,24 @@ public class NodesService {
     @Value("${vospace-authority}")
     private String authority;
 
-    public void generateNodesHtml(String path, OutputStream out) {
+    public String generateNodesHtml(String path) {
 
         Node node = client.getNode(path);
 
-        try ( PrintWriter pw = new PrintWriter(out)) {
+        try ( StringWriter sw = new StringWriter()) {
 
             if (node instanceof ContainerNode) {
                 ContainerNode folder = (ContainerNode) node;
-                pw.println("<tbody id=\"nodes\">");
+                sw.write("<tbody id=\"nodes\">");
                 for (Node child : folder.getNodes().getNode()) {
-                    pw.println(getNodeHtml(child));
+                    sw.write(getNodeHtml(child));
                 }
-                pw.println("</tbody>");
+                sw.write("</tbody>");
             }
+
+            return sw.toString();
+        } catch (IOException ex) {
+            throw new UncheckedIOException(ex);
         }
     }
 
@@ -67,7 +72,7 @@ public class NodesService {
     private String getLink(NodeInfo nodeInfo) {
         if (isDownloadable(nodeInfo)) {
             if ("vos:ContainerNode".equals(nodeInfo.getType())) {
-                return "<a href=\"#nodes" + nodeInfo.getPath() + "\">" + nodeInfo.getName() + "</a>";
+                return "<a href=\"#/nodes" + nodeInfo.getPath() + "\">" + nodeInfo.getName() + "</a>";
             } else {
                 return "<a href=\"download" + nodeInfo.getPath() + "\" target=\"blank_\">" + nodeInfo.getName() + "</a>";
             }
diff --git a/vospace-ui-backend/src/main/resources/application.properties b/vospace-ui-backend/src/main/resources/application.properties
index 934e28401708dbfcd3b590f1385ba466ca827601..1b0cb7d4cba4d692e30b91ff7dc704fc1f00894f 100644
--- a/vospace-ui-backend/src/main/resources/application.properties
+++ b/vospace-ui-backend/src/main/resources/application.properties
@@ -2,6 +2,7 @@ server.port=8085
 
 vospace-backend-url=http://localhost:8083/vospace
 vospace-authority=example.com!vospace
+use-json=true
 
 # For development only:
 spring.profiles.active=dev
diff --git a/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..dc127f7c5e6645fd9b0ca5be55cbf8161d17698c
--- /dev/null
+++ b/vospace-ui-backend/src/test/java/it/inaf/ia2/vospace/ui/controller/NodesControllerTest.java
@@ -0,0 +1,59 @@
+package it.inaf.ia2.vospace.ui.controller;
+
+import it.inaf.ia2.vospace.ui.service.NodesService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import static org.mockito.ArgumentMatchers.eq;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import static org.mockito.Mockito.verify;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.web.servlet.MockMvc;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+@ExtendWith(MockitoExtension.class)
+public class NodesControllerTest {
+
+    @Mock
+    private NodesService nodesService;
+
+    @InjectMocks
+    private NodesController controller;
+
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    public void init() {
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    @Test
+    public void testListNodesEmpty() throws Exception {
+
+        mockMvc.perform(get("/nodes"))
+                .andExpect(status().isOk());
+
+        verify(nodesService).generateNodesHtml(eq("/"));
+    }
+
+    @Test
+    public void testListNodesRoot() throws Exception {
+
+        mockMvc.perform(get("/nodes/"))
+                .andExpect(status().isOk());
+
+        verify(nodesService).generateNodesHtml(eq("/"));
+    }
+
+    @Test
+    public void testListNodesComplexPath() throws Exception {
+
+        mockMvc.perform(get("/nodes/a/b/c"))
+                .andExpect(status().isOk());
+
+        verify(nodesService).generateNodesHtml(eq("/a/b/c"));
+    }
+}
diff --git a/vospace-ui-frontend/src/App.vue b/vospace-ui-frontend/src/App.vue
index 938c672de1302520b952ab38f2680ae70823b3fb..b9a76bea0c22ec31f09d35f0f43135b2eea6c5b3 100644
--- a/vospace-ui-frontend/src/App.vue
+++ b/vospace-ui-frontend/src/App.vue
@@ -1,20 +1,20 @@
 <template>
-  <div id="app">
-    <TopMenu />
-    <div class="container">
-      <router-view></router-view>
-    </div>
-    <div id="footer-fix"></div>
-    <footer class="text-center" id="site-footer">
-        —&nbsp;Powered by <img alt="IA2 logo" src="./assets/ia2-logo-footer.png">
-        <strong class="text-primary"><a href="http://www.ia2.inaf.it/" target="blank_">IA2</a></strong>&nbsp;—
-    </footer>
-    <div id="loading" v-if="loading">
-      <div class="spinner-wrapper">
-        <b-spinner variant="primary" style="width: 3rem; height: 3rem;" label="Loading"></b-spinner>
-      </div>
+<div id="app">
+  <TopMenu />
+  <div class="container">
+    <router-view></router-view>
+  </div>
+  <div id="footer-fix"></div>
+  <footer class="text-center" id="site-footer">
+    —&nbsp;Powered by <img alt="IA2 logo" src="./assets/ia2-logo-footer.png">
+    <strong class="text-primary"><a href="http://www.ia2.inaf.it/" target="blank_">IA2</a></strong>&nbsp;—
+  </footer>
+  <div id="loading" v-if="loading">
+    <div class="spinner-wrapper">
+      <b-spinner variant="primary" style="width: 3rem; height: 3rem;" label="Loading"></b-spinner>
     </div>
   </div>
+</div>
 </template>
 
 <script>
@@ -55,20 +55,20 @@ export default {
 }
 
 #footer-fix {
-    display: block;
-    width: 100%;
-    height: 100px;
+  display: block;
+  width: 100%;
+  height: 100px;
 }
 
 #site-footer {
-    color: #666;
-    border-top: 1px #e7e7e7 solid;
-    background-color: #f8f8f8;
-    padding: 5px 0;
-    position: fixed;
-    bottom: 0;
-    right: 0;
-    left: 0;
+  color: #666;
+  border-top: 1px #e7e7e7 solid;
+  background-color: #f8f8f8;
+  padding: 5px 0;
+  position: fixed;
+  bottom: 0;
+  right: 0;
+  left: 0;
 }
 
 #loading {
diff --git a/vospace-ui-frontend/src/api/server/index.js b/vospace-ui-frontend/src/api/server/index.js
index 31c8eb7d8192fed385abf5bf863531b24dfbfa0a..a86c032ddd5f00dc7bc2d10b2afb7d44647df831 100644
--- a/vospace-ui-frontend/src/api/server/index.js
+++ b/vospace-ui-frontend/src/api/server/index.js
@@ -53,7 +53,7 @@ function loading(value) {
 
 export default {
   getNode(path) {
-    let url = BASE_API_URL + 'nodes' + path;
+    let url = BASE_API_URL + 'nodes/' + path;
     return apiRequest({
       method: 'GET',
       url: url,
diff --git a/vospace-ui-frontend/src/components/Main.vue b/vospace-ui-frontend/src/components/Main.vue
index 28ac7988173ebcd63aaac9e2c2e7e15caf0924c8..5263ff410915dd1e46b859fd7d3859e9612c8e38 100644
--- a/vospace-ui-frontend/src/components/Main.vue
+++ b/vospace-ui-frontend/src/components/Main.vue
@@ -1,13 +1,18 @@
 <template>
 <div class="container">
+  <b-breadcrumb :items="breadcrumbs"></b-breadcrumb>
   <b-card>
     <table class="table b-table table-striped table-hover">
       <thead>
         <tr>
           <th id="checkboxes">
-            <a href="#" @click.stop.prevent="selectAll"><BIconCheckSquare /></a>
+            <a href="#" @click.stop.prevent="selectAll">
+              <BIconCheckSquare />
+            </a>
             &nbsp;
-            <a href="#" @click.stop.prevent="deSelectAll"><BIconSquare /></a>
+            <a href="#" @click.stop.prevent="deSelectAll">
+              <BIconSquare />
+            </a>
           </th>
           <th>Name</th>
           <th>Size</th>
@@ -22,25 +27,46 @@
 </template>
 
 <script>
-import client from 'api-client';
 import { BIconCheckSquare, BIconSquare } from 'bootstrap-vue'
 
 export default {
   components: {
-    BIconCheckSquare, BIconSquare
+    BIconCheckSquare,
+    BIconSquare
   },
-  mounted() {
-    client.getNode(this.$store.state.path)
-      .then(res => {
-        document.getElementById('nodes').outerHTML = res;
-      });
+  computed: {
+    breadcrumbs() {
+      let items = [{
+        text: 'ROOT',
+        href: '#/nodes/'
+      }];
+      if (this.$store.state.path) {
+        let pathSplit = this.$store.state.path.split('/');
+        for (let i = 0; i < pathSplit.length; i++) {
+          items.push({
+            text: pathSplit[i],
+            href: '#/nodes/' + pathSplit.slice(0, i + 1).join('/')
+          });
+        }
+      }
+      return items;
+    }
+  },
+  created() {
+    this.loadNode();
+  },
+  watch: {
+    '$route.params.path': 'loadNode'
   },
   methods: {
+    loadNode() {
+      this.$store.dispatch('setPath', this.$route.params.path);
+    },
     selectAll() {
       this.selectInputs(true);
     },
     deSelectAll() {
-        this.selectInputs(false);
+      this.selectInputs(false);
     },
     selectInputs(value) {
       document.querySelectorAll('#nodes input').forEach(input => input.checked = value);
@@ -56,9 +82,13 @@ export default {
   text-align: left;
 }
 
+#nodes>tr>td:nth-child(2) {
+  white-space: nowrap;
+}
+
 th#checkboxes {
   /* Credits: https://stackoverflow.com/a/43615091/771431 */
-  width:0.1%;
+  width: 0.1%;
   white-space: nowrap;
 }
 </style>
diff --git a/vospace-ui-frontend/src/router.js b/vospace-ui-frontend/src/router.js
index 355f50d65dfed4d0f05e07e549d78f8d6ad9b8e6..2bb82a7ef9c3788e0128010afd775fa2746be2ce 100644
--- a/vospace-ui-frontend/src/router.js
+++ b/vospace-ui-frontend/src/router.js
@@ -5,6 +5,10 @@ import Main from './components/Main.vue';
 export default new VueRouter({
   routes: [{
     path: '/',
+    redirect: '/nodes/'
+  }, {
+    name: 'nodes',
+    path: '/nodes/:path(.*)',
     component: Main
   }]
 })
diff --git a/vospace-ui-frontend/src/store.js b/vospace-ui-frontend/src/store.js
index 464672b52388d1583acf79eb5fc412e832355318..6e4ed2cea1121cd173cfc150622c88d0f9a4e405 100644
--- a/vospace-ui-frontend/src/store.js
+++ b/vospace-ui-frontend/src/store.js
@@ -2,18 +2,35 @@
 
 import Vue from 'vue';
 import Vuex from 'vuex';
+import client from 'api-client';
 
 Vue.use(Vuex);
 
 export default new Vuex.Store({
   state: {
-    path: '/',
+    //breadcrumbs: [],
+    path: '',
     loading: true
   },
   mutations: {
     setLoading(state, loading) {
       state.loading = loading;
+    },
+    setPath(state, value) {
+      if (!value) {
+        value = '';
+      }
+      state.path = value;
+      //state.breadcrumbs = value.split('/')
     }
   },
-  actions: {}
+  actions: {
+    setPath({ state, commit }, path) {
+      commit('setPath', path);
+      client.getNode(state.path)
+        .then(res => {
+          document.getElementById('nodes').outerHTML = res
+        });
+    }
+  }
 });