diff --git a/gui/src/components/About.js b/gui/src/components/About.js
index 4f1179d7345bc7914f4e8687cfd878aaabff45dc..673b50725728cd04621665339ec165ea8f84f8f6 100644
--- a/gui/src/components/About.js
+++ b/gui/src/components/About.js
@@ -15,18 +15,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React, { useContext, useLayoutEffect, useRef, useCallback, useEffect, useState } from 'react'
+import React, { useLayoutEffect, useRef, useCallback, useEffect, useState } from 'react'
 import {ReactComponent as AboutSvg} from './about.svg'
 import PropTypes from 'prop-types'
 import Markdown from './Markdown'
 import { appBase, debug, consent, aitoolkitEnabled, encyclopediaEnabled } from '../config'
-import { apiContext } from './api'
 import packageJson from '../../package.json'
 import { domainData } from './domainData'
 import { Grid, Card, CardContent, Typography, makeStyles, Link, Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@material-ui/core'
 import { Link as RouterLink, useHistory } from 'react-router-dom'
 import tutorials from '../toolkitMetadata'
 import parserMetadata from '../parserMetadata'
+import { useApi } from './api'
 
 function CodeInfo({code, ...props}) {
   if (!code) {
@@ -166,7 +166,7 @@ const useStyles = makeStyles(theme => ({
 
 export default function About() {
   const classes = useStyles()
-  const {info} = useContext(apiContext)
+  const {info} = useApi()
   const svg = useRef()
   const history = useHistory()
 
diff --git a/gui/src/components/App.js b/gui/src/components/App.js
index 844222d0aa258caae7857db1c17bd67748300649..2be96ee1e45ca2524ef4de14f598446cdf3f0814 100644
--- a/gui/src/components/App.js
+++ b/gui/src/components/App.js
@@ -28,7 +28,6 @@ import { nomadTheme, matomoEnabled, matomoUrl, matomoSiteId, keycloakBase, keycl
 import Keycloak from 'keycloak-js'
 import { KeycloakProvider } from 'react-keycloak'
 import { MuiThemeProvider } from '@material-ui/core/styles'
-import { ApiProvider } from './api'
 import { ErrorSnacks, ErrorBoundary } from './errors'
 import Navigation from './nav/Navigation'
 
@@ -57,9 +56,7 @@ export default function App() {
               <ErrorSnacks>
                 <ErrorBoundary>
                   <RecoilRoot>
-                    <ApiProvider>
-                      <Navigation />
-                    </ApiProvider>
+                    <Navigation />
                   </RecoilRoot>
                 </ErrorBoundary>
               </ErrorSnacks>
diff --git a/gui/src/components/DatasetPage.js b/gui/src/components/DatasetPage.js
index 143a63a1e420fc7eccdbf5f5047b0492f0de517f..fa9ed851d2ae3b793499dc6ca6c457e9d27a8380 100644
--- a/gui/src/components/DatasetPage.js
+++ b/gui/src/components/DatasetPage.js
@@ -19,7 +19,7 @@ import React, { useContext, useState, useEffect, useMemo } from 'react'
 import PropTypes from 'prop-types'
 import { Typography, makeStyles } from '@material-ui/core'
 import { errorContext } from './errors'
-import { useApi } from './apiV1'
+import { useApi } from './api'
 import Search from './search/Search'
 import { SearchContext } from './search/SearchContext'
 import { DOI } from './search/results/DatasetList'
diff --git a/gui/src/components/DownloadButton.js b/gui/src/components/DownloadButton.js
index 867cbd84eca666727d94a4c8a3e5bf0e91ba5897..635e800519d03bf283fe6b3cd7059c63ede6e706 100644
--- a/gui/src/components/DownloadButton.js
+++ b/gui/src/components/DownloadButton.js
@@ -15,111 +15,103 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React from 'react'
+import React, { useState } from 'react'
 import PropTypes from 'prop-types'
 import FileSaver from 'file-saver'
-import { withApi } from './api'
-import { compose } from 'recompose'
-import { withErrors } from './errors'
+import { useErrors } from './errors'
 import { apiBase } from '../config'
 import { Tooltip, IconButton, Menu, MenuItem } from '@material-ui/core'
 import DownloadIcon from '@material-ui/icons/CloudDownload'
+import { useApi } from './api'
 
-class DownloadButton extends React.Component {
-  static propTypes = {
-    /**
-     * The query that defines what to download.
-     */
-    query: PropTypes.object.isRequired,
-    /**
-     * A tooltip for the button
-     */
-    tooltip: PropTypes.string,
-    /**
-     * Whether the button is disabled
-     */
-    disabled: PropTypes.bool,
-    /**
-     * Properties forwarded to the button.
-     */
-    buttonProps: PropTypes.object,
-    api: PropTypes.object.isRequired,
-    user: PropTypes.object,
-    raiseError: PropTypes.func.isRequired,
-    dark: PropTypes.bool
-  }
+const DownloadButton = React.memo(function DownloadButton(props) {
+  const {tooltip, disabled, buttonProps, dark, query} = props
+  const {api, user} = useApi()
+  const {raiseError} = useErrors()
 
-  state = {
-    preparingDownload: false,
-    anchorEl: null
-  }
+  const [preparingDownload, setPreparingDownload] = useState(false)
+  const [anchorEl, setAnchorEl] = useState(null)
 
-  handleClick(event) {
+  const handleClick = event => {
     event.stopPropagation()
-    this.setState({ anchorEl: event.currentTarget })
+    setAnchorEl(event.currentTarget)
   }
 
-  async handleSelect(choice) {
-    const {api, query, user, raiseError} = this.props
-
-    const params = {}
-    Object.keys(query).forEach(key => { params[key] = query[key] })
-
-    if (user) {
-      try {
-        const token = await api.getSignatureToken()
-        params['signature_token'] = token
-      } catch (e) {
-        this.setState({preparingDownload: false})
-        raiseError(e)
-      }
-    }
+  const handleSelect = (choice) => {
+    setAnchorEl(null)
 
-    // TODO using split/append is not consistent for all parameters, using append here
     const urlSearchParams = new URLSearchParams()
-    Object.keys(params).forEach(key => {
-      const value = params[key]
+    Object.keys(query).forEach(key => {
+      const value = query[key]
       if (Array.isArray(value)) {
         value.forEach(item => urlSearchParams.append(key, item))
       } else {
         urlSearchParams.append(key, value)
       }
     })
-    const url = `${apiBase}/${choice}/${choice === 'archive' ? 'download' : 'query'}?${urlSearchParams.toString()}`
-    FileSaver.saveAs(url, `nomad-${choice}-download.zip`)
-    this.setState({preparingDownload: false, anchorEl: null})
-  }
-
-  handleClose() {
-    this.setState({anchorEl: null})
-  }
 
-  render() {
-    const {tooltip, disabled, buttonProps, dark} = this.props
-    const {preparingDownload, anchorEl} = this.state
+    const openDownload = () => {
+      const url = `${apiBase}/${choice}/${choice === 'archive' ? 'download' : 'query'}?${urlSearchParams.toString()}`
+      FileSaver.saveAs(url, `nomad-${choice}-download.zip`)
+    }
 
-    const props = {
-      ...buttonProps,
-      disabled: disabled || preparingDownload,
-      onClick: this.handleClick.bind(this)
+    if (user) {
+      setPreparingDownload(true)
+      api.get('/auth/signature_token')
+        .then(response => {
+          urlSearchParams.append('signature_token', response.signature_token)
+          openDownload()
+        })
+        .catch(raiseError)
+        .finally(setPreparingDownload(false))
+    } else {
+      openDownload()
     }
+  }
 
-    return <React.Fragment>
-      <IconButton {...props} style={dark ? {color: 'white'} : null}>
-        <Tooltip title={tooltip || 'Download'}>
-          <DownloadIcon />
-        </Tooltip>
-      </IconButton>
-      <Menu
-        anchorEl={anchorEl}
-        open={Boolean(anchorEl)}
-        onClose={this.handleClose.bind(this)}
-      >
-        <MenuItem onClick={() => this.handleSelect('raw')}>Raw uploaded files</MenuItem>
-        <MenuItem onClick={() => this.handleSelect('archive')}>NOMAD Archive files</MenuItem>
-      </Menu>
-    </React.Fragment>
+  const handleClose = () => {
+    setAnchorEl(null)
   }
+
+  return <React.Fragment>
+    <IconButton
+      {...buttonProps}
+      disabled={disabled || preparingDownload}
+      onClick={handleClick}
+      style={dark ? {color: 'white'} : null}
+    >
+      <Tooltip title={tooltip || 'Download'}>
+        <DownloadIcon />
+      </Tooltip>
+    </IconButton>
+    <Menu
+      anchorEl={anchorEl}
+      open={Boolean(anchorEl)}
+      onClose={handleClose}
+    >
+      <MenuItem onClick={() => handleSelect('raw')}>Raw uploaded files</MenuItem>
+      <MenuItem onClick={() => handleSelect('archive')}>NOMAD Archive files</MenuItem>
+    </Menu>
+  </React.Fragment>
+})
+DownloadButton.propTypes = {
+  /**
+   * The query that defines what to download.
+   */
+  query: PropTypes.object.isRequired,
+  /**
+   * A tooltip for the button
+   */
+  tooltip: PropTypes.string,
+  /**
+   * Whether the button is disabled
+   */
+  disabled: PropTypes.bool,
+  /**
+   * Properties forwarded to the button.
+   */
+  buttonProps: PropTypes.object,
+  dark: PropTypes.bool
 }
 
-export default compose(withApi(false), withErrors)(DownloadButton)
+export default DownloadButton
diff --git a/gui/src/components/EditUserMetadataDialog.js b/gui/src/components/EditUserMetadataDialog.js
index 7c3fa95d5f0f2f57042b726e4e47ae57b36b791a..bf07d8394f8eb226a902406b7b8388cc4a49677a 100644
--- a/gui/src/components/EditUserMetadataDialog.js
+++ b/gui/src/components/EditUserMetadataDialog.js
@@ -25,14 +25,13 @@ import DialogContentText from '@material-ui/core/DialogContentText'
 import DialogTitle from '@material-ui/core/DialogTitle'
 import PropTypes from 'prop-types'
 import { IconButton, Tooltip, withStyles, Paper, MenuItem, Popper, CircularProgress,
-  FormGroup, Checkbox, FormLabel } from '@material-ui/core'
+  FormGroup, Checkbox } from '@material-ui/core'
 import EditIcon from '@material-ui/icons/Edit'
 import AddIcon from '@material-ui/icons/Add'
 import RemoveIcon from '@material-ui/icons/Delete'
 import Autosuggest from 'react-autosuggest'
 import match from 'autosuggest-highlight/match'
 import parse from 'autosuggest-highlight/parse'
-import { compose } from 'recompose'
 import { withApi } from './api'
 
 const local_users = {}
@@ -269,9 +268,9 @@ class UserInputUnstyled extends React.Component {
     const {api} = this.props
     query = query.toLowerCase()
     return api.getUsers(query)
-      .then(result => {
-        result.users.forEach(user => update_local_user(user))
-        const withQueryInName = result.users.filter(
+      .then(users => {
+        users.forEach(user => update_local_user(user))
+        const withQueryInName = users.filter(
           user => user.name.toLowerCase().indexOf(query) !== -1)
         withQueryInName.sort((a, b) => {
           const aValue = a.name.toLowerCase()
@@ -322,7 +321,7 @@ class UserInputUnstyled extends React.Component {
   }
 }
 
-const UserInput = withApi(false)(UserInputUnstyled)
+const UserInput = withApi(UserInputUnstyled)
 
 class DatasetInputUnstyled extends React.Component {
   static propTypes = {
@@ -373,7 +372,7 @@ class DatasetInputUnstyled extends React.Component {
   }
 }
 
-const DatasetInput = withApi(false)(DatasetInputUnstyled)
+const DatasetInput = withApi(DatasetInputUnstyled)
 
 class ReferenceInput extends React.Component {
   static propTypes = {
@@ -479,7 +478,7 @@ class ListTextInputUnstyled extends React.Component {
     values: PropTypes.arrayOf(PropTypes.object).isRequired,
     label: PropTypes.string,
     onChange: PropTypes.func,
-    component: PropTypes.func
+    component: PropTypes.any
   }
 
   static styles = theme => ({
@@ -611,15 +610,10 @@ class InviteUserDialogUnstyled extends React.Component {
     this.props.api.inviteUser(this.state.data).then(() => {
       this.handleClose()
     }).catch(error => {
-      // get message in quotes
-      console.error(error)
-      try {
-        let message = ('' + error).match(/'([^']+)'/)[1]
-        try {
-          message = JSON.parse(message).errorMessage
-        } catch (e) {}
-        this.setState({error: message, submitting: false, submitEnabled: false})
-      } catch (e) {
+      const detail = error?.response?.data?.detail
+      if (detail) {
+        this.setState({error: detail, submitting: false, submitEnabled: false})
+      } else {
         this.setState({error: '' + error, submitting: false, submitEnabled: false})
       }
     })
@@ -692,7 +686,7 @@ class InviteUserDialogUnstyled extends React.Component {
   }
 }
 
-const InviteUserDialog = compose(withApi(true, false), withStyles(InviteUserDialogUnstyled.styles))(InviteUserDialogUnstyled)
+const InviteUserDialog = withApi(withStyles(InviteUserDialogUnstyled.styles)(InviteUserDialogUnstyled))
 
 class UserMetadataFieldUnstyled extends React.PureComponent {
   static propTypes = {
@@ -749,7 +743,6 @@ class EditUserMetadataDialogUnstyled extends React.Component {
     disabled: PropTypes.bool,
     title: PropTypes.string,
     info: PropTypes.object,
-    withoutLiftEmbargo: PropTypes.bool,
     text: PropTypes.string
   }
 
@@ -770,9 +763,6 @@ class EditUserMetadataDialogUnstyled extends React.Component {
       left: '50%',
       marginTop: -12,
       marginLeft: -12
-    },
-    liftEmbargoLabel: {
-      marginTop: theme.spacing(3)
     }
   })
 
@@ -788,10 +778,7 @@ class EditUserMetadataDialogUnstyled extends React.Component {
       references: [],
       coauthors: [],
       shared_with: [],
-      datasets: [],
-      with_embargo: {
-        value: 'lift'
-      }
+      datasets: []
     }
     this.unmounted = false
   }
@@ -1019,7 +1006,7 @@ class EditUserMetadataDialogUnstyled extends React.Component {
     return (
       <React.Fragment>
         {!this.props.text &&
-          <IconButton {...buttonProps}>
+          <IconButton {...allButtonProps}>
             <Tooltip {...tooltipProps}>
               <EditIcon />
             </Tooltip>
@@ -1085,9 +1072,6 @@ class EditUserMetadataDialogUnstyled extends React.Component {
                   label="Datasets"
                 />
               </UserMetadataField>
-              {!this.props.withoutLiftEmbargo && <UserMetadataField classes={{container: classes.liftEmbargoLabel}} {...metadataFieldProps('with_embargo', true, 'lift')}>
-                <FormLabel>Lift embargo</FormLabel>
-              </UserMetadataField>}
             </DialogContent>
             {this.renderDialogActions(submitting, submitEnabled)}
           </Dialog>
@@ -1126,4 +1110,4 @@ class EditUserMetadataDialogUnstyled extends React.Component {
   }
 }
 
-export default compose(withApi(false, false), withStyles(EditUserMetadataDialogUnstyled.styles))(EditUserMetadataDialogUnstyled)
+export default withApi(withStyles(EditUserMetadataDialogUnstyled.styles)(EditUserMetadataDialogUnstyled))
diff --git a/gui/src/components/LoginLogout.js b/gui/src/components/LoginLogout.js
index 5eacd431bb39ccc08b44d2c73848e885162f6a5f..af30e5023c117fcd87a300477af3c0d84f9cbb11 100644
--- a/gui/src/components/LoginLogout.js
+++ b/gui/src/components/LoginLogout.js
@@ -17,70 +17,66 @@
  */
 import React from 'react'
 import PropTypes from 'prop-types'
-import { withStyles } from '@material-ui/core/styles'
 import Typography from '@material-ui/core/Typography'
-import { compose } from 'recompose'
-import { Button, Link } from '@material-ui/core'
-import { withApi } from './api'
+import { Button, Link, makeStyles } from '@material-ui/core'
 import { keycloakBase, keycloakRealm } from '../config'
 import LoginIcon from '@material-ui/icons/AccountCircle'
+import { useApi } from './api'
+import { useKeycloak } from 'react-keycloak'
 
-class LoginLogout extends React.Component {
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    variant: PropTypes.string,
-    color: PropTypes.string,
-    user: PropTypes.object,
-    keycloak: PropTypes.object.isRequired
-  }
-
-  static styles = theme => ({
-    root: {
-      display: 'flex',
-      alignItems: 'center',
-      '& p': {
-        marginRight: theme.spacing(2)
-      }
-    },
-    link: {
-      textDecoration: 'underline'
-    },
-    button: {} // to allow overrides
-  })
+const useStyles = makeStyles(theme => ({
+  root: {
+    display: 'flex',
+    alignItems: 'center',
+    '& p': {
+      marginRight: theme.spacing(2)
+    }
+  },
+  link: {
+    textDecoration: 'underline'
+  },
+  button: {} // to allow overrides
+}))
 
-  render() {
-    const { classes, variant, color, keycloak, user } = this.props
+const LoginLogout = React.memo(function LoginLogout(props) {
+  const classes = useStyles()
+  const {user} = useApi()
+  const {keycloak} = useKeycloak()
+  const {variant, color} = props
 
-    if (keycloak.authenticated) {
-      return (
-        <div className={classes.root}>
-          <Typography color="primary" variant="body1">
-            Welcome <Link
-              className={classes.link}
-              href={`${keycloakBase.replace(/\/$/, '')}/realms/${keycloakRealm}/account/`}>
-              { user ? user.name : '...' }
-            </Link>
-          </Typography>
-          <Button
-            className={classes.button} style={{marginLeft: 8}}
-            variant={variant} color={color}
-            onClick={() => keycloak.logout()}
-            startIcon={<LoginIcon/>}
-          >Logout</Button>
-        </div>
-      )
-    } else {
-      return (
-        <div className={classes.root}>
-          <Button
-            className={classes.button} variant={variant} color={color}
-            startIcon={<LoginIcon/>}
-            onClick={() => keycloak.login()}
-          >Login / Register</Button>
-        </div>
-      )
-    }
+  if (keycloak.authenticated) {
+    return (
+      <div className={classes.root}>
+        <Typography color="primary" variant="body1">
+          Welcome <Link
+            className={classes.link}
+            href={`${keycloakBase.replace(/\/$/, '')}/realms/${keycloakRealm}/account/`}>
+            { user ? user.name : '...' }
+          </Link>
+        </Typography>
+        <Button
+          className={classes.button} style={{marginLeft: 8}}
+          variant={variant} color={color}
+          onClick={() => keycloak.logout()}
+          startIcon={<LoginIcon/>}
+        >Logout</Button>
+      </div>
+    )
+  } else {
+    return (
+      <div className={classes.root}>
+        <Button
+          className={classes.button} variant={variant} color={color}
+          startIcon={<LoginIcon/>}
+          onClick={() => keycloak.login()}
+        >Login / Register</Button>
+      </div>
+    )
   }
+})
+LoginLogout.propTypes = {
+  variant: PropTypes.string,
+  color: PropTypes.string
 }
 
-export default compose(withApi(false), withStyles(LoginLogout.styles))(LoginLogout)
+export default LoginLogout
diff --git a/gui/src/components/UserdataPage.js b/gui/src/components/UserdataPage.js
index 46df92171db8cd5567630337c12d23b5edd9f743..8c09518671db39075676e80b3de7a5963205fddd 100644
--- a/gui/src/components/UserdataPage.js
+++ b/gui/src/components/UserdataPage.js
@@ -16,7 +16,7 @@
  * limitations under the License.
  */
 import React from 'react'
-import { withLoginRequired } from './apiV1'
+import { withLoginRequired } from './api'
 import { SearchContext } from './search/SearchContext'
 import Search from './search/Search'
 
diff --git a/gui/src/components/api.js b/gui/src/components/api.js
index d3e9db5d83b03ab0557468da3e5ee76b1dfe5ae8..0a9382f28cc0df91618ffd219535e124758cd215 100644
--- a/gui/src/components/api.js
+++ b/gui/src/components/api.js
@@ -15,20 +15,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React, { useContext, useEffect, useCallback, useRef } from 'react'
+import React, { useEffect, useMemo, useState } from 'react'
+import {
+  atom,
+  useSetRecoilState,
+  useRecoilValue,
+  useRecoilState
+} from 'recoil'
 import PropTypes from 'prop-types'
-import { withErrors } from './errors'
-import { UploadRequest } from '@navjobs/upload'
-import Swagger from 'swagger-client'
 import { apiBase } from '../config'
-import { Typography, withStyles } from '@material-ui/core'
+import { makeStyles, Typography } from '@material-ui/core'
 import LoginLogout from './LoginLogout'
-import { compose } from 'recompose'
-import { withKeycloak } from 'react-keycloak'
+import { useKeycloak } from 'react-keycloak'
+import axios from 'axios'
+import { useErrors } from './errors'
 import * as searchQuantities from '../searchQuantities.json'
 
-export const apiContext = React.createContext()
-
 export class DoesNotExist extends Error {
   constructor(msg) {
     super(msg)
@@ -50,89 +52,6 @@ export class ApiError extends Error {
   }
 }
 
-const upload_to_gui_ids = {}
-let gui_upload_id_counter = 0
-
-class Upload {
-  constructor(json, api) {
-    this.api = api
-
-    // Cannot use upload_id as key in GUI, because uploads don't have an upload_id
-    // before upload is completed
-    if (json.upload_id) {
-      // instance from the API
-      this.gui_upload_id = upload_to_gui_ids[json.upload_id]
-      if (this.gui_upload_id === undefined) {
-        // never seen in the GUI, needs a GUI id
-        this.gui_upload_id = gui_upload_id_counter++
-        upload_to_gui_ids[json.upload_id] = this.gui_upload_id
-      }
-    } else {
-      // new instance, not from the API
-      this.gui_upload_id = gui_upload_id_counter++
-    }
-    Object.assign(this, json)
-  }
-
-  uploadFile(file) {
-    const uploadFileWithProgress = async () => {
-      const authHeaders = await this.api.authHeaders()
-      let uploadRequest = await UploadRequest(
-        {
-          request: {
-            url: `${apiBase}/uploads/?name=${this.name}`,
-            method: 'PUT',
-            headers: {
-              'Content-Type': 'application/gzip',
-              ...authHeaders
-            }
-          },
-          files: [file],
-          progress: value => {
-            this.uploading = value
-          }
-        }
-      )
-      if (uploadRequest.error) {
-        handleApiError(uploadRequest.response ? uploadRequest.response.message : uploadRequest.error)
-      }
-      if (uploadRequest.aborted) {
-        throw Error('User abort')
-      }
-      this.uploading = 100
-      this.upload_id = uploadRequest.response.upload_id
-      upload_to_gui_ids[this.upload_id] = this.gui_upload_id
-    }
-
-    return uploadFileWithProgress()
-      .then(() => this)
-  }
-
-  get(page, perPage, orderBy, order) {
-    if (this.uploading !== null && this.uploading !== 100) {
-      return new Promise(resolve => resolve(this))
-    } else {
-      if (this.upload_id) {
-        return this.api.swagger().then(client => client.apis.uploads.get_upload({
-          upload_id: this.upload_id,
-          page: page || 1,
-          per_page: perPage || 5,
-          order_by: orderBy || 'mainfile',
-          order: order || -1
-        }))
-          .catch(handleApiError)
-          .then(response => response.body)
-          .then(uploadJson => {
-            Object.assign(this, uploadJson)
-            return this
-          })
-      } else {
-        return new Promise(resolve => resolve(this))
-      }
-    }
-  }
-}
-
 function handleApiError(e) {
   if (e.name === 'CannotReachApi' || e.name === 'NotAuthorized' || e.name === 'DoesNotExist') {
     throw e
@@ -167,49 +86,49 @@ function handleApiError(e) {
 }
 
 class Api {
-  swagger() {
-    if (this.keycloak.token) {
-      const self = this
-      return new Promise((resolve, reject) => {
-        self.keycloak.updateToken()
-          .success(() => {
-            self._swaggerClient
-              .then(swaggerClient => {
-                swaggerClient.authorizations = {
-                  'OpenIDConnect Bearer Token': `Bearer ${self.keycloak.token}`
-                }
-                resolve(swaggerClient)
-              })
-              .catch(() => {
-                reject(new ApiError())
-              })
-          })
-          .error(() => {
-            reject(new ApiError())
-          })
-      })
-    } else {
-      const self = this
-      return new Promise((resolve, reject) => {
-        self._swaggerClient
-          .then(swaggerClient => {
-            swaggerClient.authorizations = {}
-            resolve(swaggerClient)
-          })
-          .catch(() => {
-            reject(new ApiError())
-          })
-      })
+  constructor(keycloak, setLoading) {
+    this.keycloak = keycloak
+    this.setLoading = setLoading
+    this.axios = axios.create({
+      baseURL: `${apiBase}/v1`
+    })
+
+    this.nLoading = 0
+
+    this.onFinishLoading = (show) => {
+      if (show) {
+        this.nLoading--
+        if (this.nLoading === 0) {
+          setLoading(false)
+        }
+      }
     }
+    this.onStartLoading = (show) => {
+      if (show) {
+        this.nLoading++
+        if (this.nLoading > 0) {
+          setLoading(true)
+        }
+      }
+    }
+
+    this.subscriptions = {}
   }
 
-  authHeaders() {
+  /**
+   * Fetches the up-to-date authorization headers. Performs a token update if
+   * the current token has expired.
+   * @returns Object containing the authorization header.
+   */
+  async authHeaders() {
     if (this.keycloak.token) {
       return new Promise((resolve, reject) => {
         this.keycloak.updateToken()
           .success(() => {
             resolve({
-              'Authorization': `Bearer ${this.keycloak.token}`
+              headers: {
+                'Authorization': `Bearer ${this.keycloak.token}`
+              }
             })
           })
           .error(() => {
@@ -221,254 +140,227 @@ class Api {
     }
   }
 
-  constructor(keycloak) {
-    this._swaggerClient = Swagger(`${apiBase}/swagger.json`)
-    this.keycloak = keycloak
-
-    this.loadingHandler = []
-    this.loading = 0
-
-    this.onFinishLoading = () => {
-      this.loading--
-      this.loadingHandler.forEach(handler => handler(this.loading))
-    }
-    this.onStartLoading = () => {
-      this.loading++
-      this.loadingHandler.forEach(handler => handler(this.loading))
+  /**
+   * Returns the entry information that is stored in the search index.
+   *
+   * @param {string} entryId
+   * @returns Object containing the search index contents.
+   */
+  async entry(entryId) {
+    this.onStartLoading()
+    const auth = await this.authHeaders()
+    try {
+      const entry = await this.axios.get(`/entries/${entryId}`, auth)
+      return entry.data
+    } catch (errors) {
+      handleApiError(errors)
+    } finally {
+      this.onFinishLoading()
     }
-
-    Api.uploadIds = 0
   }
 
-  createUpload(name) {
-    const upload = new Upload({
-      upload_id: Api.uploadIds++,
-      name: name,
-      current_process_step: 'uploading',
-      uploading: 0,
-      create_time: new Date()
-    }, this)
-
-    return upload
+  /**
+   * Returns the data related to the specified dataset.
+   *
+   * @param {string} datasetID
+   * @returns Object containing the search index contents.
+   */
+  async datasets(datasetId) {
+    this.onStartLoading()
+    const auth = await this.authHeaders()
+    try {
+      const entry = await this.axios.get(`/datasets/${datasetId}`, auth)
+      return entry.data.data
+    } catch (errors) {
+      handleApiError(errors)
+    } finally {
+      this.onFinishLoading()
+    }
   }
 
-  onLoading(handler) {
-    this.loadingHandler = [...this.loadingHandler, handler]
+  /**
+   * Returns section_results from the archive corresponding to the given entry.
+   * Some large quantities which are not required by the GUI are filtered out.
+   * All references within the section are resolved by the server before
+   * sending.
+   *
+   * @param {string} entryId
+   * @returns Object containing section_results
+   */
+  async results(entryId) {
+    this.onStartLoading()
+    const auth = await this.authHeaders()
+    try {
+      const entry = await this.axios.post(
+        `/entries/${entryId}/archive/query`,
+        {
+          required: {
+            'resolve-inplace': false,
+            results: {
+              material: '*',
+              method: '*',
+              properties: {
+                structures: '*',
+                electronic: 'include-resolved',
+                vibrational: 'include-resolved',
+                // We require only the energies: trajectory, optimized
+                // structure, etc. are unnecessary.
+                geometry_optimization: {
+                  energies: 'include-resolved'
+                },
+                spectra: 'include-resolved'
+              }
+            }
+          }
+        },
+        auth
+      )
+      return parse(entry).data.data.archive
+    } catch (errors) {
+      handleApiError(errors)
+    } finally {
+      this.onFinishLoading()
+    }
   }
 
-  removeOnLoading(handler) {
-    this.loadingHandler = this.loadingHandler.filter(item => item !== handler)
+  /**
+   * Returns a list of suggestions for the given metainfo quantities.
+   *
+   * @param {string} quantity The quantity names for which suggestions are
+   * returned.
+   * @param {string} input Input used to filter the responses. Must be provided
+   * in order to return suggestions.
+   *
+   * @returns List of suggested values. The items are ordered by how well they
+   * match the input.
+   */
+  async suggestions(quantities, input) {
+    const auth = await this.authHeaders()
+    try {
+      const suggestions = await this.axios.post(
+        `/suggestions`,
+        {
+          input: input,
+          quantities: quantities
+        },
+        auth
+      )
+      return suggestions.data
+    } catch (errors) {
+      handleApiError(errors)
+    }
   }
 
-  async getUploads(state, page, perPage) {
-    state = state || 'all'
-    page = page || 1
-    perPage = perPage || 10
-
+  /**
+   * Return the raw file metadata for a given entry.
+   * @param {string} entryId
+   * @returns Object containing the raw file metadata.
+   */
+  async getRawFileListFromCalc(entryId) {
     this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.uploads.get_uploads({state: state, page: page, per_page: perPage}))
-      .catch(handleApiError)
-      .then(response => ({
-        ...response.body,
-        results: response.body.results.map(uploadJson => {
-          const upload = new Upload(uploadJson, this)
-          upload.uploading = 100
-          return upload
-        })
-      }))
-      .finally(this.onFinishLoading)
+    const auth = await this.authHeaders()
+    try {
+      const entry = await this.axios.get(`/entries/${entryId}/raw`, auth)
+      return entry.data
+    } catch (errors) {
+      handleApiError(errors)
+    } finally {
+      this.onFinishLoading()
+    }
   }
 
-  async getUnpublishedUploads() {
-    return this.getUploads('unpublished', 1, 1000)
+  /**
+   * Executes the given entry query
+   * @param {object} search contains the search object
+   * @param {string} searchTarget The target of the search: entries or materials
+   * @returns Object containing the raw file metadata.
+   */
+  async query(searchTarget, search, show = true) {
+    this.onStartLoading(show)
+    const auth = await this.authHeaders()
+    try {
+      const result = await this.axios.post(
+        `${searchTarget}/query`,
+        {
+          exclude: ['atoms', 'only_atoms', 'files', 'dft.quantities', 'dft.optimade', 'dft.labels', 'dft.geometries'],
+          ...search
+        },
+        auth
+      )
+      return result.data
+    } catch (errors) {
+      handleApiError(errors)
+    } finally {
+      this.onFinishLoading(show)
+    }
   }
 
-  async getPublishedUploads(page, perPage) {
-    return this.getUploads('published', 1, 10)
+  async get(path, query, config) {
+    const method = (path, body, config) => this.axios.get(path, config)
+    return this.doHttpRequest(method, path, null, {params: query, ...config})
   }
 
-  async archive(uploadId, calcId) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.archive.get_archive_calc({
-        upload_id: uploadId,
-        calc_id: calcId
-      }))
-      .catch(handleApiError)
-      .then(response => {
-        const result = response.body || response.text || response.data
-        if (typeof result === 'string') {
-          try {
-            return JSON.parse(result)
-          } catch (e) {
-            try {
-              return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"'))
-            } catch (e) {
-              return result
-            }
-          }
-        } else {
-          return result
-        }
-      })
-      .finally(this.onFinishLoading)
+  async post(path, body, config) {
+    const method = (path, body, config) => this.axios.post(path, body, config)
+    return this.doHttpRequest(method, path, body, config)
   }
 
-  async encyclopediaBasic(materialId) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.encyclopedia.get_material({
-        material_id: materialId
-      }))
-      .catch(handleApiError)
-      .then(response => {
-        const result = response.body || response.text || response.data
-        if (typeof result === 'string') {
-          try {
-            return JSON.parse(result)
-          } catch (e) {
-            try {
-              return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"'))
-            } catch (e) {
-              return result
-            }
-          }
-        } else {
-          return result
-        }
-      })
-      .finally(this.onFinishLoading)
+  async put(path, body, config) {
+    const method = (path, body, config) => this.axios.put(path, body, config)
+    return this.doHttpRequest(method, path, body, config)
   }
 
-  async encyclopediaCalculations(materialId) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.encyclopedia.get_calculations({
-        material_id: materialId
-      }))
-      .catch(handleApiError)
-      .then(response => {
-        const result = response.body || response.text || response.data
-        if (typeof result === 'string') {
-          try {
-            return JSON.parse(result)
-          } catch (e) {
-            try {
-              return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"'))
-            } catch (e) {
-              return result
-            }
-          }
-        } else {
-          return result
-        }
-      })
-      .finally(this.onFinishLoading)
+  async delete(path, config) {
+    const method = (path, body, config) => this.axios.delete(path, config)
+    return this.doHttpRequest(method, path, null, config)
   }
 
-  async encyclopediaCalculation(materialId, calcId, payload) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.encyclopedia.get_calculation({
-        material_id: materialId,
-        calc_id: calcId,
-        payload: payload
-      }))
-      .catch(handleApiError)
-      .then(response => {
-        const result = response.body || response.text || response.data
-        if (typeof result === 'string') {
-          try {
-            return JSON.parse(result)
-          } catch (e) {
-            try {
-              return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"'))
-            } catch (e) {
-              return result
-            }
-          }
-        } else {
-          return result
-        }
-      })
-      .finally(this.onFinishLoading)
-  }
-
-  async calcProcLog(uploadId, calcId) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.archive.get_archive_logs({
-        upload_id: uploadId,
-        calc_id: calcId
-      }))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
+  async doHttpRequest(method, path, body, config) {
+    const noLoading = config?.noLoading
+    if (!noLoading) {
+      this.onStartLoading()
+    }
+    const auth = await this.authHeaders()
+    config = config || {}
+    config.params = config.params || {}
+    config.headers = config.headers || {
+      accept: 'application/json',
+      ...auth.headers
+    }
+    try {
+      const results = await method(path, body, config)
+      return results.data
+    } catch (errors) {
+      if (config.noHandleErrors) {
+        throw errors
+      }
+      handleApiError(errors)
+    } finally {
+      if (!noLoading) {
+        this.onFinishLoading()
+      }
+    }
   }
 
-  async getRawFileListFromCalc(uploadId, calcId) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.raw.get_file_list_from_calc({
-        upload_id: uploadId,
-        calc_id: calcId,
-        path: null
-      }))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
+  async getUsers(prefix) {
+    // no loading indicator, because this is only used in the background of the edit dialog
+    return this.get('users', {prefix: prefix}, {noLoading: true}).then(response => response.data)
   }
 
-  async getRawFile(uploadId, calcId, path, kwargs) {
-    this.onStartLoading()
-    const length = (kwargs && kwargs.length) || 4096
-    return this.swagger()
-      .then(client => client.apis.raw.get_file_from_calc({
-        upload_id: uploadId,
-        calc_id: calcId,
-        path: path,
-        decompress: true,
-        ...(kwargs || {}),
-        length: length
-      }))
-      .catch(handleApiError)
-      .then(response => {
-        /* global Blob */
-        /* eslint no-undef: "error" */
-        if (response.data instanceof Blob) {
-          if (response.data.type.endsWith('empty')) {
-            return {
-              contents: '',
-              hasMore: false,
-              mimeType: 'plain/text'
-            }
-          }
-          return {
-            contents: null,
-            hasMore: false,
-            mimeType: response.data.type
-          }
-        }
-        return {
-          contents: response.data,
-          hasMore: response.data.length === length,
-          mimeType: 'plain/text'
-        }
-      })
-      .finally(this.onFinishLoading)
+  async inviteUser(user) {
+    return this.put('users/invite', user, {noHandleErrors: true})
   }
 
-  async repo(uploadId, calcId) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.repo.get_repo_calc({
-        upload_id: uploadId,
-        calc_id: calcId
-      }))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
+  async getDatasets(prefix) {
+    // no loading indicator, because this is only used in the background of the edit dialog
+    const user = await this.keycloak.loadUserInfo()
+    const query = {
+      prefix: prefix,
+      dataset_type: 'owned',
+      user_id: user.id,
+      page_size: 1000
+    }
+    return this.get('datasets', query, {noLoading: true}).then(response => response.data)
   }
 
   async edit(edit) {
@@ -484,430 +376,128 @@ class Api {
         }
       }
     })
-    return this.swagger()
-      .then(client => client.apis.repo.edit_repo({payload: edit}))
-      .catch(handleApiError)
-      .then(response => response.body)
-  }
-
-  async resolvePid(pid) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.repo.resolve_pid({pid: pid}))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
-  }
-
-  async resolveDoi(doi) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.datasets.resolve_doi({doi: doi}))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
-  }
-
-  async search(search) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.repo.search({
-        exclude: ['atoms', 'only_atoms', 'files', 'dft.quantities', 'dft.optimade', 'dft.labels', 'dft.geometries'],
-        ...search}))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
-  }
-
-  async getDatasets(prefix) {
-    // no loading indicator, because this is only used in the background of the edit dialog
-    return this.swagger()
-      .then(client => client.apis.datasets.list_datasets({prefix: prefix}))
-      .catch(handleApiError)
-      .then(response => response.body)
-  }
-
-  async assignDatasetDOI(datasetName) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.datasets.assign_doi({name: datasetName}))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
-  }
-
-  async deleteDataset(datasetName) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.datasets.delete_dataset({name: datasetName}))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
+    return this.post('entries/edit', edit)
   }
+}
 
-  async getUsers(query) {
-    // no loading indicator, because this is only used in the background of the edit dialog
-    return this.swagger()
-      .then(client => client.apis.auth.get_users({query: query}))
-      .catch(handleApiError)
-      .then(response => response.body)
-  }
-
-  async inviteUser(user) {
-    return this.swagger()
-      .then(client => client.apis.auth.invite_user({payload: user}))
-      .catch(handleApiError)
-      .then(response => response.body)
-  }
-
-  async quantities_search(search) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.repo.quantities_search(search))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
-  }
-
-  async quantity_search(search) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.repo.quantity_search(search))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
-  }
-
-  async suggestions_search(quantity, search, include, size, noLoadingIndicator) {
-    if (!noLoadingIndicator) {
-      this.onStartLoading()
-    }
-    return this.swagger()
-      .then(client => client.apis.repo.suggestions_search({
-        size: size || 20,
-        include: include,
-        quantity: quantity,
-        ...search
-      }))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(() => {
-        if (!noLoadingIndicator) {
-          this.onFinishLoading()
-        }
-      })
-  }
-
-  async deleteUpload(uploadId) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.uploads.delete_upload({upload_id: uploadId}))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
-  }
-
-  async publishUpload(uploadId, embargoLength) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.uploads.exec_upload_operation({
-        upload_id: uploadId,
-        payload: {
-          operation: 'publish',
-          metadata: {
-            embargo_length: embargoLength
-          }
-        }
-      }))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
-  }
-
-  async publishUploadToCentralNomad(uploadId) {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.uploads.exec_upload_operation({
-        upload_id: uploadId,
-        payload: {
-          operation: 'publish-to-central-nomad'
-        }
-      }))
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
-  }
-
-  async getSignatureToken() {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.auth.get_auth())
-      .catch(handleApiError)
-      .then(response => response.body.signature_token)
-      .finally(this.onFinishLoading)
-  }
-
-  _cachedInfo = null
-
-  async getInfo() {
-    if (!this._cachedInfo) {
-      this.onStartLoading()
-      this._cachedInfo = await this.swagger()
-        .then(client => {
-          return client.apis.info.get_info()
-            .then(response => response.body)
-            .catch(handleApiError)
-        })
-        .finally(this.onFinishLoading)
+/**
+ * Custom JSON parse function that can handle NaNs that can be created by the
+ * python JSON serializer.
+ */
+function parse(result) {
+  if (typeof result === 'string') {
+    try {
+      return JSON.parse(result)
+    } catch (e) {
+      try {
+        return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"'))
+      } catch (e) {
+        return result
+      }
     }
-    return this._cachedInfo
-  }
-
-  async getUploadCommand() {
-    this.onStartLoading()
-    return this.swagger()
-      .then(client => client.apis.uploads.get_upload_command())
-      .catch(handleApiError)
-      .then(response => response.body)
-      .finally(this.onFinishLoading)
+  } else {
+    return result
   }
 }
 
-export class ApiProviderComponent extends React.Component {
-  static propTypes = {
-    children: PropTypes.oneOfType([
-      PropTypes.arrayOf(PropTypes.node),
-      PropTypes.node
-    ]).isRequired,
-    raiseError: PropTypes.func.isRequired,
-    keycloak: PropTypes.object.isRequired,
-    keycloakInitialized: PropTypes.bool
-  }
-
-  constructor(props) {
-    super(props)
-    this.onToken = this.onToken.bind(this)
-  }
+/**
+ * Hook that returns a shared instance of the API class and information about
+ * the currently logged in user.
+*/
+export function useApi() {
+  const [keycloak] = useKeycloak()
+  const setLoading = useSetLoading()
+  const [user, setUser] = useState(null)
+  const [info, setInfo] = useState(null)
 
-  onToken(token) {
-    // console.log(token)
-  }
+  const api = useMemo(() => new Api(keycloak, setLoading), [keycloak, setLoading])
 
-  update() {
-    const { keycloak } = this.props
-    this.setState({api: this.createApi(keycloak)})
-    if (keycloak.token) {
-      keycloak.loadUserInfo()
-        .success(user => {
-          this.setState({user: user})
-        })
-        .error(error => {
-          this.props.raiseError(error)
-        })
+  useEffect(() => {
+    if (keycloak.authenticated) {
+      keycloak.loadUserInfo().success(setUser)
     }
-  }
+  }, [keycloak, setUser])
 
-  componentDidMount() {
-    this.update()
-  }
-
-  componentDidUpdate(prevProps) {
-    if (this.props.keycloakInitialized !== prevProps.keycloakInitialized) {
-      this.update()
+  useEffect(() => {
+    if (api) {
+      api.get('/info').then(setInfo).catch(() => {})
     }
-  }
-
-  createApi(keycloak) {
-    const api = new Api(keycloak)
-    api.getInfo()
-      .catch(handleApiError)
-      .then(info => {
-        if (info.parsers) {
-          info.parsers.sort()
-        }
-        this.setState({info: info})
-      })
-      .catch(error => {
-        this.props.raiseError(error)
-      })
-
-    return api
-  }
-
-  state = {
-    api: null,
-    info: null
-  }
+  }, [api])
 
-  render() {
-    const { children } = this.props
-    return (
-      <apiContext.Provider value={this.state}>
-        {children}
-      </apiContext.Provider>
-    )
+  return {
+    api: api,
+    user: user,
+    info: info
   }
 }
 
-class LoginRequiredUnstyled extends React.Component {
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    message: PropTypes.string
-  }
-
-  static styles = theme => ({
-    root: {
-      display: 'flex',
-      alignItems: 'center',
-      padding: theme.spacing(2),
-      '& p': {
-        marginRight: theme.spacing(2)
-      }
-    }
-  })
-
-  render() {
-    const {classes, message} = this.props
-
-    let loginMessage = ''
-    if (message) {
-      loginMessage = <Typography>
-        {this.props.message}
-      </Typography>
-    }
-
-    return (
-      <div className={classes.root}>
-        <div>
-          {loginMessage}
-        </div>
-        <LoginLogout color="primary" />
-      </div>
-    )
-  }
+/**
+ * Hooks/state for reading/writing whether the API is loading something.
+*/
+const apiLoading = atom({
+  key: 'apiLoading',
+  default: false
+})
+export function useLoading() {
+  return useRecoilValue(apiLoading)
 }
-
-export function DisableOnLoading({children}) {
-  const containerRef = useRef(null)
-  const {api} = useContext(apiContext)
-  const handleLoading = useCallback((loading) => {
-    const enable = loading ? 'none' : ''
-    containerRef.current.style.pointerEvents = enable
-    containerRef.current.style.userSelects = enable
-  }, [])
-
-  useEffect(() => {
-    api.onLoading(handleLoading)
-    return () => {
-      api.removeOnLoading(handleLoading)
-    }
-  }, [api, handleLoading])
-
-  return <div ref={containerRef}>{children}</div>
+export function useLoadingState() {
+  return useRecoilState(apiLoading)
 }
-DisableOnLoading.propTypes = {
-  children: PropTypes.any.isRequired
+export function useSetLoading() {
+  return useSetRecoilState(apiLoading)
 }
 
-export const ApiProvider = compose(withKeycloak, withErrors)(ApiProviderComponent)
-
-const LoginRequired = withStyles(LoginRequiredUnstyled.styles)(LoginRequiredUnstyled)
-
-const __reauthorize_trigger_changes = ['api', 'calcId', 'uploadId', 'calc_id', 'upload_id']
-
-class WithApiComponent extends React.Component {
-  static propTypes = {
-    raiseError: PropTypes.func.isRequired,
-    loginRequired: PropTypes.bool,
-    showErrorPage: PropTypes.bool,
-    loginMessage: PropTypes.string,
-    api: PropTypes.object,
-    user: PropTypes.object,
-    Component: PropTypes.any
-  }
-
-  state = {
-    notAuthorized: false
-  }
-
-  constructor(props) {
-    super(props)
-    this.raiseError = this.raiseError.bind(this)
-  }
-
-  componentDidUpdate(prevProps) {
-    if (__reauthorize_trigger_changes.find(key => this.props[key] !== prevProps[key])) {
-      this.setState({notAuthorized: false})
+const useLoginRequiredStyles = makeStyles(theme => ({
+  root: {
+    padding: theme.spacing(2),
+    display: 'flex',
+    alignItems: 'center',
+    '& p': {
+      marginRight: theme.spacing(1)
     }
   }
+}))
 
-  raiseError(error) {
-    const { raiseError, showErrorPage } = this.props
-
-    console.error(error)
-
-    if (!showErrorPage) {
-      raiseError(error)
-    } else {
-      if (error.name === 'NotAuthorized') {
-        this.setState({notAuthorized: true})
-      } else {
-        raiseError(error)
-      }
-    }
-  }
-
-  render() {
-    const { raiseError, loginRequired, loginMessage, Component, ...rest } = this.props
-    const { api, keycloak } = rest
-    const { notAuthorized } = this.state
-    if (notAuthorized) {
-      if (keycloak.authenticated) {
-        return (
-          <div style={{marginTop: 24}}>
-            <Typography variant="h6">Not Authorized</Typography>
-            <Typography>
-              You are not authorized to access this information. If someone send
-              you a link to this data, ask the authors to make the data publicly available
-              or share it with you.
-            </Typography>
-          </div>
-        )
-      } else {
-        return (
-          <LoginRequired message="You need to be logged in to access this information." />
-        )
-      }
-    } else {
-      if (api) {
-        if (keycloak.authenticated || !loginRequired) {
-          return <Component {...rest} raiseError={this.raiseError} />
-        } else {
-          return <LoginRequired message={loginMessage} />
-        }
-      } else {
-        return ''
-      }
-    }
+export function LoginRequired({message, children}) {
+  const classes = useLoginRequiredStyles()
+  const {api} = useApi()
+  if (api.keycloak.authenticated) {
+    return <React.Fragment>
+      {children}
+    </React.Fragment>
+  } else {
+    return <div className={classes.root}>
+      <Typography>
+        {message || 'You have to login to use this functionality.'}
+      </Typography>
+      <LoginLogout color="primary" />
+    </div>
   }
 }
+LoginRequired.propTypes = {
+  message: PropTypes.string,
+  children: PropTypes.oneOfType([
+    PropTypes.arrayOf(PropTypes.node),
+    PropTypes.node
+  ]).isRequired
+}
 
-const WithKeycloakWithApiCompnent = withKeycloak(WithApiComponent)
-
-export function withApi(loginRequired, showErrorPage, loginMessage) {
-  return function(Component) {
-    return withErrors(props => (
-      <apiContext.Consumer>
-        {apiContext => (
-          <WithKeycloakWithApiCompnent
-            loginRequired={loginRequired}
-            loginMessage={loginMessage}
-            showErrorPage={showErrorPage}
-            Component={Component}
-            {...props} {...apiContext}
-          />
-        )}
-      </apiContext.Consumer>
-    ))
-  }
+/**
+ * HOC for wrapping components that require a login. Without login will return
+ * the given message together with a login link.
+ *
+ * @param {*} Component
+ * @param {*} message The message to display
+ */
+export function withLoginRequired(Component, message) {
+  return ({...props}) => <LoginRequired message={message}>
+    <Component {...props} />
+  </LoginRequired>
 }
+
+export const withApi = (Component) => React.forwardRef((props, ref) => {
+  const apiProps = useApi()
+  const {raiseError} = useErrors()
+  return <Component ref={ref} {...apiProps} raiseError={raiseError} {...props} />
+})
diff --git a/gui/src/components/apiV1.js b/gui/src/components/apiV1.js
deleted file mode 100644
index a100cec0e17dd07f70672fc8a6b9d46fa222f945..0000000000000000000000000000000000000000
--- a/gui/src/components/apiV1.js
+++ /dev/null
@@ -1,444 +0,0 @@
-/*
- * Copyright The NOMAD Authors.
- *
- * This file is part of NOMAD. See https://nomad-lab.eu for further info.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import React, { useMemo } from 'react'
-import {
-  atom,
-  useSetRecoilState,
-  useRecoilValue,
-  useRecoilState
-} from 'recoil'
-import PropTypes from 'prop-types'
-import { apiBase } from '../config'
-import { makeStyles, Typography } from '@material-ui/core'
-import LoginLogout from './LoginLogout'
-import { useKeycloak } from 'react-keycloak'
-import axios from 'axios'
-
-export class DoesNotExist extends Error {
-  constructor(msg) {
-    super(msg)
-    this.name = 'DoesNotExist'
-  }
-}
-
-export class NotAuthorized extends Error {
-  constructor(msg) {
-    super(msg)
-    this.name = 'NotAuthorized'
-  }
-}
-
-export class ApiError extends Error {
-  constructor(msg) {
-    super(msg)
-    this.name = 'CannotReachApi'
-  }
-}
-
-function handleApiError(e) {
-  if (e.name === 'CannotReachApi' || e.name === 'NotAuthorized' || e.name === 'DoesNotExist') {
-    throw e
-  }
-
-  let error = null
-  if (e.response) {
-    const body = e.response.body
-    const message = (body && (body.message || body.description)) || e.response.statusText
-    const errorMessage = `${message} (${e.response.status})`
-    if (e.response.status === 404) {
-      error = new DoesNotExist(errorMessage)
-    } else if (e.response.status === 401) {
-      error = new NotAuthorized(errorMessage)
-    } else if (e.response.status === 502) {
-      error = new ApiError(errorMessage)
-    } else {
-      error = new Error(errorMessage)
-    }
-    error.status = e.response.status
-    error.apiMessage = message
-  } else {
-    if (e.message === 'Failed to fetch') {
-      error = new ApiError(e.message)
-      error.status = 400
-    } else {
-      const errorMessage = e.status ? `${e} (${e.status})` : '' + e
-      error = new Error(errorMessage)
-    }
-  }
-  throw error
-}
-
-class Api {
-  constructor(keycloak, setLoading) {
-    this.keycloak = keycloak
-    this.setLoading = setLoading
-    this.axios = axios.create({
-      baseURL: `${apiBase}/v1`
-    })
-
-    this.nLoading = 0
-
-    this.onFinishLoading = (show) => {
-      if (show) {
-        this.nLoading--
-        if (this.nLoading === 0) {
-          setLoading(false)
-        }
-      }
-    }
-    this.onStartLoading = (show) => {
-      if (show) {
-        this.nLoading++
-        if (this.nLoading > 0) {
-          setLoading(true)
-        }
-      }
-    }
-
-    this.subscriptions = {}
-  }
-
-  /**
-   * Fetches the up-to-date authorization headers. Performs a token update if
-   * the current token has expired.
-   * @returns Object containing the authorization header.
-   */
-  async authHeaders() {
-    if (this.keycloak.token) {
-      return new Promise((resolve, reject) => {
-        this.keycloak.updateToken()
-          .success(() => {
-            resolve({
-              headers: {
-                'Authorization': `Bearer ${this.keycloak.token}`
-              }
-            })
-          })
-          .error(() => {
-            reject(new ApiError())
-          })
-      })
-    } else {
-      return {}
-    }
-  }
-
-  /**
-   * Returns the entry information that is stored in the search index.
-   *
-   * @param {string} entryId
-   * @returns Object containing the search index contents.
-   */
-  async entry(entryId) {
-    this.onStartLoading()
-    const auth = await this.authHeaders()
-    try {
-      const entry = await this.axios.get(`/entries/${entryId}`, auth)
-      return entry.data
-    } catch (errors) {
-      handleApiError(errors)
-    } finally {
-      this.onFinishLoading()
-    }
-  }
-
-  /**
-   * Returns the data related to the specified dataset.
-   *
-   * @param {string} datasetID
-   * @returns Object containing the search index contents.
-   */
-  async datasets(datasetId) {
-    this.onStartLoading()
-    const auth = await this.authHeaders()
-    try {
-      const entry = await this.axios.get(`/datasets/${datasetId}`, auth)
-      return entry.data.data
-    } catch (errors) {
-      handleApiError(errors)
-    } finally {
-      this.onFinishLoading()
-    }
-  }
-
-  /**
-   * Returns section_results from the archive corresponding to the given entry.
-   * Some large quantities which are not required by the GUI are filtered out.
-   * All references within the section are resolved by the server before
-   * sending.
-   *
-   * @param {string} entryId
-   * @returns Object containing section_results
-   */
-  async results(entryId) {
-    this.onStartLoading()
-    const auth = await this.authHeaders()
-    try {
-      const entry = await this.axios.post(
-        `/entries/${entryId}/archive/query`,
-        {
-          required: {
-            'resolve-inplace': false,
-            results: {
-              material: '*',
-              method: '*',
-              properties: {
-                structures: '*',
-                electronic: 'include-resolved',
-                vibrational: 'include-resolved',
-                // We require only the energies: trajectory, optimized
-                // structure, etc. are unnecessary.
-                geometry_optimization: {
-                  energies: 'include-resolved'
-                },
-                spectra: 'include-resolved'
-              }
-            }
-          }
-        },
-        auth
-      )
-      return parse(entry).data.data.archive
-    } catch (errors) {
-      handleApiError(errors)
-    } finally {
-      this.onFinishLoading()
-    }
-  }
-
-  /**
-   * Returns a list of suggestions for the given metainfo quantities.
-   *
-   * @param {string} quantity The quantity names for which suggestions are
-   * returned.
-   * @param {string} input Input used to filter the responses. Must be provided
-   * in order to return suggestions.
-   *
-   * @returns List of suggested values. The items are ordered by how well they
-   * match the input.
-   */
-  async suggestions(quantities, input) {
-    const auth = await this.authHeaders()
-    try {
-      const suggestions = await this.axios.post(
-        `/suggestions`,
-        {
-          input: input,
-          quantities: quantities
-        },
-        auth
-      )
-      return suggestions.data
-    } catch (errors) {
-      handleApiError(errors)
-    }
-  }
-
-  /**
-   * Return the raw file metadata for a given entry.
-   * @param {string} entryId
-   * @returns Object containing the raw file metadata.
-   */
-  async getRawFileListFromCalc(entryId) {
-    this.onStartLoading()
-    const auth = await this.authHeaders()
-    try {
-      const entry = await this.axios.get(`/entries/${entryId}/raw`, auth)
-      return entry.data
-    } catch (errors) {
-      handleApiError(errors)
-    } finally {
-      this.onFinishLoading()
-    }
-  }
-
-  /**
-   * Executes the given entry query
-   * @param {object} search contains the search object
-   * @param {string} searchTarget The target of the search: entries or materials
-   * @returns Object containing the raw file metadata.
-   */
-  async query(searchTarget, search, show = true) {
-    this.onStartLoading(show)
-    const auth = await this.authHeaders()
-    try {
-      const result = await this.axios.post(
-        `${searchTarget}/query`,
-        {
-          exclude: ['atoms', 'only_atoms', 'files', 'dft.quantities', 'dft.optimade', 'dft.labels', 'dft.geometries'],
-          ...search
-        },
-        auth
-      )
-      return result.data
-    } catch (errors) {
-      handleApiError(errors)
-    } finally {
-      this.onFinishLoading(show)
-    }
-  }
-
-  async get(path, query) {
-    const method = (path, body, config) => this.axios.get(path, config)
-    return this.doHttpRequest(method, path, null, {params: query})
-  }
-
-  async post(path, body, config) {
-    const method = (path, body, config) => this.axios.post(path, body, config)
-    return this.doHttpRequest(method, path, body, config)
-  }
-
-  async put(path, body, config) {
-    const method = (path, body, config) => this.axios.put(path, body, config)
-    return this.doHttpRequest(method, path, body, config)
-  }
-
-  async delete(path, config) {
-    const method = (path, body, config) => this.axios.delete(path, config)
-    return this.doHttpRequest(method, path, null, config)
-  }
-
-  async doHttpRequest(method, path, body, config) {
-    this.onStartLoading()
-    const auth = await this.authHeaders()
-    config = config || {}
-    config.params = config.params || {}
-    config.headers = config.headers || {
-      accept: 'application/json',
-      ...auth.headers
-    }
-    try {
-      const results = await method(path, body, config)
-      return results.data
-    } catch (errors) {
-      handleApiError(errors)
-    } finally {
-      this.onFinishLoading()
-    }
-  }
-}
-
-/**
- * Custom JSON parse function that can handle NaNs that can be created by the
- * python JSON serializer.
- */
-function parse(result) {
-  if (typeof result === 'string') {
-    try {
-      return JSON.parse(result)
-    } catch (e) {
-      try {
-        return JSON.parse(result.replace(/\bNaN\b/g, '"NaN"'))
-      } catch (e) {
-        return result
-      }
-    }
-  } else {
-    return result
-  }
-}
-
-/**
- * Hook that returns a shared instance of the API class and information about
- * the currently logged in user.
-*/
-let api = null
-let user = null
-export function useApi() {
-  const [keycloak] = useKeycloak()
-  const setLoading = useSetLoading()
-
-  if (!api || api.keycloak !== keycloak) {
-    api = new Api(keycloak, setLoading)
-    if (keycloak.authenticated) {
-      keycloak.loadUserInfo()
-        .success(data => {
-          user = data
-        })
-    }
-  }
-  const result = useMemo(() => {
-    return {api, user}
-  }, [])
-  return result
-}
-
-/**
- * Hooks/state for reading/writing whether the API is loading something.
-*/
-const apiLoading = atom({
-  key: 'apiLoading',
-  default: false
-})
-export function useLoading() {
-  return useRecoilValue(apiLoading)
-}
-export function useLoadingState() {
-  return useRecoilState(apiLoading)
-}
-export function useSetLoading() {
-  return useSetRecoilState(apiLoading)
-}
-
-const useLoginRequiredStyles = makeStyles(theme => ({
-  root: {
-    padding: theme.spacing(2),
-    display: 'flex',
-    alignItems: 'center',
-    '& p': {
-      marginRight: theme.spacing(1)
-    }
-  }
-}))
-
-export function LoginRequired({message, children}) {
-  const classes = useLoginRequiredStyles()
-  const {api} = useApi()
-  if (api.keycloak.authenticated) {
-    return <React.Fragment>
-      {children}
-    </React.Fragment>
-  } else {
-    return <div className={classes.root}>
-      <Typography>
-        {message || 'You have to login to use this functionality.'}
-      </Typography>
-      <LoginLogout color="primary" />
-    </div>
-  }
-}
-LoginRequired.propTypes = {
-  message: PropTypes.string,
-  children: PropTypes.oneOfType([
-    PropTypes.arrayOf(PropTypes.node),
-    PropTypes.node
-  ]).isRequired
-}
-
-/**
- * HOC for wrapping components that require a login. Without login will return
- * the given message together with a login link.
- *
- * @param {*} Component
- * @param {*} message The message to display
- */
-export function withLoginRequired(Component, message) {
-  return ({...props}) => <LoginRequired message={message}>
-    <Component {...props} />
-  </LoginRequired>
-}
diff --git a/gui/src/components/archive/MetainfoBrowser.js b/gui/src/components/archive/MetainfoBrowser.js
index d27b23e2806afea256299eab172669facf64c997..f32b908173ccc25e52290a338915f0b65d6ac489 100644
--- a/gui/src/components/archive/MetainfoBrowser.js
+++ b/gui/src/components/archive/MetainfoBrowser.js
@@ -23,7 +23,6 @@ import Browser, { Item, Content, Compartment, Adaptor, laneContext, formatSubSec
 import { Typography, Box, makeStyles, Grid, FormGroup, TextField, Button } from '@material-ui/core'
 import { metainfoDef, resolveRef, vicinityGraph, rootSections, path as metainfoPath, packagePrefixes, defsByName, path } from './metainfo'
 import * as d3 from 'd3'
-import { apiContext } from '../api'
 import blue from '@material-ui/core/colors/blue'
 import teal from '@material-ui/core/colors/teal'
 import lime from '@material-ui/core/colors/lime'
@@ -35,6 +34,8 @@ import Histogram from '../Histogram'
 import { appBase } from '../../config'
 import { useHistory, useRouteMatch } from 'react-router-dom'
 import Autocomplete from '@material-ui/lab/Autocomplete'
+import { useApi } from '../api'
+import { useErrors } from '../errors'
 
 export const help = `
 The NOMAD *metainfo* defines all quantities used to represent archive data in
@@ -429,23 +430,45 @@ Definition.propTypes = {
 }
 
 function DefinitionDetails({def, ...props}) {
-  const {api} = useContext(apiContext)
+  const {api} = useApi()
+  const {raiseError} = useErrors()
   const lane = useContext(laneContext)
   const isLast = !lane.next
   const [usage, setUsage] = useState(null)
   const [showUsage, setShowUsage] = useState(false)
 
+  const quantityPath = useMemo(() => {
+    const path = lane.path.split('/')
+    const index = path.indexOf('EntryArchive')
+    if (index >= 0) {
+      return path.slice(index + 1).join('.')
+    }
+    return null
+  }, [lane])
+
   useEffect(() => {
     if (showUsage) {
-      api.quantity_search({
-        'dft.quantities': [def.name],
-        size: 100, // make sure we get all codes
-        quantity: 'dft.code_name'
-      }).then(result => {
-        setUsage(result.quantity.values)
-      })
+      api.post('/entries/query', {
+        owner: 'visible',
+        query: {
+          'quantities:any': [quantityPath]
+        },
+        aggregations: {
+          program_names: {
+            terms: {
+              quantity: 'results.method.simulation.program_name',
+              pagination: {
+                page_size: 100 // make sure we get all codes
+              }
+            }
+          }
+        }
+      }).then(response => {
+        const aggData = response.aggregations.program_names.terms.data
+        setUsage(aggData)
+      }).catch(raiseError)
     }
-  }, [api, def.name, showUsage, setUsage])
+  }, [api, raiseError, showUsage, quantityPath, setUsage])
 
   return <React.Fragment>
     {def.categories && def.categories.length > 0 && <Compartment title="Categories">
@@ -461,22 +484,22 @@ function DefinitionDetails({def, ...props}) {
         <VicinityGraph def={def} />
       </Compartment>
     }
-    {isLast && def.m_def !== 'Category' && def.name !== 'EntryArchive' && !def.extends_base_section &&
+    {quantityPath &&
       <Compartment title="usage">
         {!showUsage && <Button fullWidth variant="outlined" onClick={() => setShowUsage(true)}>Show usage</Button>}
         {showUsage && !usage && <Typography><i>loading ...</i></Typography>}
-        {usage && Object.keys(usage).length > 0 && (
+        {usage && usage.length > 0 && (
           <Histogram
-            data={Object.keys(usage).map(key => ({
-              key: key,
-              name: key,
-              value: usage[key].total
+            data={usage.map(use => ({
+              key: use.value,
+              name: use.value,
+              value: use.count
             }))}
             initialScale={0.5}
             title="Metadata use per code"
           />
         )}
-        {usage && Object.keys(usage).length === 0 && (
+        {usage && usage.length === 0 && (
           <Typography color="error"><i>This metadata is not used at all.</i></Typography>
         )}
       </Compartment>
diff --git a/gui/src/components/dataset/ResolveDOI.js b/gui/src/components/dataset/ResolveDOI.js
index 98bf72108c6858ea7f329dd2327f91aff364d828..3e5b793cef1a10f5d8bee6eed90331817fb3ad56 100644
--- a/gui/src/components/dataset/ResolveDOI.js
+++ b/gui/src/components/dataset/ResolveDOI.js
@@ -15,58 +15,54 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React from 'react'
-import PropTypes from 'prop-types'
-import { withStyles, Typography } from '@material-ui/core'
-import { compose } from 'recompose'
-import { withApi } from '../api'
-import { matchPath } from 'react-router'
+import React, { useEffect, useState } from 'react'
+import { Typography, makeStyles } from '@material-ui/core'
+import { matchPath, useLocation, useRouteMatch, useHistory } from 'react-router'
+import {useApi} from '../api'
+import {useErrors} from '../errors'
+import { getUrl } from '../nav/Routes'
 
-class ResolveDOI extends React.Component {
-  static styles = theme => ({
-    root: {
-      padding: theme.spacing(3)
-    }
-  })
-
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    api: PropTypes.object.isRequired,
-    raiseError: PropTypes.func.isRequired,
-    location: PropTypes.object.isRequired,
-    match: PropTypes.object.isRequired,
-    history: PropTypes.object.isRequired
+const useStyles = makeStyles(theme => ({
+  root: {
+    padding: theme.spacing(3)
   }
+}))
+
+export default function ResolveDOI() {
+  const classes = useStyles()
+  const {api} = useApi()
+  const {raiseError} = useErrors()
+  const history = useHistory()
+  const location = useLocation()
+  const match = useRouteMatch()
 
-  update() {
-    const { location, match, api, history, raiseError } = this.props
+  const [doesNotExist, setDoesNotExist] = useState(false)
+
+  useEffect(() => {
     const doiMatch = matchPath(location.pathname, {
       path: `${match.path}/:doi*`
     })
     let { doi } = doiMatch.params
 
-    api.resolveDoi(doi).then(dataset => {
-      history.push(`/dataset/id/${dataset.dataset_id}`)
-    }).catch(raiseError)
-  }
+    api.get('/datasets', {doi: doi})
+      .then(response => {
+        if (response.pagination.total >= 1) {
+          const dataset_id = response.data[0].dataset_id
+          history.push(getUrl(`dataset/id/${dataset_id}`, location))
+        } else {
+          setDoesNotExist(true)
+        }
+      })
+      .catch(raiseError)
+  }, [setDoesNotExist, history, location, match, api, raiseError])
 
-  componentDidMount() {
-    this.update()
-  }
+  let message = 'loading ...'
 
-  componentDidUpdate(prevProps) {
-    if (prevProps.location.pathname !== this.props.location.pathname || prevProps.api !== this.props.api) {
-      this.update()
-    }
+  if (doesNotExist) {
+    message = 'This URL points to a dataset that does not exist.'
   }
 
-  render() {
-    const { classes } = this.props
-
-    return (
-      <Typography className={classes.root}>loading ...</Typography>
-    )
-  }
+  return (
+    <Typography className={classes.root}>{message}</Typography>
+  )
 }
-
-export default compose(withApi(false), withStyles(ResolveDOI.styles))(ResolveDOI)
diff --git a/gui/src/components/entry/ArchiveEntryView.js b/gui/src/components/entry/ArchiveEntryView.js
index b72367213f428c997d44d01eca0b83213195cfba..413ec9b63a9389fdefaabff83eb5a2e8803fb5c7 100644
--- a/gui/src/components/entry/ArchiveEntryView.js
+++ b/gui/src/components/entry/ArchiveEntryView.js
@@ -15,15 +15,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React from 'react'
+import React, { useEffect, useState } from 'react'
 import PropTypes from 'prop-types'
-import { withStyles, Fab, Card, CardContent, Typography } from '@material-ui/core'
-import { compose } from 'recompose'
-import { withApi } from '../api'
+import { Fab, Card, CardContent, Typography, makeStyles } from '@material-ui/core'
 import DownloadIcon from '@material-ui/icons/CloudDownload'
 import Download from './Download'
 import ArchiveBrowser from '../archive/ArchiveBrowser'
 import Page from '../Page'
+import { useErrors } from '../errors'
+import { useApi } from '../api'
 
 export const help = `
 The NOMAD **archive** provides data and meta-data in a common hierarchical format based on
@@ -35,129 +35,87 @@ you can click section names to get more information. Browse the *metainfo* to
 learn more about NOMAD's archive format [here](/metainfo).
 `
 
-class ArchiveEntryView extends React.Component {
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    api: PropTypes.object.isRequired,
-    info: PropTypes.object,
-    raiseError: PropTypes.func.isRequired,
-    uploadId: PropTypes.string.isRequired,
-    entryId: PropTypes.string.isRequired
+const useStyles = makeStyles(theme => ({
+  archiveBrowser: {
+    marginTop: theme.spacing(2)
+  },
+  error: {
+    marginTop: theme.spacing(2)
+  },
+  downloadFab: {
+    zIndex: 1,
+    right: 32,
+    bottom: 32,
+    position: 'fixed !important'
   }
-
-  static styles = theme => ({
-    archiveBrowser: {
-      marginTop: theme.spacing(2)
-    },
-    error: {
-      marginTop: theme.spacing(2)
-    },
-    downloadFab: {
-      zIndex: 1,
-      right: 32,
-      bottom: 32,
-      position: 'fixed !important'
-    }
-  })
-
-  static defaultState = {
-    data: null,
-    doesNotExist: false
-  }
-
-  state = {
-    ...ArchiveEntryView.defaultState
-  }
-
-  constructor(props) {
-    super(props)
-    this.unmounted = false
-  }
-
-  componentWillUnmount() {
-    this.unmounted = true
-  }
-
-  componentDidMount() {
-    this.updateArchive()
-  }
-
-  componentDidUpdate(prevProps) {
-    if (prevProps.api !== this.props.api ||
-        prevProps.uploadId !== this.props.uploadId ||
-        prevProps.entryId !== this.props.entryId) {
-      this.setState({...ArchiveEntryView.defaultState})
-      this.updateArchive()
-    }
-  }
-
-  updateArchive() {
-    const {uploadId, entryId, api} = this.props
-    api.archive(uploadId, entryId).then(data => {
-      if (!this.unmounted) {
-        this.setState({data: data})
-      }
-    }).catch(error => {
-      if (!this.unmounted) {
-        this.setState({data: null})
-      }
-      if (error.name === 'DoesNotExist') {
-        this.setState({doesNotExist: true})
-      } else {
-        this.props.raiseError(error)
-      }
-    })
-  }
-
-  render() {
-    const { classes, uploadId, entryId } = this.props
-    const { data, doesNotExist } = this.state
-
-    if (doesNotExist) {
-      return (
-        <Page>
-          <Typography className={classes.error}>
-            No archive exists for this entry. Either the archive was not generated due
-            to parsing or other processing errors (check the log tab), or the entry it
-            self does not exist.
-          </Typography>
-        </Page>
-      )
-    }
-
-    return (
-      <Page width={'100%'} maxWidth={'undefined'}>
-        {
-          data && typeof data !== 'string'
-            ? <div className={classes.archiveBrowser}>
-              <ArchiveBrowser data={data} />
-            </div> : <div>{
-              data
-                ? <div>
-                  <Typography>Archive data is not valid JSON. Displaying plain text instead.</Typography>
-                  <Card>
-                    <CardContent>
-                      <pre>{data || ''}</pre>
-                    </CardContent>
-                  </Card>
-                </div>
-                : <Typography>loading ...</Typography>
-            }</div>
+}))
+
+export default function ArchiveEntryView(props) {
+  const classes = useStyles()
+  const {entryId} = props
+  const {api} = useApi()
+  const {raiseError} = useErrors()
+
+  const [data, setData] = useState(null)
+  const [doesNotExist, setDoesNotExist] = useState(false)
+
+  useEffect(() => {
+    api.get(`/entries/${entryId}/archive`)
+      .then(response => {
+        response.data.archive.processing_logs = undefined
+        setData(response.data.archive)
+      })
+      .catch(error => {
+        if (error.name === 'DoesNotExist') {
+          setDoesNotExist(true)
+        } else {
+          raiseError(error)
         }
+      })
+  }, [setData, setDoesNotExist, api, raiseError, entryId])
 
-        <Download
-          classes={{root: classes.downloadFab}} tooltip="download calculation archive"
-          component={Fab} className={classes.downloadFab} color="primary" size="medium"
-          url={`archive/${uploadId}/${entryId}`} fileName={`${entryId}.json`}
-        >
-          <DownloadIcon />
-        </Download>
+  if (doesNotExist) {
+    return (
+      <Page>
+        <Typography className={classes.error}>
+          No archive exists for this entry. Either the archive was not generated due
+          to parsing or other processing errors (check the log tab), or the entry it
+          self does not exist.
+        </Typography>
       </Page>
     )
   }
-}
 
-export default compose(
-  withApi(false, true),
-  withStyles(ArchiveEntryView.styles)
-)(ArchiveEntryView)
+  return (
+    <Page width={'100%'} maxWidth={'undefined'}>
+      {
+        data && typeof data !== 'string'
+          ? <div className={classes.archiveBrowser}>
+            <ArchiveBrowser data={data} />
+          </div> : <div>{
+            data
+              ? <div>
+                <Typography>Archive data is not valid JSON. Displaying plain text instead.</Typography>
+                <Card>
+                  <CardContent>
+                    <pre>{data || ''}</pre>
+                  </CardContent>
+                </Card>
+              </div>
+              : <Typography>loading ...</Typography>
+          }</div>
+      }
+
+      <Download
+        classes={{root: classes.downloadFab}} tooltip="download calculation archive"
+        component={Fab} className={classes.downloadFab} color="primary" size="medium"
+        url={`entries/${entryId}/archive/download`} fileName={`${entryId}.json`}
+      >
+        <DownloadIcon />
+      </Download>
+    </Page>
+  )
+}
+ArchiveEntryView.propTypes = {
+  entryId: PropTypes.string.isRequired
+}
diff --git a/gui/src/components/entry/ArchiveLogView.js b/gui/src/components/entry/ArchiveLogView.js
index 4f53367a37db4b26de56b5a1d949af1fea5282fa..ade07eeb85a535f9a41bfd6a53540d29bd13aebd 100644
--- a/gui/src/components/entry/ArchiveLogView.js
+++ b/gui/src/components/entry/ArchiveLogView.js
@@ -15,162 +15,122 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React from 'react'
+import React, { useEffect, useState } from 'react'
 import PropTypes from 'prop-types'
-import { withStyles, Fab, Typography, Accordion, AccordionSummary, AccordionDetails } from '@material-ui/core'
-import { compose } from 'recompose'
-import { withApi } from '../api'
-import Download from './Download'
-import DownloadIcon from '@material-ui/icons/CloudDownload'
+import { Typography, Accordion, AccordionSummary, AccordionDetails, makeStyles } from '@material-ui/core'
 import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
 import ReactJson from 'react-json-view'
 import { amber } from '@material-ui/core/colors'
 import { maxLogsToShow } from '../../config'
 import Page from '../Page'
-
-class LogEntryUnstyled extends React.Component {
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    entry: PropTypes.object.isRequired
+import { useErrors } from '../errors'
+import { useApi } from '../api'
+
+const useLogEntryStyles = makeStyles(theme => ({
+  warning: {
+    color: amber[700]
+  },
+  exception: {
+    overflowX: 'scroll',
+    margin: 0
   }
-
-  static styles = theme => ({
-    warning: {
-      color: amber[700]
-    },
-    exception: {
-      overflowX: 'scroll',
-      margin: 0
-    }
-  })
-
-  render() {
-    const { classes, entry } = this.props
-    const data = entry
-
-    const summaryProps = {}
-    if (data.level === 'ERROR' || data.level === 'CRITICAL') {
-      summaryProps.color = 'error'
-    } else if (data.level === 'WARNING') {
-      summaryProps.classes = {root: classes.warning}
-    }
-    return (
-      <Accordion>
-        <AccordionSummary expandIcon={<ExpandMoreIcon />}>
-          <Typography {...summaryProps}>{data.level}: {data.event} {(data.parser || data.normalizer) ? `(${data.parser || data.normalizer})` : ''}</Typography>
-        </AccordionSummary>
-        <AccordionDetails>
-          <ReactJson
-            src={data}
-            enableClipboard={false}
-            displayObjectSize={false} />
-        </AccordionDetails>
-        {data.exception && <AccordionDetails>
-          <pre className={classes.exception}>{data.exception}</pre>
-        </AccordionDetails>}
-      </Accordion>)
+}))
+
+const LogEntry = React.memo(function LogEntry(props) {
+  const classes = useLogEntryStyles()
+  const {entry} = props
+  const data = entry
+
+  const summaryProps = {}
+  if (data.level === 'ERROR' || data.level === 'CRITICAL') {
+    summaryProps.color = 'error'
+  } else if (data.level === 'WARNING') {
+    summaryProps.classes = {root: classes.warning}
   }
+  return (
+    <Accordion>
+      <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+        <Typography {...summaryProps}>{data.level}: {data.event} {(data.parser || data.normalizer) ? `(${data.parser || data.normalizer})` : ''}</Typography>
+      </AccordionSummary>
+      <AccordionDetails>
+        <ReactJson
+          src={data}
+          enableClipboard={false}
+          displayObjectSize={false} />
+      </AccordionDetails>
+      {data.exception && <AccordionDetails>
+        <pre className={classes.exception}>{data.exception}</pre>
+      </AccordionDetails>}
+    </Accordion>
+  )
+})
+LogEntry.propTypes = {
+  entry: PropTypes.object.isRequired
 }
 
-const LogEntry = withStyles(LogEntryUnstyled.styles)(LogEntryUnstyled)
-
-class ArchiveLogView extends React.Component {
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    api: PropTypes.object.isRequired,
-    raiseError: PropTypes.func.isRequired,
-    uploadId: PropTypes.string.isRequired,
-    entryId: PropTypes.string.isRequired
+const useStyles = makeStyles(theme => ({
+  moreLogs: {
+    marginTop: theme.spacing(2)
+  },
+  downloadFab: {
+    zIndex: 1,
+    right: 32,
+    bottom: 32,
+    position: 'fixed !important'
   }
-
-  static styles = theme => ({
-    moreLogs: {
-      marginTop: theme.spacing(2)
-    },
-    downloadFab: {
-      zIndex: 1,
-      right: 32,
-      bottom: 32,
-      position: 'fixed !important'
-    }
-  });
-
-  static defaultState = {
-    data: null,
-    doesNotExist: false
-  }
-
-  state = {...ArchiveLogView.defaultState}
-
-  componentDidMount() {
-    this.update()
-  }
-
-  componentDidUpdate(prevProps) {
-    if (prevProps.api !== this.props.api ||
-        prevProps.uploadId !== this.props.uploadId ||
-        prevProps.entryId !== this.props.entryId) {
-      this.setState({...ArchiveLogView.defaultState})
-      this.update()
-    }
-  }
-
-  update() {
-    const {uploadId, entryId, api, raiseError} = this.props
-    api.calcProcLog(uploadId, entryId).then(data => {
-      this.setState({data: data})
-    }).catch(error => {
-      this.setState({data: null})
-      if (error.name === 'DoesNotExist') {
-        this.setState({doesNotExist: true})
-      } else {
-        raiseError(error)
-      }
-    })
-  }
-
-  render() {
-    const { classes, uploadId, entryId } = this.props
-    const { data, doesNotExist } = this.state
-
-    if (doesNotExist) {
-      return (
-        <Page>
-          <Typography>
-            No archive log does exist for this entry. Most likely the entry itself does not
-            exist.
-          </Typography>
-        </Page>
-      )
-    }
-
-    let content = 'loading ...'
-    if (data) {
-      content = <div>
-        {data.slice(0, maxLogsToShow).map((entry, i) => <LogEntry key={i} entry={entry}/>)}
-        {data.length > maxLogsToShow && <Typography classes={{root: classes.moreLogs}}>
-          There are {data.length - maxLogsToShow} more log entries. Download the log to see all of them.
-        </Typography>}
-      </div>
-    }
-
+}))
+
+export default function ArchiveLogView(props) {
+  const classes = useStyles()
+  const {entryId} = props
+  const {api} = useApi()
+  const {raiseError} = useErrors()
+
+  const [data, setData] = useState(null)
+  const [doesNotExist, setDoesNotExist] = useState(false)
+
+  useEffect(() => {
+    api.post(`/entries/${entryId}/archive/query`, {required: {processing_logs: '*'}})
+      .then(response => {
+        const data = response.data.archive.processing_logs || []
+        setData(data)
+      })
+      .catch(error => {
+        if (error.name === 'DoesNotExist') {
+          setDoesNotExist(true)
+        } else {
+          raiseError(error)
+        }
+      })
+  }, [setData, setDoesNotExist, api, raiseError, entryId])
+
+  if (doesNotExist) {
     return (
-      <Page limitedWidth>
-        {content}
-        <Download
-          classes={{root: classes.downloadFab}} tooltip="download logfile"
-          component={Fab} className={classes.downloadFab} size="medium"
-          color="primary"
-          url={`archive/logs/${uploadId}/${entryId}`} fileName={`${entryId}.log`}
-        >
-          <DownloadIcon />
-        </Download>
+      <Page>
+        <Typography>
+          No archive log does exist for this entry. Most likely the entry itself does not
+          exist.
+        </Typography>
       </Page>
     )
   }
-}
 
-export default compose(
-  withApi(false, true),
-  withStyles(ArchiveLogView.styles)
-)(ArchiveLogView)
+  let content = 'loading ...'
+  if (data) {
+    content = <div>
+      {data.slice(0, maxLogsToShow).map((entry, i) => <LogEntry key={i} entry={entry}/>)}
+      {data.length > maxLogsToShow && <Typography classes={{root: classes.moreLogs}}>
+        There are {data.length - maxLogsToShow} more log entries. Download the log to see all of them.
+      </Typography>}
+    </div>
+  }
+
+  return (
+    <Page limitedWidth>
+      {content}
+    </Page>
+  )
+}
+ArchiveLogView.propTypes = {
+  entryId: PropTypes.string.isRequired
+}
diff --git a/gui/src/components/entry/Download.js b/gui/src/components/entry/Download.js
index fd8f2eebc1588aaf6465202fef13d0c2e798fc04..055ae73ce64710d176f2923653b4a0a373bed37a 100644
--- a/gui/src/components/entry/Download.js
+++ b/gui/src/components/entry/Download.js
@@ -15,88 +15,77 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React from 'react'
+import React, { useState } from 'react'
 import PropTypes from 'prop-types'
 import FileSaver from 'file-saver'
-import { withApi } from '../api'
-import { compose } from 'recompose'
-import { withErrors } from '../errors'
+import { useErrors } from '../errors'
 import { apiBase } from '../../config'
-import { withStyles, Tooltip } from '@material-ui/core'
+import { makeStyles, Tooltip } from '@material-ui/core'
+import { useApi } from '../api'
 
-class Download extends React.Component {
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    fileName: PropTypes.string,
-    url: PropTypes.string,
-    component: PropTypes.any,
-    children: PropTypes.oneOfType([
-      PropTypes.arrayOf(PropTypes.node),
-      PropTypes.node
-    ]).isRequired,
-    api: PropTypes.object.isRequired,
-    user: PropTypes.object,
-    disabled: PropTypes.bool,
-    raiseError: PropTypes.func.isRequired,
-    tooltip: PropTypes.string,
-    color: PropTypes.string,
-    size: PropTypes.string
-  }
+const useStyles = makeStyles(theme => ({
+  root: {}
+}))
 
-  static styles = theme => ({
-    root: {}
-  })
+const Download = React.memo(function Download(props) {
+  const classes = useStyles(props)
+  const {url, fileName, component, children, disabled, color, size, tooltip} = props
+  const {api, user} = useApi()
+  const {raiseError} = useErrors()
 
-  state = {
-    preparingDownload: false
-  }
+  const [preparingDownload, setPreparingDownload] = useState(false)
 
-  async onDownloadClicked() {
-    const {url, api, user, fileName, raiseError} = this.props
-    let fullUrl = `${apiBase}/${url}`
+  const handleClick = () => {
+    let fullUrl = `${apiBase}/v1/${url}`
     let downloadUrl = fullUrl
     if (user) {
-      api.getSignatureToken()
-        .catch(error => {
-          this.setState({preparingDownload: false})
-          raiseError(error)
-        })
-        .then(result => {
+      setPreparingDownload(true)
+      api.get('/auth/signature_token')
+        .then(response => {
           if (fullUrl.startsWith('/')) {
             fullUrl = `${window.location.origin}${fullUrl}`
           }
           const downloadUrl = new URL(fullUrl)
-          downloadUrl.searchParams.append('signature_token', result)
+          downloadUrl.searchParams.append('signature_token', response.signature_token)
           FileSaver.saveAs(downloadUrl.href, fileName)
-          this.setState({preparingDownload: false})
         })
+        .catch(raiseError)
+        .finally(() => setPreparingDownload(false))
     } else {
       FileSaver.saveAs(downloadUrl, fileName)
-      this.setState({preparingDownload: false})
     }
   }
 
-  render() {
-    const {classes, component, children, disabled, color, size, tooltip} = this.props
-    const {preparingDownload} = this.state
+  const Component = component
 
-    const Component = component
+  const button = (
+    <Component
+      className={classes.root}
+      disabled={disabled || preparingDownload} color={color} size={size}
+      onClick={handleClick}
+    >
+      {children}
+    </Component>
+  )
 
-    const button = (
-      <Component className={classes.root}
-        disabled={disabled || preparingDownload} color={color} size={size}
-        onClick={() => this.onDownloadClicked()}
-      >
-        {children}
-      </Component>
-    )
-
-    if (tooltip && !disabled && !preparingDownload) {
-      return <Tooltip title={tooltip}>{button}</Tooltip>
-    } else {
-      return button
-    }
+  if (tooltip && !disabled && !preparingDownload) {
+    return <Tooltip title={tooltip}>{button}</Tooltip>
+  } else {
+    return button
   }
+})
+Download.propTypes = {
+  fileName: PropTypes.string,
+  url: PropTypes.string,
+  component: PropTypes.any,
+  children: PropTypes.oneOfType([
+    PropTypes.arrayOf(PropTypes.node),
+    PropTypes.node
+  ]).isRequired,
+  disabled: PropTypes.bool,
+  tooltip: PropTypes.string,
+  color: PropTypes.string,
+  size: PropTypes.string
 }
 
-export default compose(withApi(false), withErrors, withStyles(Download.styles))(Download)
+export default Download
diff --git a/gui/src/components/entry/EntryPage.js b/gui/src/components/entry/EntryPage.js
index 4e53a30cfa61b156004d000d0fc4a34d423d8e89..9d9ae0020cc829422be8c770d733b0b865a6b361 100644
--- a/gui/src/components/entry/EntryPage.js
+++ b/gui/src/components/entry/EntryPage.js
@@ -40,8 +40,8 @@ The *log* tab will show you a log of the entry's processing.
 `
 
 const TabRoutes = React.memo(function Routes({match}) {
-  const {params: {uploadId, entryId, tab = 'overview'}} = match
-  const props = {entryId: entryId, uploadId: uploadId}
+  const {params: {entryId, tab = 'overview'}} = match
+  const props = {entryId: entryId}
 
   if (!entryId) {
     return ''
diff --git a/gui/src/components/entry/EntryQuery.js b/gui/src/components/entry/EntryQuery.js
index 6ad45e378f10d570632e951a2ce1252fa1f3cfab..b26aca2d5df6d73c5bb238ea69fe40c89f87f5ff 100644
--- a/gui/src/components/entry/EntryQuery.js
+++ b/gui/src/components/entry/EntryQuery.js
@@ -15,99 +15,65 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React from 'react'
-import PropTypes from 'prop-types'
-import { withStyles, Typography, Link } from '@material-ui/core'
-import { compose } from 'recompose'
-import { withApi, DoesNotExist } from '../api'
-import { withRouter } from 'react-router'
+import React, { useEffect, useMemo, useState } from 'react'
+import { Typography, Link, makeStyles } from '@material-ui/core'
+import { useHistory, useLocation } from 'react-router'
 import qs from 'qs'
+import { useApi } from '../api'
+import { useErrors } from '../errors'
+import { getUrl } from '../nav/Routes'
 
-class EntryQuery extends React.Component {
-  static styles = theme => ({
-    root: {
-      padding: theme.spacing(3)
-    }
-  })
-
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    api: PropTypes.object.isRequired,
-    history: PropTypes.object.isRequired,
-    location: PropTypes.object.isRequired,
-    raiseError: PropTypes.func.isRequired
+const useStyles = makeStyles(theme => ({
+  root: {
+    padding: theme.spacing(3)
   }
+}))
 
-  static defaultState = {
-    doesNotExist: false,
-    queryParams: null
-  }
+export default function EntryQuery(props) {
+  const classes = useStyles()
+  const history = useHistory()
+  const location = useLocation()
+  const {api} = useApi()
+  const {raiseError} = useErrors()
 
-  state = {...EntryQuery.defaultState}
+  const [doesNotExist, setDoesNotExist] = useState(false)
 
-  update() {
-    const { api, history, location } = this.props
+  const queryParams = useMemo(() => qs.parse(location.search.substring(1)), [location])
 
-    let queryParams = null
-    if (location && location.search) {
-      queryParams = qs.parse(location.search.substring(1))
-    }
-    api.search({...queryParams}).then(data => {
-      if (data.results && data.results.length > 0) {
-        const { calc_id, upload_id } = data.results[0]
-        history.push(`/entry/id/${upload_id}/${calc_id}`)
-      } else {
-        throw new DoesNotExist()
-      }
-    }).catch(error => {
-      if (error.name === 'DoesNotExist') {
-        this.setState({doesNotExist: true, queryParams: queryParams})
-      } else {
-        this.props.raiseError(error)
-      }
-    })
-  }
+  useEffect(() => {
+    api.get('/entries', {...queryParams})
+      .then(response => {
+        if (response.pagination.total > 0) {
+          const {entry_id, upload_id} = response.data[0]
+          history.push(getUrl(`entry/id/${upload_id}/${entry_id}`))
+        } else {
+          setDoesNotExist(true)
+        }
+      })
+      .catch(raiseError)
+  }, [history, api, raiseError, setDoesNotExist, queryParams])
 
-  componentDidMount() {
-    this.update()
-  }
+  let message = 'loading ...'
 
-  componentDidUpdate(prevProps) {
-    if (prevProps.location.search !== this.props.location.search || prevProps.api !== this.props.api) {
-      this.setState({...EntryQuery.defaultState})
-      this.update()
+  if (doesNotExist) {
+    if (queryParams && queryParams['external_id'] && queryParams['external_id'].startsWith('mp-')) {
+      message = <React.Fragment>
+        This particular calculation <Link href={`https://materialsproject.org/tasks/${queryParams['external_id']}#`}>
+          {queryParams['external_id']}
+        </Link> has not yet been provided to NOMAD by the Materials Project.
+      </React.Fragment>
+    } else if (api.isLoggedIn) {
+      message = `
+          This URL points to an entry that either does not exist, or that you are not
+          authorized to see.`
+    } else {
+      message = `
+          This URL points to an entry that either does not exist, or that is not
+          publically visibile. Please login; you might be authorized to view it.`
     }
   }
 
-  render() {
-    const { classes, api } = this.props
-    const { doesNotExist, queryParams } = this.state
-
-    let message = 'loading ...'
-
-    if (doesNotExist) {
-      console.log(queryParams)
-      if (queryParams && queryParams['external_id'] && queryParams['external_id'].startsWith('mp-')) {
-        message = <React.Fragment>
-          This particular calculation <Link href={`https://materialsproject.org/tasks/${queryParams['external_id']}#`}>
-            {queryParams['external_id']}
-          </Link> has not yet been provided to NOMAD by the Materials Project.
-        </React.Fragment>
-      } else if (api.isLoggedIn) {
-        message = `
-            This URL points to an entry that either does not exist, or that you are not
-            authorized to see.`
-      } else {
-        message = `
-            This URL points to an entry that either does not exist, or that is not
-            publically visibile. Please login; you might be authorized to view it.`
-      }
-    }
-
-    return (
-      <Typography className={classes.root}>{message}</Typography>
-    )
-  }
+  return (
+    <Typography className={classes.root}>{message}</Typography>
+  )
 }
-
-export default compose(withRouter, withApi(false), withStyles(EntryQuery.styles))(EntryQuery)
diff --git a/gui/src/components/entry/OverviewView.js b/gui/src/components/entry/OverviewView.js
index 963b48d155a777398ee6031d0184ea080a76352d..6057038a8fd9418c33d2c0bdb87a30d224f8b0a5 100644
--- a/gui/src/components/entry/OverviewView.js
+++ b/gui/src/components/entry/OverviewView.js
@@ -17,7 +17,7 @@
  */
 import React, { useState, useEffect } from 'react'
 import PropTypes from 'prop-types'
-import { useApi } from '../apiV1'
+import { useApi } from '../api'
 import { useErrors } from '../errors'
 import { Typography, makeStyles, Box, Grid, Link, Divider } from '@material-ui/core'
 import { ApiDialog } from '../ApiDialogButton'
@@ -78,7 +78,7 @@ const useStyles = makeStyles(theme => ({
 /**
  * Shows an informative overview about the selected entry.
  */
-const OverviewView = React.memo(function OverviewView({uploadId, entryId, ...moreProps}) {
+const OverviewView = React.memo(function OverviewView({entryId, ...moreProps}) {
   const { raiseError } = useErrors()
   const [entry, setEntry] = useState(null)
   const [exists, setExists] = useState(true)
@@ -214,7 +214,6 @@ const OverviewView = React.memo(function OverviewView({uploadId, entryId, ...mor
 })
 
 OverviewView.propTypes = {
-  uploadId: PropTypes.string.isRequired,
   entryId: PropTypes.string.isRequired
 }
 
diff --git a/gui/src/components/entry/OverviewView.spec.js b/gui/src/components/entry/OverviewView.spec.js
index dca55f08c56b021bef75b8dea84b1bfb86e3495a..9cc0a448058f01dfefe20dbef084687bc1666f06 100644
--- a/gui/src/components/entry/OverviewView.spec.js
+++ b/gui/src/components/entry/OverviewView.spec.js
@@ -25,10 +25,10 @@ import '@testing-library/jest-dom/extend-expect'
 import {
   repoDftBulk
 } from '../../../tests/DFTBulk'
-import { useApi } from '../apiV1'
+import { useApi } from '../api'
 import OverviewView from './OverviewView'
 
-jest.mock('../apiV1')
+jest.mock('../api')
 
 beforeAll(() => {
   useApi.mockReturnValue({
@@ -42,7 +42,7 @@ beforeAll(() => {
   })
 })
 
-afterAll(() => jest.unmock('../apiV1'))
+afterAll(() => jest.unmock('../api'))
 
 function expectPlotButtons(plot) {
   expect(within(plot).getByRole('button', {name: 'Reset view'})).toBeInTheDocument()
diff --git a/gui/src/components/entry/RawFileView.js b/gui/src/components/entry/RawFileView.js
index 8184ef825920e9869b5507597e6d199b77b4fb6b..a450afd59159c175d5d973ce3ea9a284b62787a8 100644
--- a/gui/src/components/entry/RawFileView.js
+++ b/gui/src/components/entry/RawFileView.js
@@ -19,7 +19,7 @@ import React, { useContext, useState, useEffect } from 'react'
 import PropTypes from 'prop-types'
 import { Typography, makeStyles, Card, CardHeader, CardContent } from '@material-ui/core'
 import { errorContext } from '../errors'
-import { useApi } from '../apiV1'
+import { useApi } from '../api'
 import RawFiles from './RawFiles'
 import Page from '../Page'
 
@@ -29,7 +29,7 @@ const useStyles = makeStyles(theme => ({
   }
 }))
 
-export default function RawFileView({uploadId, entryId}) {
+export default function RawFileView({entryId}) {
   const classes = useStyles()
   const {raiseError} = useContext(errorContext)
   const [state, setState] = useState({entryData: null, doesNotExist: false})
@@ -37,7 +37,7 @@ export default function RawFileView({uploadId, entryId}) {
 
   useEffect(() => {
     setState({entryData: null, doesNotExist: false})
-  }, [setState, uploadId, entryId])
+  }, [setState, entryId])
 
   useEffect(() => {
     api.entry(entryId).then(entry => {
@@ -52,7 +52,7 @@ export default function RawFileView({uploadId, entryId}) {
     })
   }, [api, raiseError, entryId, setState])
 
-  const entryData = state.entryData || {uploadId: uploadId, entryId: entryId}
+  const entryData = state.entryData || {entryId: entryId}
 
   if (state.doesNotExist) {
     return <Page>
@@ -67,7 +67,7 @@ export default function RawFileView({uploadId, entryId}) {
       <Card className={classes.root}>
         <CardHeader title="Raw files" />
         <CardContent>
-          <RawFiles data={entryData} entryId={entryId} uploadId={uploadId} />
+          <RawFiles data={entryData} entryId={entryId} />
         </CardContent>
       </Card>
     </Page>
@@ -75,6 +75,5 @@ export default function RawFileView({uploadId, entryId}) {
 }
 
 RawFileView.propTypes = {
-  uploadId: PropTypes.string.isRequired,
   entryId: PropTypes.string.isRequired
 }
diff --git a/gui/src/components/entry/RawFiles.js b/gui/src/components/entry/RawFiles.js
index c6c74ca2aaaa664f4ca1837a7e9d7f0820c42312..39f663006ccbc67a3bb4e88bb3a6f1f43046578c 100644
--- a/gui/src/components/entry/RawFiles.js
+++ b/gui/src/components/entry/RawFiles.js
@@ -30,14 +30,13 @@ import {
   Tooltip
 } from '@material-ui/core'
 import DownloadIcon from '@material-ui/icons/CloudDownload'
-import { withApi } from '../api'
-import { compose } from 'recompose'
 import Download from './Download'
 import ReloadIcon from '@material-ui/icons/Cached'
 import ViewIcon from '@material-ui/icons/Search'
 import InfiniteScroll from 'react-infinite-scroller'
 import { ScrollContext } from '../nav/Navigation'
-import { useApi } from '../apiV1'
+import { useApi } from '../api'
+import { useErrors } from '../errors'
 
 const useStyles = makeStyles(theme => ({
   root: {},
@@ -92,7 +91,7 @@ function label(file) {
   return file.split('/').reverse()[0]
 }
 
-function RawFiles({api, user, data, uploadId, entryId, raiseError}) {
+export default function RawFiles({data, entryId}) {
   const theme = useTheme()
   const classes = useStyles(theme)
   const [selectedFiles, setSelectedFiles] = useState([])
@@ -101,7 +100,8 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) {
   const [files, setFiles] = useState(null)
   const [loading, setLoading] = useState(false)
   const [doesNotExist, setDoesNotExist] = useState(false)
-  const {api: apiv1} = useApi()
+  const {raiseError} = useErrors()
+  const {api, user} = useApi()
 
   useEffect(() => {
     setSelectedFiles([])
@@ -110,16 +110,16 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) {
     setFiles(null)
     setLoading(false)
     setDoesNotExist(false)
-  }, [api, uploadId, entryId])
+  }, [api, entryId])
 
   const update = useCallback(() => {
     // this might accidentally happen, when the user logs out and the ids aren't
     // necessarily available anymore, but the component is still mounted
-    if (!uploadId || !entryId) {
+    if (!entryId) {
       return
     }
 
-    apiv1.getRawFileListFromCalc(entryId).then(data => {
+    api.getRawFileListFromCalc(entryId).then(data => {
       const files = data.data.files.map(file => file.path)
       if (files.length > 500) {
         raiseError('There are more than 500 files in this entry. We can only show the first 500.')
@@ -136,7 +136,7 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) {
       }
     })
     setLoading(true)
-  }, [uploadId, entryId, raiseError, apiv1])
+  }, [entryId, raiseError, api])
 
   const handleSelectFile = useCallback((file) => {
     setSelectedFiles(prevState => {
@@ -153,10 +153,13 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) {
   const handleFileClicked = useCallback(file => {
     setShownFile(file)
     setFileContents(null)
-    api.getRawFile(uploadId, entryId, file.split('/').reverse()[0], {length: 16 * 1024})
-      .then(contents => setFileContents(contents))
+    api.get(`/entries/${entryId}/raw/download/${file.split('/').reverse()[0]}`, {length: 16 * 1024, decompress: true})
+      .then(contents => setFileContents({
+        hasMore: true,
+        contents: contents
+      }))
       .catch(raiseError)
-  }, [api, raiseError, uploadId, entryId])
+  }, [api, raiseError, entryId])
 
   const handleLoadMore = useCallback((page) => {
     // The infinite scroll component has the issue if calling load more whenever it
@@ -168,15 +171,15 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) {
     const initialEntryId = entryId
 
     if (fileContents.contents.length < (page + 1) * 16 * 1024) {
-      api.getRawFile(uploadId, entryId, shownFile.split('/').reverse()[0], {offset: page * 16 * 1024, length: 16 * 1024})
+      api.get(`/entries/${entryId}/raw/download/${shownFile.split('/').reverse()[0]}`, {offset: page * 16 * 1024, length: 16 * 1024, decompress: true})
         .then(contents => {
           // The back-button navigation might cause a scroll event, might cause to loadmore,
           // will set this state, after navigation back to this page, but potentially
           // different entry.
           if (initialEntryId === entryId) {
             setFileContents({
-              ...contents,
-              contents: ((fileContents && fileContents.contents) || '') + contents.contents
+              hasMore: contents.length > 0,
+              contents: ((fileContents && fileContents.contents) || '') + contents
             })
           }
         })
@@ -186,7 +189,7 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) {
           raiseError(error)
         })
     }
-  }, [api, uploadId, entryId, shownFile, fileContents, raiseError])
+  }, [api, entryId, shownFile, fileContents, raiseError])
 
   const filterPotcar = useCallback((file) => {
     if (file.substring(file.lastIndexOf('/')).includes('POTCAR') && !file.endsWith('.stripped')) {
@@ -207,18 +210,19 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) {
     </Typography>
   }
 
+  const file = path => path.substring(path.lastIndexOf('/') + 1)
+
   let downloadUrl
   if (selectedFiles.length === 1) {
     // download the individual file
-    downloadUrl = `raw/${uploadId}/${selectedFiles[0]}`
+    downloadUrl = `entries/${entryId}/raw/download/${file(selectedFiles[0])}`
   } else if (selectedFiles.length === availableFiles.length) {
     // use an endpoint that downloads all files of the calc
-    downloadUrl = `raw/calc/${uploadId}/${entryId}/*?strip=true`
+    downloadUrl = `entries/${entryId}/raw/download`
   } else if (selectedFiles.length > 0) {
-    // use a prefix to shorten the url
-    const prefix = selectedFiles[0].substring(0, selectedFiles[0].lastIndexOf('/'))
-    const files = selectedFiles.map(path => path.substring(path.lastIndexOf('/') + 1)).join(',')
-    downloadUrl = `raw/${uploadId}?files=${encodeURIComponent(files)}&prefix=${prefix}&strip=true`
+    // download specific files
+    const query = selectedFiles.map(file).map(f => `include_files=${encodeURIComponent(f)}`).join('&')
+    downloadUrl = `entries/${entryId}/raw/download?${query}`
   }
 
   return (
@@ -310,16 +314,7 @@ function RawFiles({api, user, data, uploadId, entryId, raiseError}) {
     </div>
   )
 }
-
 RawFiles.propTypes = {
-  uploadId: PropTypes.string.isRequired,
   entryId: PropTypes.string.isRequired,
-  data: PropTypes.object,
-  api: PropTypes.object.isRequired,
-  user: PropTypes.object,
-  raiseError: PropTypes.func.isRequired
+  data: PropTypes.object
 }
-
-export default compose(
-  withApi(false, true)
-)(RawFiles)
diff --git a/gui/src/components/entry/ResolvePID.js b/gui/src/components/entry/ResolvePID.js
index b2ab1b782156e0e12c4f49f68709c191851e879c..f0983c2d7d145cb0cbc7478355109ecb5ca7312c 100644
--- a/gui/src/components/entry/ResolvePID.js
+++ b/gui/src/components/entry/ResolvePID.js
@@ -15,87 +15,63 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React from 'react'
-import PropTypes from 'prop-types'
-import { withStyles, Typography } from '@material-ui/core'
-import { compose } from 'recompose'
-import { withApi } from '../api'
-import { withRouter, matchPath } from 'react-router'
+import React, { useEffect, useState } from 'react'
+import { Typography, makeStyles } from '@material-ui/core'
+import { matchPath, useLocation, useRouteMatch, useHistory } from 'react-router'
+import {useApi} from '../api'
+import {useErrors} from '../errors'
+import { getUrl } from '../nav/Routes'
 
-class ResolvePID extends React.Component {
-  static styles = theme => ({
-    root: {
-      padding: theme.spacing(3)
-    }
-  })
-
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    location: PropTypes.object.isRequired,
-    match: PropTypes.object.isRequired,
-    history: PropTypes.object.isRequired,
-    api: PropTypes.object.isRequired,
-    raiseError: PropTypes.func.isRequired
+const useStyles = makeStyles(theme => ({
+  root: {
+    padding: theme.spacing(3)
   }
+}))
 
-  static defaultState = {
-    doesNotExist: false
-  }
+export default function ResolvePID() {
+  const classes = useStyles()
+  const {api, user} = useApi()
+  const {raiseError} = useErrors()
+  const history = useHistory()
+  const location = useLocation()
+  const match = useRouteMatch()
 
-  state = {...ResolvePID.defaultState}
+  const [doesNotExist, setDoesNotExist] = useState(false)
 
-  update() {
-    const { location, match, api, history } = this.props
+  useEffect(() => {
     const pidMatch = matchPath(location.pathname, {
       path: `${match.path}/:pid/:handle?`
     })
     let { pid, handle } = pidMatch.params
     pid = handle ? pid + '/' + handle : pid
 
-    api.resolvePid(pid).then(entry => {
-      history.push(`/entry/id/${entry.upload_id}/${entry.calc_id}`)
-    }).catch(error => {
-      if (error.name === 'DoesNotExist') {
-        this.setState({doesNotExist: true})
-      } else {
-        this.props.raiseError(error)
-      }
-    })
-  }
+    api.post('/entries/query', {owner: 'all', query: {pid: pid}})
+      .then(response => {
+        if (response.pagination.total >= 1) {
+          const entry = response.data[0]
+          history.push(getUrl(`entry/id/${entry.upload_id}/${entry.calc_id}`, location))
+        } else {
+          setDoesNotExist(true)
+        }
+      })
+      .catch(raiseError)
+  }, [setDoesNotExist, history, location, match, api, raiseError])
 
-  componentDidMount() {
-    this.update()
-  }
+  let message = 'loading ...'
 
-  componentDidUpdate(prevProps) {
-    if (prevProps.location.pathname !== this.props.location.pathname || prevProps.api !== this.props.api) {
-      this.setState({...ResolvePID.defaultState})
-      this.update()
+  if (doesNotExist) {
+    if (user) {
+      message = `
+          This URL points to an entry that either does not exist, or that you are not
+          authorized to see.`
+    } else {
+      message = `
+          This URL points to an entry that either does not exist, or that is not
+          publically visibile. Please login; you might be authorized to view it.`
     }
   }
 
-  render() {
-    const { classes, api } = this.props
-    const { doesNotExist } = this.state
-
-    let message = 'loading ...'
-
-    if (doesNotExist) {
-      if (api.isLoggedIn) {
-        message = `
-            This URL points to an entry that either does not exist, or that you are not
-            authorized to see.`
-      } else {
-        message = `
-            This URL points to an entry that either does not exist, or that is not
-            publically visibile. Please login; you might be authorized to view it.`
-      }
-    }
-
-    return (
-      <Typography className={classes.root}>{message}</Typography>
-    )
-  }
+  return (
+    <Typography className={classes.root}>{message}</Typography>
+  )
 }
-
-export default compose(withRouter, withApi(false), withStyles(ResolvePID.styles))(ResolvePID)
diff --git a/gui/src/components/entry/properties/MaterialCard.js b/gui/src/components/entry/properties/MaterialCard.js
index a0d8ccc5d5a329292960d5c6a7a4c2839ea28146..de774e9e0f1609b51f0068b398587494655420c9 100644
--- a/gui/src/components/entry/properties/MaterialCard.js
+++ b/gui/src/components/entry/properties/MaterialCard.js
@@ -63,7 +63,7 @@ export default function MaterialCard({entryMetadata, archive}) {
   const materialId = entryMetadata.results?.material?.material_id
 
   let structures = null
-  if (archive) {
+  if (archive?.results) {
     structures = []
     const structureKinds = ['original', 'conventional', 'primitive']
     const archiveStructures = archive.results.properties?.structures
diff --git a/gui/src/components/material/MaterialPage.js b/gui/src/components/material/MaterialPage.js
deleted file mode 100644
index f29a3727c7270e20f37bf1d8bbfe50b043de47d1..0000000000000000000000000000000000000000
--- a/gui/src/components/material/MaterialPage.js
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright The NOMAD Authors.
- *
- * This file is part of NOMAD. See https://nomad-lab.eu for further info.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import React, { useEffect, useState } from 'react'
-import PropTypes from 'prop-types'
-import { Box } from '@material-ui/core'
-import ElectronicStructureOverview from './ElectronicStructureOverview'
-import { useRouteMatch, Route } from 'react-router-dom'
-import { withApi } from '../api'
-
-const MaterialPageContent = withApi(false, true)(({fixed, api, materialId, raiseError}) => {
-  const props = fixed ? {maxWidth: 1200} : {}
-  const [data, setData] = useState()
-
-  // Load the data parallelly from API on first render
-  useEffect(() => {
-    Promise.all([
-      api.encyclopediaBasic(materialId),
-      api.encyclopediaCalculations(materialId)
-    ]).then((results) => {
-      setData({
-        basic: results[0],
-        calculations: results[1]
-      })
-    }).catch(error => {
-      if (error.name === 'DoesNotExist') {
-        raiseError(error)
-      }
-    })
-  }, [api, materialId, raiseError])
-
-  return <Box padding={3} margin="auto" {...props}>
-    <ElectronicStructureOverview data={data}></ElectronicStructureOverview>
-  </Box>
-})
-MaterialPageContent.propTypes = ({
-  materialId: PropTypes.string,
-  api: PropTypes.func,
-  raiseError: PropTypes.func,
-  fixed: PropTypes.bool
-})
-function MaterialPage() {
-  const { path } = useRouteMatch()
-
-  return (
-    <Route
-      path={`${path}/:materialId?/:tab?`}
-      render={({match: {params: {materialId, tab = 'overview'}}}) => {
-        if (materialId) {
-          return (
-            <React.Fragment>
-              <MaterialPageContent fixed={true} materialId={materialId}>
-              </MaterialPageContent>
-            </React.Fragment>
-          )
-        } else {
-          return ''
-        }
-      }}
-    />
-  )
-}
-
-export default MaterialPage
diff --git a/gui/src/components/nav/AppBar.js b/gui/src/components/nav/AppBar.js
index 913344dee7b8c4d05d313bafb046b3e672045775..f075e12714f4377bf0deeadaea1701af2e284489 100644
--- a/gui/src/components/nav/AppBar.js
+++ b/gui/src/components/nav/AppBar.js
@@ -28,7 +28,7 @@ import {
 import LoginLogout from '../LoginLogout'
 import UnitSelector from '../UnitSelector'
 import MainMenu from './MainMenu'
-import { useLoading } from '../apiV1'
+import { useLoading } from '../api'
 import { guiBase } from '../../config'
 import Breadcrumbs from './Breadcrumbs'
 
diff --git a/gui/src/components/nav/Navigation.js b/gui/src/components/nav/Navigation.js
index 989b491d9d9627dc81ff19f02629b76d27e72dc1..21ce0fe6f8b4cfcf9a19e5ff849c2a7addb6c037 100644
--- a/gui/src/components/nav/Navigation.js
+++ b/gui/src/components/nav/Navigation.js
@@ -25,7 +25,6 @@ import { amber } from '@material-ui/core/colors'
 import AppBar, { appBarHeight } from './AppBar'
 import { version } from '../../config'
 import { Routes } from './Routes'
-import { withApi } from '../api'
 import { serviceWorkerUpdateHandlerRef } from '../../serviceWorker'
 import { ErrorBoundary } from '../errors'
 
@@ -134,7 +133,7 @@ const useStyles = makeStyles(theme => ({
   }
 }))
 
-function Navigation() {
+export default function Navigation() {
   const classes = useStyles()
   const scrollParentRef = useRef(null)
 
@@ -155,5 +154,3 @@ function Navigation() {
     </div>
   )
 }
-
-export default withApi(false)(Navigation)
diff --git a/gui/src/components/nav/Routes.js b/gui/src/components/nav/Routes.js
index be8d7c78f515db6a2c2cf166286e0e33eaebce38..11dd7ed32512ee2021a619cfd07ab7cc66bcd4d3 100644
--- a/gui/src/components/nav/Routes.js
+++ b/gui/src/components/nav/Routes.js
@@ -373,7 +373,6 @@ export function getUrl(path, location) {
 
   return `${url}/${path}`
 }
-routes.forEach(route => addRoute(route, ''))
 
 export const RouteButton = React.forwardRef(function RouteButton(props, ref) {
   const {component, path, ...moreProps} = props
diff --git a/gui/src/components/search/EntryList.js b/gui/src/components/search/EntryList.js
index 9368a9c385cd5b5352c63a96f8abc767a6a911f4..4c1bcd88215cc750319211c1b23e3defb0a0d545 100644
--- a/gui/src/components/search/EntryList.js
+++ b/gui/src/components/search/EntryList.js
@@ -15,7 +15,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import React, { useContext } from 'react'
+import React from 'react'
 import PropTypes from 'prop-types'
 import { withStyles, Link, Typography, Tooltip, IconButton, TablePagination } from '@material-ui/core'
 import { compose } from 'recompose'
@@ -31,17 +31,17 @@ import UploaderIcon from '@material-ui/icons/AccountCircle'
 import SharedIcon from '@material-ui/icons/SupervisedUserCircle'
 import PrivateIcon from '@material-ui/icons/VisibilityOff'
 import { domainData } from '../domainData'
-import { apiContext, withApi } from '../api'
 import { authorList, nameList } from '../../utils'
 import EntryDetails from '../entry/EntryDetails'
 import { EntryButton } from '../nav/Routes'
+import { useApi, withApi } from '../api'
 
 export function Published(props) {
-  const api = useContext(apiContext)
+  const {user} = useApi()
   const {entry} = props
   if (entry.published) {
     if (entry.with_embargo) {
-      if (api.user && entry.uploader.user_id === api.user.sub) {
+      if (user && entry.uploader.user_id === user.sub) {
         if (entry.owners.length === 1) {
           return <Tooltip title="published with embargo by you and only accessible by you">
             <UploaderIcon color="error" />
@@ -51,12 +51,12 @@ export function Published(props) {
             <SharedIcon color="error" />
           </Tooltip>
         }
-      } else if (api.user && entry.owners.find(user => user.user_id === api.user.sub)) {
+      } else if (user && entry.owners.find(user => user.user_id === user.sub)) {
         return <Tooltip title="published with embargo and shared with you">
           <SharedIcon color="error" />
         </Tooltip>
       } else {
-        if (api.user) {
+        if (user) {
           return <Tooltip title="published with embargo and not accessible by you">
             <PrivateIcon color="error" />
           </Tooltip>
@@ -424,6 +424,6 @@ export class EntryListUnstyled extends React.Component {
   }
 }
 
-const EntryList = compose(withRouter, withApi(false, false), withStyles(EntryListUnstyled.styles))(EntryListUnstyled)
+const EntryList = withApi(compose(withRouter, withStyles(EntryListUnstyled.styles))(EntryListUnstyled))
 
 export default EntryList
diff --git a/gui/src/components/search/SearchBar.js b/gui/src/components/search/SearchBar.js
index 72f6a221db14a80262c76fe604f593fbb1452374..336860a5ca42c20fa237afc41ca091035d04ec11 100644
--- a/gui/src/components/search/SearchBar.js
+++ b/gui/src/components/search/SearchBar.js
@@ -32,7 +32,7 @@ import {
   Typography
 } from '@material-ui/core'
 import IconButton from '@material-ui/core/IconButton'
-import { useApi } from '../apiV1'
+import { useApi } from '../api'
 import { useUnits } from '../../units'
 import { isMetaNumber, isMetaTimestamp } from '../../utils'
 import {
diff --git a/gui/src/components/search/SearchContext.js b/gui/src/components/search/SearchContext.js
index af4c4fe1191d91869623e08d1bc12072434b9191..ba9eecdfee56a4198f786d9481d9b1696e502d3c 100644
--- a/gui/src/components/search/SearchContext.js
+++ b/gui/src/components/search/SearchContext.js
@@ -36,7 +36,7 @@ import {
 import qs from 'qs'
 import PropTypes from 'prop-types'
 import { useHistory } from 'react-router-dom'
-import { useApi } from '../apiV1'
+import { useApi } from '../api'
 import { setToArray, formatMeta, parseMeta } from '../../utils'
 import searchQuantities from '../../searchQuantities'
 import { Quantity } from '../../units'
diff --git a/gui/src/components/search/input/InputText.js b/gui/src/components/search/input/InputText.js
index 5c2b5a6b602cee68db20d5aa96bf29ee1a1f588b..6b82ed03dea4b235bdabf6acb446536cc3fa378d 100644
--- a/gui/src/components/search/input/InputText.js
+++ b/gui/src/components/search/input/InputText.js
@@ -29,7 +29,7 @@ import CloseIcon from '@material-ui/icons/Close'
 import PropTypes from 'prop-types'
 import clsx from 'clsx'
 import { Unit } from '../../../units'
-import { useApi } from '../../apiV1'
+import { useApi } from '../../api'
 import searchQuantities from '../../../searchQuantities'
 import InputLabel from './InputLabel'
 import InputTooltip from './InputTooltip'
diff --git a/gui/src/components/search/menus/FilterSubMenuAccess.js b/gui/src/components/search/menus/FilterSubMenuAccess.js
index 11785d6146da5b682dc0fe61b63120098b6aa938..fbefaefb39fbf687575bffe08664ac2e1b31f681 100644
--- a/gui/src/components/search/menus/FilterSubMenuAccess.js
+++ b/gui/src/components/search/menus/FilterSubMenuAccess.js
@@ -20,7 +20,7 @@ import PropTypes from 'prop-types'
 import { Grid } from '@material-ui/core'
 import { FilterSubMenu } from './FilterMenu'
 import InputRadio from '../input/InputRadio'
-import { useApi } from '../../apiV1'
+import { useApi } from '../../api'
 
 const FilterSubMenuAccess = React.memo(({
   value,
diff --git a/gui/src/components/search/results/DatasetList.js b/gui/src/components/search/results/DatasetList.js
index 51f63802a08693ebbac0398bf9b1f1a3f3a1a456..aaf192e522e7b032fa4be09b7c9b87843883910f 100644
--- a/gui/src/components/search/results/DatasetList.js
+++ b/gui/src/components/search/results/DatasetList.js
@@ -17,24 +17,9 @@
  */
 import React from 'react'
 import PropTypes from 'prop-types'
-import { withStyles, TableCell, Toolbar, IconButton, FormGroup, Tooltip, Link } from '@material-ui/core'
-import { compose } from 'recompose'
-import { withRouter } from 'react-router'
-import NextIcon from '@material-ui/icons/ChevronRight'
-import StartIcon from '@material-ui/icons/SkipPrevious'
-import DataTable from '../../DataTable'
-import SearchIcon from '@material-ui/icons/Search'
-import DOIIcon from '@material-ui/icons/Bookmark'
-import DeleteIcon from '@material-ui/icons/Delete'
-import { withApi } from '../../api'
-import EditUserMetadataDialog from '../../EditUserMetadataDialog'
-import DownloadButton from '../../DownloadButton'
+import { withStyles, IconButton, Tooltip, Link } from '@material-ui/core'
 import ClipboardIcon from '@material-ui/icons/Assignment'
 import { CopyToClipboard } from 'react-copy-to-clipboard'
-import ConfirmDialog from '../../uploads/ConfirmDialog'
-import { oasis } from '../../../config'
-import { authorList } from '../../../utils'
-import { DatasetButton } from '../../nav/Routes'
 
 class DOIUnstyled extends React.Component {
   static propTypes = {
@@ -77,247 +62,3 @@ class DOIUnstyled extends React.Component {
 }
 
 export const DOI = withStyles(DOIUnstyled.styles)(DOIUnstyled)
-
-class DatasetActionsUnstyled extends React.Component {
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    dataset: PropTypes.object.isRequired,
-    history: PropTypes.object.isRequired,
-    search: PropTypes.bool,
-    user: PropTypes.object,
-    onChange: PropTypes.func,
-    api: PropTypes.object.isRequired,
-    raiseError: PropTypes.func.isRequired
-  }
-
-  static styles = theme => ({
-    group: {
-      flexWrap: 'nowrap',
-      flexDirection: 'row-reverse'
-    }
-  })
-
-  state = {
-    confirmDoi: false
-  }
-
-  constructor(props) {
-    super(props)
-    this.handleClickDOI = this.handleClickDOI.bind(this)
-    this.handleClickDelete = this.handleClickDelete.bind(this)
-    this.handleEdit = this.handleEdit.bind(this)
-  }
-
-  handleClickDOI(after) {
-    const {api, dataset, onChange, raiseError} = this.props
-    const datasetName = dataset.name
-
-    api.assignDatasetDOI(datasetName)
-      .then(dataset => {
-        if (onChange) {
-          onChange(dataset)
-        }
-        if (after) {
-          after()
-        }
-      })
-      .catch(raiseError)
-  }
-
-  handleEdit() {
-    const {onChange, dataset} = this.props
-    if (onChange) {
-      onChange(dataset)
-    }
-  }
-
-  handleClickDelete() {
-    const {api, dataset, onChange, raiseError} = this.props
-    const datasetName = dataset.name
-
-    api.deleteDataset(datasetName)
-      .then(dataset => {
-        if (onChange) {
-          onChange(null)
-        }
-      })
-      .catch(raiseError)
-  }
-
-  render() {
-    const {dataset, search, user, classes} = this.props
-    const {doi} = dataset
-    const editable = user && dataset.example &&
-      dataset.example.owners.find(author => author.user_id === user.sub)
-
-    const canAssignDOI = !doi
-    const canDelete = !doi
-    const query = {dataset_id: [dataset.dataset_id]}
-
-    return <FormGroup row classes={{root: classes.group}}>
-      {search && <Tooltip title="Open a search page with entries from this dataset only.">
-        <DatasetButton component={IconButton} datasetId={dataset.dataset_id}>
-          <SearchIcon />
-        </DatasetButton>
-      </Tooltip>}
-      {<DownloadButton query={query} tooltip="Download dataset" />}
-      {editable && canDelete && <Tooltip title="Delete this dataset.">
-        <IconButton onClick={this.handleClickDelete}>
-          <DeleteIcon />
-        </IconButton>
-      </Tooltip>}
-      {editable && <EditUserMetadataDialog
-        title="Edit metadata of all dataset entries"
-        example={dataset.example} query={query}
-        total={dataset.total} onEditComplete={this.handleEdit}
-      />}
-      {!oasis && editable && canAssignDOI && <Tooltip title="Assign a DOI to this dataset.">
-        <IconButton onClick={() => this.setState({confirmDoi: true})}>
-          <DOIIcon />
-        </IconButton>
-      </Tooltip>}
-      <ConfirmDialog
-        open={this.state.confirmDoi}
-        title="Assign a DOI"
-        content={`
-          DOIs are **permanent**. Are you sure that you want to assign a DOI to this
-          dataset? Once the DOI was assigned, entries cannot removed from the dataset and
-          the dataset cannot be deleted.
-        `}
-        onClose={() => this.setState({confirmDoi: false})}
-        onConfirm={() => {
-          this.handleClickDOI(() => this.setState({confirmDoi: false}))
-        }}
-      />
-    </FormGroup>
-  }
-}
-
-export const DatasetActions = compose(withRouter, withApi(false), withStyles(DatasetActionsUnstyled.styles))(DatasetActionsUnstyled)
-
-class DatasetListUnstyled extends React.Component {
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    data: PropTypes.object,
-    total: PropTypes.number,
-    onChange: PropTypes.func.isRequired,
-    onEdit: PropTypes.func.isRequired,
-    history: PropTypes.any.isRequired,
-    datasets_after: PropTypes.string,
-    per_page: PropTypes.number,
-    actions: PropTypes.element
-  }
-
-  static styles = theme => ({
-    root: {
-      overflow: 'auto',
-      paddingLeft: theme.spacing(2),
-      paddingRight: theme.spacing(2)
-    },
-    scrollCell: {
-      padding: 0
-    },
-    scrollBar: {
-      minHeight: 56,
-      padding: 0
-    },
-    scrollSpacer: {
-      flexGrow: 1
-    },
-    clickableRow: {
-      cursor: 'pointer'
-    }
-  })
-
-  constructor(props) {
-    super(props)
-    this.renderEntryActions = this.renderEntryActions.bind(this)
-  }
-
-  columns = {
-    name: {
-      label: 'Dataset name',
-      description: 'The name given to this dataset by its creator',
-      render: (dataset) => dataset.name
-    },
-    created: {
-      label: 'Created',
-      description: 'The data when this dataset was created',
-      render: (dataset) => dataset.created && new Date(dataset.created).toLocaleString()
-    },
-    DOI: {
-      label: 'Dataset DOI',
-      description: 'The DOI of the dataset, if a DOI was assigned',
-      render: (dataset) => dataset.doi && <DOI doi={dataset.doi} />
-    },
-    entries: {
-      label: 'Entries',
-      description: 'Number of entries that comprise the group',
-      render: (dataset) => dataset.total
-    },
-    authors: {
-      label: 'Authors',
-      description: 'Authors including the uploader and the co-authors',
-      render: (dataset) => authorList(dataset.example)
-    }
-  }
-
-  renderEntryActions(entry) {
-    const {onEdit} = this.props
-    return <DatasetActions search dataset={entry} onChange={onEdit} />
-  }
-
-  render() {
-    const { classes, data, total, datasets_after, per_page, onChange, actions } = this.props
-    const datasets = data.datasets_grouped || {values: []}
-    const results = Object.keys(datasets.values).map(id => {
-      const exampleDataset = datasets.values[id].examples[0].datasets.find(ds => ds.dataset_id === id)
-      return {
-        ...exampleDataset,
-        id: id,
-        total: datasets.values[id].total,
-        example: datasets.values[id].examples[0]
-      }
-    })
-    const after = datasets.after
-    const perPage = per_page || 10
-
-    let paginationText
-    if (datasets_after) {
-      paginationText = `next ${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}`
-    } else {
-      paginationText = `1-${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}`
-    }
-
-    const pagination = <TableCell colSpan={1000} classes={{root: classes.scrollCell}}>
-      <Toolbar className={classes.scrollBar}>
-        <span className={classes.scrollSpacer}>&nbsp;</span>
-        <span>{paginationText}</span>
-        <IconButton disabled={!datasets_after} onClick={() => onChange({datasets_grouped_after: null})}>
-          <StartIcon />
-        </IconButton>
-        <IconButton disabled={results.length < perPage} onClick={() => onChange({datasets_grouped_after: after})}>
-          <NextIcon />
-        </IconButton>
-      </Toolbar>
-    </TableCell>
-
-    return <DataTable
-      entityLabels={['dataset', 'datasets']}
-      id={row => row.id}
-      total={total}
-      columns={this.columns}
-      selectedColumns={['name', 'DOI', 'entries', 'authors']}
-      selectedColumnsKey="datasets"
-      entryActions={this.renderEntryActions}
-      data={results}
-      rows={perPage}
-      actions={actions}
-      pagination={pagination}
-    />
-  }
-}
-
-const DatasetList = compose(withRouter, withApi(false), withStyles(DatasetListUnstyled.styles))(DatasetListUnstyled)
-
-export default DatasetList
diff --git a/gui/src/components/search/results/SearchResultsEntries.js b/gui/src/components/search/results/SearchResultsEntries.js
index d82a5671021e3bdf5fa3ef372c0658e5db1887d0..2857b20f22e94dd423a8aaeeb4e85b54c872be76 100644
--- a/gui/src/components/search/results/SearchResultsEntries.js
+++ b/gui/src/components/search/results/SearchResultsEntries.js
@@ -31,7 +31,7 @@ import { domainData } from '../../domainData'
 import EntryDetails from '../../entry/EntryDetails'
 import { authorList, nameList } from '../../../utils'
 import NewDataTable from '../../NewDataTable'
-import { useApi } from '../../apiV1'
+import { useApi } from '../../api'
 import { EntryButton } from '../../nav/Routes'
 import Quantity from '../../Quantity'
 import searchQuantities from '../../../searchQuantities'
diff --git a/gui/src/components/search/results/UploadsList.js b/gui/src/components/search/results/UploadsList.js
deleted file mode 100644
index 49ce3390cc5eac858e74c38821b28f617c53cab5..0000000000000000000000000000000000000000
--- a/gui/src/components/search/results/UploadsList.js
+++ /dev/null
@@ -1,263 +0,0 @@
-/*
- * Copyright The NOMAD Authors.
- *
- * This file is part of NOMAD. See https://nomad-lab.eu for further info.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import React from 'react'
-import PropTypes from 'prop-types'
-import { withStyles, TableCell, Toolbar, IconButton, FormGroup, Tooltip } from '@material-ui/core'
-import { compose } from 'recompose'
-import { withRouter } from 'react-router'
-import NextIcon from '@material-ui/icons/ChevronRight'
-import StartIcon from '@material-ui/icons/SkipPrevious'
-import DataTable from '../../DataTable'
-import { withApi } from '../../api'
-import EditUserMetadataDialog from '../../EditUserMetadataDialog'
-import DownloadButton from '../../DownloadButton'
-import ClipboardIcon from '@material-ui/icons/Assignment'
-import { CopyToClipboard } from 'react-copy-to-clipboard'
-import DetailsIcon from '@material-ui/icons/MoreHoriz'
-import PublicIcon from '@material-ui/icons/Public'
-import UploaderIcon from '@material-ui/icons/AccountCircle'
-import { UploadButton } from '../../nav/Routes'
-
-export function Published(props) {
-  const {entry} = props
-  if (entry.published) {
-    return <Tooltip title="published upload">
-      <PublicIcon color="primary" />
-    </Tooltip>
-  } else {
-    return <Tooltip title="this upload is not yet published">
-      <UploaderIcon color="error"/>
-    </Tooltip>
-  }
-}
-
-class UploadIdUnstyled extends React.Component {
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    uploadId: PropTypes.string.isRequired
-  }
-
-  static styles = theme => ({
-    root: {
-      display: 'inline-flex',
-      alignItems: 'center',
-      flexDirection: 'row',
-      flexWrap: 'nowrap'
-    }
-  })
-
-  render() {
-    const {classes, uploadId} = this.props
-    return <span className={classes.root}>
-      {uploadId}
-      <CopyToClipboard
-        text={uploadId} onCopy={() => null}
-      >
-        <Tooltip title={`Copy to clipboard`}>
-          <IconButton style={{margin: 3, marginRight: 0, padding: 4}}>
-            <ClipboardIcon style={{fontSize: 16}} />
-          </IconButton>
-        </Tooltip>
-      </CopyToClipboard>
-    </span>
-  }
-}
-
-export const UploadId = withStyles(UploadIdUnstyled.styles)(UploadIdUnstyled)
-
-class UploadActionsUnstyled extends React.Component {
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    upload: PropTypes.object.isRequired,
-    user: PropTypes.object,
-    onEdit: PropTypes.func,
-    history: PropTypes.object.isRequired
-  }
-
-  static styles = theme => ({
-    group: {
-      flexWrap: 'nowrap',
-      flexDirection: 'row-reverse'
-    }
-  })
-
-  constructor(props) {
-    super(props)
-    this.handleEdit = this.handleEdit.bind(this)
-  }
-
-  handleEdit() {
-    const {onEdit, upload} = this.props
-    if (onEdit) {
-      onEdit(upload)
-    }
-  }
-
-  render() {
-    const {upload, user, classes} = this.props
-    const editable = user && upload.example &&
-      upload.example.authors.find(author => author.user_id === user.sub)
-    const uploadId = upload.example.upload_id
-    const query = {upload_id: [uploadId]}
-
-    return <FormGroup row classes={{root: classes.group}}>
-      {user.sub === upload.example.uploader.user_id &&
-      <Tooltip title="Open this upload on the uploads page">
-        <UploadButton component={IconButton} uploadId={uploadId}>
-          <DetailsIcon />
-        </UploadButton>
-      </Tooltip>}
-      {<DownloadButton query={query} tooltip="Download upload" />}
-      {editable && <EditUserMetadataDialog
-        title="Edit metadata of all entries in this upload"
-        example={upload.example} query={query}
-        total={upload.total} onEditComplete={this.handleEdit}
-      />}
-    </FormGroup>
-  }
-}
-
-export const UploadActions = compose(withRouter, withApi(false), withStyles(UploadActionsUnstyled.styles))(UploadActionsUnstyled)
-
-class UploadListUnstyled extends React.Component {
-  static propTypes = {
-    classes: PropTypes.object.isRequired,
-    data: PropTypes.object,
-    total: PropTypes.number,
-    onChange: PropTypes.func.isRequired,
-    onEdit: PropTypes.func.isRequired,
-    history: PropTypes.any.isRequired,
-    uploads_after: PropTypes.string,
-    actions: PropTypes.element
-  }
-
-  static styles = theme => ({
-    root: {
-      overflow: 'auto',
-      paddingLeft: theme.spacing(2),
-      paddingRight: theme.spacing(2)
-    },
-    scrollCell: {
-      padding: 0
-    },
-    scrollBar: {
-      minHeight: 56,
-      padding: 0
-    },
-    scrollSpacer: {
-      flexGrow: 1
-    },
-    clickableRow: {
-      cursor: 'pointer'
-    }
-  })
-
-  constructor(props) {
-    super(props)
-    this.renderEntryActions = this.renderEntryActions.bind(this)
-  }
-
-  columns = {
-    upload_time: {
-      label: 'Upload time',
-      render: (upload) => new Date(upload.example.upload_time).toLocaleString()
-    },
-    upload_name: {
-      label: 'Name',
-      render: (upload) => upload.example.upload_name || ''
-    },
-    upload_id: {
-      label: 'Id',
-      render: (upload) => <UploadId uploadId={upload.example.upload_id} />
-    },
-    last_processing: {
-      label: 'Last processed',
-      render: (upload) => new Date(upload.example.last_processing).toLocaleString()
-    },
-    version: {
-      label: 'Processed with version',
-      render: (upload) => upload.example.nomad_version
-    },
-    entries: {
-      label: 'Entries',
-      render: (upload) => upload.total
-    },
-    published: {
-      label: 'Published',
-      align: 'center',
-      render: upload => <Published entry={upload.example} />
-    }
-  }
-
-  renderEntryActions(entry) {
-    const {onEdit} = this.props
-    return <UploadActions search upload={entry} onEdit={onEdit}/>
-  }
-
-  render() {
-    const { classes, data, total, uploads_after, onChange, actions } = this.props
-    const uploads = data.uploads_grouped || {values: []}
-    const results = Object.keys(uploads.values).map(id => {
-      return {
-        id: id,
-        total: uploads.values[id].total,
-        example: uploads.values[id].examples[0]
-      }
-    })
-    const per_page = 10
-    const after = uploads.after
-
-    let paginationText
-    if (uploads_after) {
-      paginationText = `next ${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}`
-    } else {
-      paginationText = `1-${results.length.toLocaleString()} of ${(total || 0).toLocaleString()}`
-    }
-
-    const pagination = <TableCell colSpan={1000} classes={{root: classes.scrollCell}}>
-      <Toolbar className={classes.scrollBar}>
-        <span className={classes.scrollSpacer}>&nbsp;</span>
-        <span>{paginationText}</span>
-        <IconButton disabled={!uploads_after} onClick={() => onChange({uploads_grouped_after: null})}>
-          <StartIcon />
-        </IconButton>
-        <IconButton disabled={results.length < per_page} onClick={() => onChange({uploads_grouped_after: after})}>
-          <NextIcon />
-        </IconButton>
-      </Toolbar>
-    </TableCell>
-
-    return <DataTable
-      entityLabels={['upload', 'uploads']}
-      id={row => row.id}
-      total={total}
-      columns={this.columns}
-      selectedColumns={['upload_time', 'upload_id', 'entries', 'published']}
-      selectedColumnsKey="uploads"
-      entryActions={this.renderEntryActions}
-      data={results}
-      rows={per_page}
-      actions={actions}
-      pagination={pagination}
-    />
-  }
-}
-
-const UploadList = compose(withRouter, withApi(false), withStyles(UploadListUnstyled.styles))(UploadListUnstyled)
-
-export default UploadList
diff --git a/gui/src/components/uploads/FilesBrowser.js b/gui/src/components/uploads/FilesBrowser.js
index 1bd7bb1afa0d057c72ec5fb4cd441830b6da9665..4ca54b91e280602dd410efacc6a976fa024a6e01 100644
--- a/gui/src/components/uploads/FilesBrowser.js
+++ b/gui/src/components/uploads/FilesBrowser.js
@@ -21,7 +21,7 @@ import PropTypes from 'prop-types'
 import { Box, Chip, CircularProgress, Collapse, IconButton, makeStyles, Paper, Typography } from '@material-ui/core'
 import ExpandMoreIcon from '@material-ui/icons/ExpandMore'
 import ChevronRightIcon from '@material-ui/icons/ChevronRight'
-import { useApi } from '../apiV1'
+import { useApi } from '../api'
 import { useErrors } from '../errors'
 import PreviewIcon from '@material-ui/icons/Visibility'
 import FilePreview from './FilePreview'
diff --git a/gui/src/components/uploads/NewUploadButton.js b/gui/src/components/uploads/NewUploadButton.js
index 15207a6cb4d8a2a71dce147917da1fc447bf041f..0aca67ab77a2455d29bbb27398c75dd98c30e48b 100644
--- a/gui/src/components/uploads/NewUploadButton.js
+++ b/gui/src/components/uploads/NewUploadButton.js
@@ -18,7 +18,7 @@
 import { Button } from '@material-ui/core'
 import React, { useState } from 'react'
 import { useHistory, useLocation } from 'react-router-dom'
-import { useApi } from '../apiV1'
+import { useApi } from '../api'
 import { useErrors } from '../errors'
 import { getUrl } from '../nav/Routes'
 
diff --git a/gui/src/components/uploads/UploadPage.js b/gui/src/components/uploads/UploadPage.js
index 499d01bd3aa0fe8f2b798f76dcde5008ffe33d64..2b7a4812d03f687cfed7caaa2d938531a4572f44 100644
--- a/gui/src/components/uploads/UploadPage.js
+++ b/gui/src/components/uploads/UploadPage.js
@@ -24,7 +24,7 @@ import Dropzone from 'react-dropzone'
 import UploadIcon from '@material-ui/icons/CloudUpload'
 import { appBase } from '../../config'
 import { CodeList } from '../About'
-import { DoesNotExist, useApi, withLoginRequired } from '../apiV1'
+import { DoesNotExist, useApi, withLoginRequired } from '../api'
 import { useParams } from 'react-router'
 import { useHistory, useLocation } from 'react-router-dom'
 import FilesBrower from './FilesBrowser'
diff --git a/gui/src/components/uploads/UploadsPage.js b/gui/src/components/uploads/UploadsPage.js
index 69fd13be0886e7183dcfc53d29120beff3bcc784..d1d992689b7d2a7638e9bcf30830631f2ddfdbcb 100644
--- a/gui/src/components/uploads/UploadsPage.js
+++ b/gui/src/components/uploads/UploadsPage.js
@@ -26,7 +26,7 @@ import HelpDialog from '../Help'
 import { CopyToClipboard } from 'react-copy-to-clipboard'
 import { guiBase } from '../../config'
 import NewUploadButton from './NewUploadButton'
-import { useApi, withLoginRequired } from '../apiV1'
+import { useApi, withLoginRequired } from '../api'
 import Page from '../Page'
 import { useErrors } from '../errors'
 import DataTable from '../DataTable'
diff --git a/gui/src/testutils.js b/gui/src/testutils.js
index fc9eedb55669cdc5801e0c667e0ba7331f74fb35..42a2a418b7f24706766898bdd6e01584f5e1a360 100644
--- a/gui/src/testutils.js
+++ b/gui/src/testutils.js
@@ -20,7 +20,6 @@ import React from 'react'
 import { RecoilRoot } from 'recoil'
 import { compose } from 'recompose'
 import { render } from '@testing-library/react'
-import { apiContext as apiContextV0 } from './components/api'
 import { Router } from 'react-router-dom'
 import {
   archiveDftBulk
@@ -93,22 +92,6 @@ export function withKeycloakMock(Component) {
   }
 }
 
-/**
- * HOC for injecting a mocked API implementation.
- */
-export function withApiV0Mock(Component) {
-  const apiValue = {
-    api: {
-      archive: (upload_id, calc_id) => {
-        return wait(archives.get(calc_id))
-      }
-    }
-  }
-  return <apiContextV0.Provider value={apiValue}>
-    {Component}
-  </apiContextV0.Provider>
-}
-
 /**
  * HOC for Router dependency injection
  */
@@ -133,8 +116,7 @@ export function withRecoilRoot(Component) {
 export function renderWithAPIRouter(component) {
   return render(compose(
     withRecoilRoot,
-    withRouterMock,
-    withApiV0Mock
+    withRouterMock
   )(component))
 }
 
diff --git a/nomad/app/v1/main.py b/nomad/app/v1/main.py
index 27bc58d929f14533002ff38bd670c6dc37a40f30..83b8eccd8811d78890d86f623ea3fbde7a292d83 100644
--- a/nomad/app/v1/main.py
+++ b/nomad/app/v1/main.py
@@ -24,7 +24,7 @@ import traceback
 from nomad import config, utils
 
 from .common import root_path
-from .routers import users, entries, materials, auth, datasets, uploads, suggestions
+from .routers import users, entries, materials, auth, info, datasets, uploads, suggestions
 
 
 logger = utils.get_logger(__name__)
@@ -157,6 +157,7 @@ async def unicorn_exception_handler(request: Request, e: Exception):
         }
     )
 
+app.include_router(info.router, prefix='/info')
 app.include_router(auth.router, prefix='/auth')
 app.include_router(materials.router, prefix='/materials')
 app.include_router(entries.router, prefix='/entries')
diff --git a/nomad/app/v1/models.py b/nomad/app/v1/models.py
index 26833ebb4e79588c67fd642cd4d0752a5cd782b3..a1ebaff79e6f0e78a9472aebb90e12a555a877af 100644
--- a/nomad/app/v1/models.py
+++ b/nomad/app/v1/models.py
@@ -44,8 +44,10 @@ from .utils import parameter_dependency_from_model, update_url_query_arguments
 
 
 User = datamodel.User.m_def.a_pydantic.model
-Value = Union[StrictInt, StrictFloat, StrictBool, datetime.datetime, str]
-ComparableValue = Union[StrictInt, StrictFloat, datetime.datetime, str]
+# It is important that datetime.datetime comes last. Otherwise, number valued strings
+# are interpreted as epoch dates by pydantic
+Value = Union[StrictInt, StrictFloat, StrictBool, str, datetime.datetime]
+ComparableValue = Union[StrictInt, StrictFloat, str, datetime.datetime]
 
 
 class HTTPExceptionModel(BaseModel):
@@ -962,6 +964,11 @@ class Files(BaseModel):
         whole path.
 
         A re pattern will replace a given glob pattern.'''))
+    include_files: Optional[List[str]] = Field(
+        None, description=strip('''
+        Optional list of file names. Only files with these names are included in the
+        results. This will overwrite any given glob or re pattern.''')
+    )
 
     @validator('glob_pattern')
     def validate_glob_pattern(cls, glob_pattern):  # pylint: disable=no-self-argument
@@ -986,6 +993,11 @@ class Files(BaseModel):
         # use the compiled glob pattern as re
         if values.get('re_pattern') is None:
             values['re_pattern'] = values.get('glob_pattern')
+
+        if values.get('include_files') is not None:
+            files = values['include_files']
+            values['re_pattern'] = re.compile(f'({"|".join([re.escape(f) for f in files])})$')
+
         return values
 
 
@@ -1005,10 +1017,7 @@ class Bucket(BaseModel):
 
 class BucketAggregationResponse(BaseModel):
     data: List[Bucket] = Field(
-        None, description=strip('''
-        The aggregation data as a dictionary. The key is a string representation of the values.
-        The dictionary values contain the aggregated data depending if `entries` where
-        requested.'''))
+        None, description=strip('''The aggregation data as a list.'''))
 
 
 class TermsAggregationResponse(BucketAggregationResponse, TermsAggregation):
diff --git a/nomad/app/v1/routers/auth.py b/nomad/app/v1/routers/auth.py
index a27d6271434d1ffd5e3f326dc8d47a663b7376a3..8a83e1ba00f22837dcc3870ddc18578ed0722e04 100644
--- a/nomad/app/v1/routers/auth.py
+++ b/nomad/app/v1/routers/auth.py
@@ -25,6 +25,8 @@ from functools import wraps
 from fastapi import APIRouter, Depends, Query as FastApiQuery, HTTPException, status
 from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
 from pydantic import BaseModel
+import jwt
+import datetime
 
 from nomad import utils, infrastructure, config, datamodel
 from nomad.utils import get_logger, strip
@@ -44,6 +46,10 @@ class Token(BaseModel):
     token_type: str
 
 
+class SignatureToken(BaseModel):
+    signature_token: str
+
+
 oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f'{root_path}/auth/token', auto_error=False)
 
 
@@ -51,7 +57,8 @@ def create_user_dependency(
         required: bool = False,
         basic_auth_allowed: bool = False,
         bearer_token_auth_allowed: bool = True,
-        upload_token_auth_allowed: bool = False) -> Callable:
+        upload_token_auth_allowed: bool = False,
+        signature_token_auth_allowed: bool = False) -> Callable:
     '''
     Creates a dependency for getting the authenticated user. The parameters define if
     the authentication is required or not, and which authentication methods are allowed.
@@ -65,6 +72,8 @@ def create_user_dependency(
             user = _get_user_bearer_token_auth(kwargs.get('bearer_token'))
         if not user and upload_token_auth_allowed:
             user = _get_user_upload_token_auth(kwargs.get('token'))
+        if not user and signature_token_auth_allowed:
+            user = _get_user_signature_token_auth(kwargs.get('signature_token'))
 
         if required and not user:
             raise HTTPException(
@@ -110,6 +119,15 @@ def create_user_dependency(
                     None,
                     description='Token for simplified authorization for uploading.'),
                 kind=Parameter.KEYWORD_ONLY))
+    if signature_token_auth_allowed:
+        new_parameters.append(
+            Parameter(
+                name='signature_token',
+                annotation=str,
+                default=FastApiQuery(
+                    None,
+                    description='Signature token used to sign download urls.'),
+                kind=Parameter.KEYWORD_ONLY))
 
     # Create a wrapper around user_dependency, and set the signature on it
     @wraps(user_dependency)
@@ -185,6 +203,31 @@ def _get_user_upload_token_auth(upload_token: str) -> User:
     return None
 
 
+def _get_user_signature_token_auth(signature_token: str) -> User:
+    '''
+    Verifies the signature token (throwing exception if illegal value provided) and returns the
+    corresponding user object, or None, if no upload_token provided.
+    '''
+    if signature_token:
+        try:
+            decoded = jwt.decode(signature_token, config.services.api_secret, algorithms=['HS256'])
+            return datamodel.User.get(user_id=decoded['user'])
+        except KeyError:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail='Token with invalid/unexpected payload.')
+        except jwt.ExpiredSignatureError:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail='Expired token.')
+        except jwt.InvalidTokenError:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail='Invalid token.')
+
+    return None
+
+
 _bad_credentials_response = status.HTTP_401_UNAUTHORIZED, {
     'model': HTTPExceptionModel,
     'description': strip('''
@@ -247,6 +290,25 @@ async def get_token_via_query(username: str, password: str):
     return {'access_token': access_token, 'token_type': 'bearer'}
 
 
+@router.get(
+    '/signature_token',
+    tags=[default_tag],
+    summary='Get a signature token',
+    response_model=SignatureToken)
+async def get_signature_token(user: User = Depends(create_user_dependency())):
+    '''
+    Generates and returns a signature token for the authenticated user. Authentication
+    has to be provided with another method, e.g. access token.
+    '''
+
+    expires_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=10)
+    signature_token = jwt.encode(
+        dict(user=user.user_id, exp=expires_at),
+        config.services.api_secret, 'HS256').decode('utf-8')
+
+    return {'signature_token': signature_token}
+
+
 def generate_upload_token(user):
     payload = uuid.UUID(user.user_id).bytes
     signature = hmac.new(
diff --git a/nomad/app/v1/routers/datasets.py b/nomad/app/v1/routers/datasets.py
index ec982ee3d08ceb713ea3aa0ed2cede9b9cbc4e2e..aecad4c433aacddd9b094c3d5d5eb402daf09d72 100644
--- a/nomad/app/v1/routers/datasets.py
+++ b/nomad/app/v1/routers/datasets.py
@@ -133,13 +133,18 @@ async def get_datasets(
         name: str = FastApiQuery(None),
         user_id: str = FastApiQuery(None),
         dataset_type: str = FastApiQuery(None),
+        doi: str = FastApiQuery(None),
+        prefix: str = FastApiQuery(None),
         pagination: DatasetPagination = Depends(dataset_pagination_parameters)):
     '''
     Retrieves all datasets that match the given criteria.
     '''
     mongodb_objects = DatasetDefinitionCls.m_def.a_mongo.objects
-    query_params = dict(dataset_id=dataset_id, name=name, user_id=user_id, dataset_type=dataset_type)
+    query_params = dict(dataset_id=dataset_id, name=name, user_id=user_id, dataset_type=dataset_type, doi=doi)
+    if prefix and prefix != '':
+        query_params.update(name=re.compile('^%s.*' % prefix, re.IGNORECASE))  # type: ignore
     query_params = {k: v for k, v in query_params.items() if v is not None}
+
     mongodb_query = mongodb_objects(**query_params)
 
     order_by = pagination.order_by if pagination.order_by is not None else 'dataset_id'
diff --git a/nomad/app/v1/routers/entries.py b/nomad/app/v1/routers/entries.py
index ea5d726e3b842c93e801da755359dfced8fc8732..f40f805e7a6e3d83ca67f24de90439037b6f1c1b 100644
--- a/nomad/app/v1/routers/entries.py
+++ b/nomad/app/v1/routers/entries.py
@@ -16,24 +16,30 @@
 # limitations under the License.
 #
 
-from typing import Optional, Union, Dict, Iterator, Any, List
+from datetime import datetime
+
+from typing import Optional, Set, Union, Dict, Iterator, Any, List
 from fastapi import (
     APIRouter, Depends, Path, status, HTTPException, Request, Query as QueryParameter,
     Body)
 from fastapi.responses import StreamingResponse
 from fastapi.exceptions import RequestValidationError
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, validator
 import os.path
 import io
 import json
 import orjson
+from pydantic.main import create_model
+from starlette.responses import Response
 
-from nomad import files, config, utils
+from nomad import files, config, utils, metainfo, processing as proc
+from nomad import datamodel
+from nomad.datamodel import EditableUserMetadata
 from nomad.files import StreamedFile, create_zipstream
 from nomad.utils import strip
 from nomad.archive import RequiredReader, RequiredValidationError, ArchiveQueryError
 from nomad.archive import ArchiveQueryError
-from nomad.search import AuthenticationRequiredError, SearchError
+from nomad.search import AuthenticationRequiredError, SearchError, update_metadata as es_update_metadata
 from nomad.search.v1 import search, QueryValidationError
 from nomad.metainfo.elasticsearch_extension import entry_type
 
@@ -42,7 +48,7 @@ from ..utils import (
     create_download_stream_zipped, create_download_stream_raw_file,
     DownloadItem, create_responses)
 from ..models import (
-    PaginationResponse, MetadataPagination, WithQuery, WithQueryAndPagination, MetadataRequired,
+    Aggregation, Pagination, PaginationResponse, MetadataPagination, TermsAggregation, WithQuery, WithQueryAndPagination, MetadataRequired,
     MetadataResponse, Metadata, Files, Query, User, Owner,
     QueryParameters, metadata_required_parameters, files_parameters, metadata_pagination_parameters,
     HTTPExceptionModel)
@@ -228,6 +234,37 @@ class EntryMetadataResponse(BaseModel):
         None, description=strip('''The entry metadata as dictionary.'''))
 
 
+class EntryMetadataEditActionField(BaseModel):
+    value: str = Field(None, description='The value/values that is set as a string.')
+    success: Optional[bool] = Field(None, description='If this can/could be done. Only in API response.')
+    message: Optional[str] = Field(None, descriptin='A message that details the action result. Only in API response.')
+
+
+EntryMetadataEditActions = create_model('EntryMetadataEditActions', **{  # type: ignore
+    quantity.name: (
+        Optional[EntryMetadataEditActionField]
+        if quantity.is_scalar else Optional[List[EntryMetadataEditActionField]], None)
+    for quantity in EditableUserMetadata.m_def.definitions
+    if isinstance(quantity, metainfo.Quantity)
+})
+
+
+class EntryMetadataEdit(WithQuery):
+    verify: Optional[bool] = Field(False, description='If true, no action is performed.')
+    actions: EntryMetadataEditActions = Field(  # type: ignore
+        None,
+        description='Each action specifies a single value (even for multi valued quantities).')
+
+    @validator('owner')
+    def validate_query(cls, owner):  # pylint: disable=no-self-argument
+        return Owner.user
+
+
+class EntryMetadataEditResponse(EntryMetadataEdit):
+    success: bool = Field(None, description='If the overall edit can/could be done. Only in API response.')
+    message: str = Field(None, description='A message that details the overall edit result. Only in API response.')
+
+
 _bad_owner_response = status.HTTP_401_UNAUTHORIZED, {
     'model': HTTPExceptionModel,
     'description': strip('''
@@ -258,13 +295,19 @@ _raw_download_file_response = 200, {
         on the file contents.
     ''')}
 
-_archive_download_response = 200, {
+_archives_download_response = 200, {
     'content': {'application/zip': {}},
     'description': strip('''
         A zip file with the requested archive files. The file is streamed.
         The content length is not known in advance.
     ''')}
 
+_archive_download_response = 200, {
+    'content': {'application/json': {}},
+    'description': strip('''
+        A json body with the requested archive.
+    ''')}
+
 
 _bad_archive_required_response = status.HTTP_400_BAD_REQUEST, {
     'model': HTTPExceptionModel,
@@ -272,6 +315,12 @@ _bad_archive_required_response = status.HTTP_400_BAD_REQUEST, {
         The given required specification could not be understood.''')}
 
 
+_bad_metadata_edit_response = status.HTTP_400_BAD_REQUEST, {
+    'model': HTTPExceptionModel,
+    'description': strip('''
+        The given edit actions cannot be performed by you on the given query.''')}
+
+
 def perform_search(*args, **kwargs):
     try:
         search_response = search(*args, **kwargs)
@@ -591,7 +640,7 @@ async def post_entries_raw_download_query(
 async def get_entries_raw_download(
         with_query: WithQuery = Depends(query_parameters),
         files: Files = Depends(files_parameters),
-        user: User = Depends(create_user_dependency())):
+        user: User = Depends(create_user_dependency(signature_token_auth_allowed=True))):
 
     return _answer_entries_raw_download_request(
         owner=with_query.owner, query=with_query.query, files=files, user=user)
@@ -792,7 +841,7 @@ _entries_archive_download_docstring = strip('''
     description=_entries_archive_download_docstring,
     response_class=StreamingResponse,
     responses=create_responses(
-        _archive_download_response, _bad_owner_response, _bad_archive_required_response))
+        _archives_download_response, _bad_owner_response, _bad_archive_required_response))
 async def post_entries_archive_download_query(
         data: EntriesArchiveDownload, user: User = Depends(create_user_dependency())):
 
@@ -807,11 +856,11 @@ async def post_entries_archive_download_query(
     description=_entries_archive_download_docstring,
     response_class=StreamingResponse,
     responses=create_responses(
-        _archive_download_response, _bad_owner_response, _bad_archive_required_response))
+        _archives_download_response, _bad_owner_response, _bad_archive_required_response))
 async def get_entries_archive_download(
         with_query: WithQuery = Depends(query_parameters),
         files: Files = Depends(files_parameters),
-        user: User = Depends(create_user_dependency())):
+        user: User = Depends(create_user_dependency(signature_token_auth_allowed=True))):
 
     return _answer_entries_archive_download_request(
         owner=with_query.owner, query=with_query.query, files=files, user=user)
@@ -857,7 +906,6 @@ async def get_entry_metadata(
     response_model_exclude_none=True)
 async def get_entry_raw(
         entry_id: str = Path(..., description='The unique entry id of the entry to retrieve raw data from.'),
-        files: Files = Depends(files_parameters),
         user: User = Depends(create_user_dependency())):
     '''
     Returns the file metadata for all input and output files (including auxiliary files)
@@ -890,7 +938,7 @@ async def get_entry_raw(
 async def get_entry_raw_download(
         entry_id: str = Path(..., description='The unique entry id of the entry to retrieve raw data from.'),
         files: Files = Depends(files_parameters),
-        user: User = Depends(create_user_dependency())):
+        user: User = Depends(create_user_dependency(signature_token_auth_allowed=True))):
     '''
     Streams a .zip file with the raw files from the requested entry.
     '''
@@ -928,7 +976,7 @@ async def get_entry_raw_download_file(
         decompress: Optional[bool] = QueryParameter(
             False, description=strip('''
                 Attempt to decompress the contents, if the file is .gz or .xz.''')),
-        user: User = Depends(create_user_dependency())):
+        user: User = Depends(create_user_dependency(signature_token_auth_allowed=True))):
     '''
     Streams the contents of an individual file from the requested entry.
     '''
@@ -1021,6 +1069,21 @@ async def get_entry_archive(
     return _answer_entry_archive_request(entry_id=entry_id, required='*', user=user)
 
 
+@router.get(
+    '/{entry_id}/archive/download',
+    tags=[archive_tag],
+    summary='Get the archive for an entry by its id as plain archive json',
+    responses=create_responses(_bad_id_response, _archive_download_response))
+async def get_entry_archive_download(
+        entry_id: str = Path(..., description='The unique entry id of the entry to retrieve raw data from.'),
+        user: User = Depends(create_user_dependency(signature_token_auth_allowed=True))):
+    '''
+    Returns the full archive for the given `entry_id`.
+    '''
+    response = _answer_entry_archive_request(entry_id=entry_id, required='*', user=user)
+    return response['data']['archive']
+
+
 @router.post(
     '/{entry_id}/archive/query',
     tags=[archive_tag],
@@ -1038,3 +1101,225 @@ async def post_entry_archive_query(
     in the body.
     '''
     return _answer_entry_archive_request(entry_id=entry_id, required=data.required, user=user)
+
+
+def edit(query: Query, user: User, mongo_update: Dict[str, Any] = None, re_index=True) -> List[str]:
+    # get all calculations that have to change
+    entry_ids: List[str] = []
+    upload_ids: Set[str] = set()
+    with utils.timer(logger, 'edit query executed'):
+        all_entries = _do_exaustive_search(
+            owner=Owner.user, query=query, include=['entry_id', 'upload_id'], user=user)
+
+        for entry in all_entries:
+            entry_ids.append(entry['entry_id'])
+            upload_ids.add(entry['upload_id'])
+
+    # perform the update on the mongo db
+    with utils.timer(logger, 'edit mongo update executed', size=len(entry_ids)):
+        if mongo_update is not None:
+            n_updated = proc.Calc.objects(calc_id__in=entry_ids).update(multi=True, **mongo_update)
+            if n_updated != len(entry_ids):
+                logger.error('edit repo did not update all entries', payload=mongo_update)
+
+    # re-index the affected entries in elastic search
+    with utils.timer(logger, 'edit elastic update executed', size=len(entry_ids)):
+        if re_index:
+            updated_metadata: List[datamodel.EntryMetadata] = []
+            for calc in proc.Calc.objects(calc_id__in=entry_ids):
+                updated_metadata.append(
+                    datamodel.EntryMetadata(calc_id=calc.calc_id, **calc.metadata))
+
+            failed = es_update_metadata(updated_metadata, update_materials=True, refresh=True)
+
+            if failed > 0:
+                logger.error(
+                    'edit repo with failed elastic updates',
+                    payload=mongo_update, nfailed=failed)
+
+    return list(upload_ids)
+
+
+def get_quantity_values(quantity, **kwargs):
+    ''' Get all the uploader from the query, to check coauthers and shared_with for uploaders. '''
+    response = perform_search(
+        **kwargs,
+        aggregations=dict(agg=Aggregation(terms=TermsAggregation(quantity=quantity))),
+        pagination=Pagination(page_size=0))
+    terms = response.aggregations['agg'].terms  # pylint: disable=no-member
+    return [bucket.value for bucket in terms.data]
+
+
+_editable_quantities = {
+    quantity.name: quantity for quantity in EditableUserMetadata.m_def.definitions}
+
+
+@router.post(
+    '/edit',
+    tags=[archive_tag],
+    summary='Edit the user metadata of a set of entries',
+    response_model=EntryMetadataEditResponse,
+    response_model_exclude_unset=True,
+    response_model_exclude_none=True,
+    responses=create_responses(_bad_metadata_edit_response))
+async def post_entry_metadata_edit(
+        response: Response,
+        data: EntryMetadataEdit,
+        user: User = Depends(create_user_dependency())):
+
+    '''
+    Performs or validates edit actions on a set of entries that match a given query.
+    '''
+
+    # checking the edit actions and preparing a mongo update on the fly
+    query = data.query
+    data = EntryMetadataEditResponse(**data.dict())
+    data.query = query  # to dict from dict does not work with the op aliases in queries
+    actions = data.actions
+    verify = data.verify
+    data.success = True
+    mongo_update = {}
+    uploader_ids = None
+    has_error = False
+    removed_datasets = None
+
+    with utils.timer(logger, 'edit verified'):
+        for action_quantity_name in actions.dict():  # type: ignore
+            quantity_actions = getattr(actions, action_quantity_name, None)
+            if quantity_actions is None:
+                continue
+
+            quantity = _editable_quantities.get(action_quantity_name)
+            if quantity is None:
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail='Unknown quantity %s' % action_quantity_name)
+
+            # TODO this does not work. Because the quantities are not in EditableUserMetadata
+            # they are also not in the model and ignored by fastapi. This probably
+            # also did not work in the old API.
+            if action_quantity_name in ['uploader', 'upload_time']:
+                if not user.is_admin():
+                    raise HTTPException(
+                        status_code=status.HTTP_400_BAD_REQUEST,
+                        detail='Only the admin user can set %s' % quantity.name)
+
+            if isinstance(quantity_actions, list) == quantity.is_scalar:
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail='Wrong shape for quantity %s' % action_quantity_name)
+
+            if not isinstance(quantity_actions, list):
+                quantity_actions = [quantity_actions]
+
+            verify_reference = None
+            if isinstance(quantity.type, metainfo.Reference):
+                verify_reference = quantity.type.target_section_def.section_cls
+
+            mongo_key = 'metadata__%s' % quantity.name
+            has_error = False
+            for action in quantity_actions:
+                action.success = True
+                action.message = None
+                action_value = action.value
+                action_value = action_value if action_value is None else action_value.strip()
+
+                if action_quantity_name == 'with_embargo':
+                    raise HTTPException(
+                        status_code=status.HTTP_400_BAD_REQUEST,
+                        detail='Updating the embargo flag on entry level is no longer allowed.')
+
+                if action_value is None:
+                    mongo_value = None
+
+                elif action_value == '':
+                    mongo_value = None
+
+                elif verify_reference in [datamodel.User, datamodel.Author]:
+                    try:
+                        mongo_value = datamodel.User.get(user_id=action_value).user_id
+                    except KeyError:
+                        action.success = False
+                        has_error = True
+                        action.message = 'User does not exist'
+                        continue
+
+                    if uploader_ids is None:
+                        uploader_ids = get_quantity_values(
+                            quantity='uploader.user_id', owner=Owner.user, query=data.query, user_id=user.user_id)
+                    if action_value in uploader_ids:
+                        action.success = False
+                        has_error = True
+                        action.message = 'This user is already an uploader of one entry in the query'
+                        continue
+
+                elif verify_reference == datamodel.Dataset:
+                    try:
+                        mongo_value = datamodel.Dataset.m_def.a_mongo.get(
+                            user_id=user.user_id, name=action_value).dataset_id
+                    except KeyError:
+                        action.message = 'Dataset does not exist and will be created'
+                        mongo_value = None
+                        if not verify:
+                            dataset = datamodel.Dataset(
+                                dataset_id=utils.create_uuid(), user_id=user.user_id,
+                                name=action_value, created=datetime.utcnow())
+                            dataset.a_mongo.create()
+                            mongo_value = dataset.dataset_id
+
+                else:
+                    mongo_value = action_value
+
+                if len(quantity.shape) == 0:
+                    mongo_update[mongo_key] = mongo_value
+                else:
+                    mongo_values = mongo_update.setdefault(mongo_key, [])
+                    if mongo_value is not None:
+                        if mongo_value in mongo_values:
+                            action.success = False
+                            has_error = True
+                            action.message = 'Duplicate values are not allowed'
+                            continue
+                        mongo_values.append(mongo_value)
+
+            if len(quantity_actions) == 0 and len(quantity.shape) > 0:
+                mongo_update[mongo_key] = []
+
+            if action_quantity_name == 'datasets':
+                # check if datasets edit is allowed and if datasets have to be removed
+                old_datasets = get_quantity_values(
+                    quantity='datasets.dataset_id', owner=Owner.user, query=data.query, user_id=user.user_id)
+
+                removed_datasets = []
+                for dataset_id in old_datasets:
+                    if dataset_id not in mongo_update.get(mongo_key, []):
+                        removed_datasets.append(dataset_id)
+
+                doi_ds = datamodel.Dataset.m_def.a_mongo.objects(
+                    dataset_id__in=removed_datasets, doi__ne=None).first()
+                if doi_ds is not None:
+                    data.success = False
+                    data.message = (data.message if data.message else '') + (
+                        'Edit would remove entries from a dataset with DOI (%s) ' % doi_ds.name)
+                    has_error = True
+
+    # stop here, if client just wants to verify its actions
+    if verify:
+        return data
+
+    # stop if the action were not ok
+    if has_error:
+        response.status_code = status.HTTP_400_BAD_REQUEST
+        return data
+
+    # perform the change
+    mongo_update['metadata__last_edit'] = datetime.utcnow()
+    edit(data.query, user, mongo_update, True)
+
+    # remove potentially empty old datasets
+    if removed_datasets is not None:
+        for dataset in removed_datasets:
+            if proc.Calc.objects(metadata__datasets=dataset).first() is None:
+                datamodel.Dataset.m_def.a_mongo.objects(dataset_id=dataset).delete()
+
+    return data
diff --git a/nomad/app/v1/routers/info.py b/nomad/app/v1/routers/info.py
new file mode 100644
index 0000000000000000000000000000000000000000..5c27f8c4f301c8ec546923be3fb96b4d36d10afb
--- /dev/null
+++ b/nomad/app/v1/routers/info.py
@@ -0,0 +1,158 @@
+#
+# Copyright The NOMAD Authors.
+#
+# This file is part of NOMAD. See https://nomad-lab.eu for further info.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+'''
+API endpoint that deliver backend configuration details.
+'''
+
+from typing import Dict, Any, List, Optional
+from datetime import datetime
+from fastapi.routing import APIRouter
+from pydantic.fields import Field
+from pydantic.main import BaseModel
+
+from nomad import config, normalizing, datamodel, gitinfo
+from nomad.utils import strip
+from nomad.search import v0 as search
+from nomad.parsing import parsers, MatchingParser
+
+
+router = APIRouter()
+default_tag = 'info'
+
+
+class MetainfoModel(BaseModel):
+    all_package: str = Field(None, description=strip('''
+        Name of the metainfo package that references all available packages, i.e.
+        the complete metainfo.'''))
+
+    root_section: str = Field(None, description=strip('''
+        Name of the topmost section, e.g. section run for computational material science
+        data.'''))
+
+
+class DomainModel(BaseModel):
+    name: str
+    metainfo: MetainfoModel
+
+
+class GitInfoModel(BaseModel):
+    ref: str
+    version: str
+    commit: str
+    log: str
+
+
+class StatisticsModel(BaseModel):
+    n_entries: int = Field(None, description='Number of entries in NOMAD')
+    n_uploads: int = Field(None, description='Number of uploads in NOMAD')
+    n_quantities: int = Field(None, description='Accumulated number of quantities over all entries in the Archive')
+    n_calculations: int = Field(None, description='Accumulated number of calculations, e.g. total energy calculations in the Archive')
+    n_materials: int = Field(None, description='Number of materials in NOMAD')
+    # TODO raw_file_size, archive_file_size
+
+
+class CodeInfoModel(BaseModel):
+    code_name: Optional[str] = Field(None, description='Name of the code or input format')
+    code_homepage: Optional[str] = Field(None, description='Homepage of the code or input format')
+
+
+class InfoModel(BaseModel):
+    parsers: List[str]
+    metainfo_packages: List[str]
+    codes: List[CodeInfoModel]
+    normalizers: List[str]
+    domains: List[DomainModel]
+    statistics: StatisticsModel = Field(None, description='General NOMAD statistics')
+    search_quantities: dict
+    version: str
+    release: str
+    git: GitInfoModel
+    oasis: bool
+
+
+_statistics: Dict[str, Any] = None
+
+
+def statistics():
+    global _statistics
+    if _statistics is None or datetime.now().timestamp() - _statistics.get('timestamp', 0) > 3600 * 24:
+        _statistics = dict(timestamp=datetime.now().timestamp())
+        _statistics.update(
+            **search.SearchRequest().global_statistics().execute()['global_statistics'])
+
+    return _statistics
+
+
+@router.get(
+    '',
+    tags=[default_tag],
+    summary='Get information about the nomad backend and its configuration',
+    response_model_exclude_unset=True,
+    response_model_exclude_none=True,
+    response_model=InfoModel)
+async def get_info():
+    ''' Return information about the nomad backend and its configuration. '''
+    codes_dict = {}
+    for parser in parsers.parser_dict.values():
+        if isinstance(parser, MatchingParser) and parser.domain == 'dft':
+            code_name = parser.code_name
+            if code_name in codes_dict:
+                continue
+            codes_dict[code_name] = dict(code_name=code_name, code_homepage=parser.code_homepage)
+    codes = sorted(list(codes_dict.values()), key=lambda code_info: code_info['code_name'].lower())
+
+    return {
+        'parsers': [
+            key[key.index('/') + 1:]
+            for key in parsers.parser_dict.keys()],
+        'metainfo_packages': ['general', 'general.experimental', 'common', 'public'] + sorted([
+            key[key.index('/') + 1:]
+            for key in parsers.parser_dict.keys()]),
+        'codes': codes,
+        'normalizers': [normalizer.__name__ for normalizer in normalizing.normalizers],
+        'statistics': statistics(),
+        'domains': [
+            {
+                'name': domain_name,
+                'metainfo': {
+                    'all_package': domain['metainfo_all_package'],
+                    'root_section': domain['root_section']
+                }
+            }
+            for domain_name, domain in datamodel.domains.items()
+        ],
+        'search_quantities': {
+            s.qualified_name: {
+                'name': s.qualified_name,
+                'description': s.description,
+                'many': s.many
+            }
+            for s in search.search_quantities.values()
+            if 'optimade' not in s.qualified_name
+        },
+        'version': config.meta.version,
+        'release': config.meta.release,
+        'git': {
+            'ref': gitinfo.ref,
+            'version': gitinfo.version,
+            'commit': gitinfo.commit,
+            'log': gitinfo.log
+        },
+        'oasis': config.keycloak.oasis
+    }
diff --git a/nomad/app/v1/routers/users.py b/nomad/app/v1/routers/users.py
index 39e0ab832ff54762f00f27c31b37c73447d65e4a..87ae5b8302d74e9b8cc8685c0109f6734dc2e59e 100644
--- a/nomad/app/v1/routers/users.py
+++ b/nomad/app/v1/routers/users.py
@@ -16,8 +16,11 @@
 # limitations under the License.
 #
 
-from fastapi import Depends, APIRouter, status
+from typing import List
+from fastapi import Depends, APIRouter, status, HTTPException
+from pydantic.main import BaseModel
 
+from nomad import infrastructure, config, datamodel
 from nomad.utils import strip
 
 from .auth import create_user_dependency
@@ -34,6 +37,15 @@ _authentication_required_response = status.HTTP_401_UNAUTHORIZED, {
         Unauthorized. The operation requires authorization,
         but no or bad authentication credentials are given.''')}
 
+_bad_invite_response = status.HTTP_400_BAD_REQUEST, {
+    'model': HTTPExceptionModel,
+    'description': strip('''
+        The invite is invalid.''')}
+
+
+class Users(BaseModel):
+    data: List[User]
+
 
 @router.get(
     '/me',
@@ -44,3 +56,60 @@ _authentication_required_response = status.HTTP_401_UNAUTHORIZED, {
     response_model=User)
 async def read_users_me(current_user: User = Depends(create_user_dependency(required=True))):
     return current_user
+
+
+@router.get(
+    '',
+    tags=[default_tag],
+    summary='Get existing users',
+    description='Get existing users witht the given prefix',
+    response_model_exclude_unset=True,
+    response_model_exclude_none=True,
+    response_model=Users)
+async def get_users(prefix: str):
+    users = []
+    for user in infrastructure.keycloak.search_user(prefix):
+        user_dict = user.m_to_dict(include_derived=True)
+        user_dict['email'] = None
+        users.append(user_dict)
+    return dict(data=users)
+
+
+@router.put(
+    '/invite',
+    tags=[default_tag],
+    summary='Invite a new user',
+    responses=create_responses(_authentication_required_response, _bad_invite_response),
+    response_model=User)
+async def invite_user(user: User, current_user: User = Depends(create_user_dependency(required=True))):
+    if config.keycloak.oasis:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail='User invide does not work this NOMAD OASIS.')
+
+    json_data = user.dict()
+    try:
+        user = datamodel.User.m_from_dict(json_data)
+    except Exception as e:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail='Invalid user data: %s' % str(e))
+
+    if user.email is None:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail='Invalid user data: email is required')
+
+    try:
+        error = infrastructure.keycloak.add_user(user, invite=True)
+    except KeyError as e:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail='Invalid user data: %s' % str(e))
+
+    if error is not None:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail='Could not invite user: %s' % str(error))
+
+    return datamodel.User.get(username=user.username), 200
diff --git a/nomad/datamodel/datamodel.py b/nomad/datamodel/datamodel.py
index 50977aec8aeaa28353edbcbb8060d6bd6f4a8168..359c203091efb8e11bef680c6006437a6a805bc5 100644
--- a/nomad/datamodel/datamodel.py
+++ b/nomad/datamodel/datamodel.py
@@ -29,7 +29,7 @@ from nomad.metainfo.elastic_extension import ElasticDocument
 from nomad.metainfo.mongoengine_extension import Mongo, MongoDocument
 from nomad.datamodel.metainfo.common import FastAccess
 from nomad.metainfo.pydantic_extension import PydanticModel
-from nomad.metainfo.elasticsearch_extension import Elasticsearch, material_entry_type
+from nomad.metainfo.elasticsearch_extension import Elasticsearch, material_entry_type, entry_type
 
 from .dft import DFTMetadata
 from .ems import EMSMetadata
@@ -66,6 +66,19 @@ def PathSearch():
             field='path', _es_field='')]
 
 
+quantity_analyzer = analyzer(
+    'quantity_analyzer',
+    tokenizer=tokenizer('quantity_tokenizer', 'pattern', pattern='.'))
+
+
+def QuantitySearch():
+    return [
+        Elasticsearch(_es_field='keyword'),
+        Elasticsearch(
+            mapping=dict(type='text', analyzer=path_analyzer.to_dict()),
+            field='path', _es_field='')]
+
+
 class Author(metainfo.MSection):
     ''' A person that is author of data in NOMAD or references by NOMAD. '''
     name = metainfo.Quantity(
@@ -440,7 +453,8 @@ class EntryMetadata(metainfo.MSection):
         The unique, sequentially enumerated, integer PID that was used in the legacy
         NOMAD CoE. It allows to resolve URLs of the old NOMAD CoE Repository.''',
         categories=[MongoMetadata],
-        a_search=Search(many_or='append'))
+        a_search=Search(many_or='append'),
+        a_elasticsearch=Elasticsearch(entry_type))
 
     raw_id = metainfo.Quantity(
         type=str,
@@ -651,6 +665,14 @@ class EntryMetadata(metainfo.MSection):
         description='The number of atoms in the entry\'s material',
         a_search=Search())
 
+    n_quantities = metainfo.Quantity(
+        type=int, default=0, description='Number of metainfo quantities parsed from the entry.')
+
+    quantities = metainfo.Quantity(
+        type=str, shape=['0..*'],
+        description='All quantities that are used by this entry.',
+        a_elasticsearch=QuantitySearch())
+
     ems = metainfo.SubSection(sub_section=EMSMetadata, a_search=Search())
     dft = metainfo.SubSection(sub_section=DFTMetadata, a_search=Search(), categories=[FastAccess])
     qcms = metainfo.SubSection(sub_section=QCMSMetadata, a_search=Search())
@@ -674,6 +696,36 @@ class EntryMetadata(metainfo.MSection):
 
         domain_section.apply_domain_metadata(archive)
 
+        quantities = set()
+        n_quantities = 0
+
+        section_paths = {}
+
+        def get_section_path(section):
+            section_path = section_paths.get(section)
+            if section_path is None:
+                parent = section.m_parent
+                if parent:
+                    parent_path = get_section_path(parent)
+                    if parent_path == '':
+                        section_path = section.m_parent_sub_section.name
+                    else:
+                        section_path = f'{parent_path}.{section.m_parent_sub_section.name}'
+                else:
+                    section_path = ''
+                section_paths[section] = section_path
+                quantities.add(section_path)
+
+            return section_path
+
+        for section, property_def, _ in archive.m_traverse():
+            quantity_path = f'{get_section_path(section)}.{property_def.name}'
+            quantities.add(quantity_path)
+            n_quantities += 1
+
+        self.quantities = list(quantities)
+        self.n_quantities = n_quantities
+
 
 class EntryArchive(metainfo.MSection):
     entry_id = metainfo.Quantity(
diff --git a/nomad/datamodel/results.py b/nomad/datamodel/results.py
index 1cfa41037a4e94a06278d1725285d0fd3895274c..ffb7ba140215782b4b3a45526a278fe2a03466d5 100644
--- a/nomad/datamodel/results.py
+++ b/nomad/datamodel/results.py
@@ -16,13 +16,13 @@
 # limitations under the License.
 #
 
-from nomad.datamodel.metainfo.common_experimental import Spectrum
 import numpy as np
 from elasticsearch_dsl import Text
 
 from ase.data import chemical_symbols
 
 from nomad import config
+from nomad.datamodel.metainfo.common_experimental import Spectrum
 from nomad.metainfo.elasticsearch_extension import Elasticsearch, material_type, material_entry_type
 
 from nomad.metainfo import (
diff --git a/tests/app/v1/conftest.py b/tests/app/v1/conftest.py
index a0287c50c17a830567c6293e72e1878a4c22e53d..a2202c3146065cf679b22850576736973c6ca993 100644
--- a/tests/app/v1/conftest.py
+++ b/tests/app/v1/conftest.py
@@ -104,13 +104,17 @@ def example_data(elastic_module, raw_files_module, mongo_module, test_user, othe
         entry_id = 'id_%02d' % i
         material_id = 'id_%02d' % (int(math.floor(i / 4)) + 1)
         mainfile = 'test_content/subdir/test_entry_%02d/mainfile.json' % i
+        kwargs = {}
         if i == 11:
             mainfile = 'test_content/subdir/test_entry_10/mainfile_11.json'
+        if i == 1:
+            kwargs['pid'] = '123'
         data.create_entry(
             upload_id='id_published',
             calc_id=entry_id,
             material_id=material_id,
-            mainfile=mainfile)
+            mainfile=mainfile,
+            **kwargs)
 
         if i == 1:
             archive = data.archives[entry_id]
diff --git a/tests/app/v1/routers/common.py b/tests/app/v1/routers/common.py
index 1e54603f93d8833e31d84093419d4e0b76dee0f9..2df46931d70aa9314493695f0670ffde9d09022d 100644
--- a/tests/app/v1/routers/common.py
+++ b/tests/app/v1/routers/common.py
@@ -34,6 +34,7 @@ def post_query_test_parameters(
     program_name = f'{entry_prefix}results.method.simulation.program_name'
     method = f'{entry_prefix}results.method'
     properties = f'{entry_prefix}results.properties'
+    upload_time = f'{entry_prefix}upload_time'
 
     return [
         pytest.param({}, 200, total, id='empty'),
@@ -64,7 +65,10 @@ def post_query_test_parameters(
         pytest.param({method: {'simulation.program_name': 'VASP'}}, 200, total, id='inner-object'),
         pytest.param({f'{properties}.electronic.dos_electronic.spin_polarized': True}, 200, 1, id='nested-implicit'),
         pytest.param({f'{properties}.electronic.dos_electronic': {'spin_polarized': True}}, 200, 1, id='nested-explicit'),
-        pytest.param({properties: {'electronic.dos_electronic': {'spin_polarized': True}}}, 200, 1, id='nested-explicit-explicit')
+        pytest.param({properties: {'electronic.dos_electronic': {'spin_polarized': True}}}, 200, 1, id='nested-explicit-explicit'),
+        pytest.param({f'{upload_time}:gt': '1970-01-01'}, 200, total, id='date-1'),
+        pytest.param({f'{upload_time}:lt': '2099-01-01'}, 200, total, id='date-2'),
+        pytest.param({f'{upload_time}:gt': '2099-01-01'}, 200, 0, id='date-3')
     ]
 
 
@@ -73,6 +77,7 @@ def get_query_test_parameters(
 
     elements = f'{material_prefix}elements'
     n_elements = f'{material_prefix}n_elements'
+    upload_time = f'{entry_prefix}upload_time'
 
     return [
         pytest.param({}, 200, total, id='empty'),
@@ -96,7 +101,8 @@ def get_query_test_parameters(
         pytest.param({'q': f'{n_elements}__gt__2'}, 200, 0, id='q-gt'),
         pytest.param({'q': f'{entry_prefix}upload_time__gt__2014-01-01'}, 200, total, id='datetime'),
         pytest.param({'q': [elements + '__all__H', elements + '__all__O']}, 200, total, id='q-all'),
-        pytest.param({'q': [elements + '__all__H', elements + '__all__X']}, 200, 0, id='q-all')
+        pytest.param({'q': [elements + '__all__H', elements + '__all__X']}, 200, 0, id='q-all'),
+        pytest.param({'q': f'{upload_time}__gt__1970-01-01'}, 200, total, id='date')
     ]
 
 
@@ -454,7 +460,6 @@ def assert_aggregations(
             value = bucket['value']
             if agg_type == 'date_histogram': assert re.match(r'\d{4}\-\d{2}\-\d{2}', value)
             elif agg_type == 'histogram': assert isinstance(value, (float, int))
-            else: assert isinstance(value, str)
 
             for metric in agg.get('metrics', []):
                 assert metric in bucket['metrics']
diff --git a/tests/app/v1/routers/test_auth.py b/tests/app/v1/routers/test_auth.py
index 49b434165eed1e9afec538f7860e4e59ec5fe23e..b82a2f73261eeba55e678bdfe8aeb8fa549a000e 100644
--- a/tests/app/v1/routers/test_auth.py
+++ b/tests/app/v1/routers/test_auth.py
@@ -40,3 +40,9 @@ def test_get_token(client, test_user, http_method):
 @pytest.mark.parametrize('http_method', ['post', 'get'])
 def test_get_token_bad_credentials(client, http_method):
     perform_get_token_test(client, http_method, 401, 'bad', 'credentials')
+
+
+def test_get_signature_token(client, test_user_auth):
+    response = client.get('auth/signature_token', headers=test_user_auth)
+    assert response.status_code == 200
+    assert response.json().get('signature_token') is not None
diff --git a/tests/app/v1/routers/test_datasets.py b/tests/app/v1/routers/test_datasets.py
index 672cc4e80ac4e6f8cbf6c5956abdd0835c099be8..cc501e5cca126464639853bb0d87889505e5874a 100644
--- a/tests/app/v1/routers/test_datasets.py
+++ b/tests/app/v1/routers/test_datasets.py
@@ -114,7 +114,10 @@ def assert_pagination(pagination):
 
 def assert_dataset(dataset, query: Query = None, entries: List[str] = None, n_entries: int = -1, **kwargs):
     for key, value in kwargs.items():
-        assert dataset[key] == value
+        if key == 'prefix':
+            assert dataset['name'].startswith(value)
+        else:
+            assert dataset[key] == value
 
     dataset_id = dataset['dataset_id']
 
@@ -166,7 +169,9 @@ def assert_dataset_deleted(dataset_id):
     pytest.param({}, 4, 200, id='empty'),
     pytest.param({'dataset_id': 'dataset_1'}, 1, 200, id='id'),
     pytest.param({'name': 'test dataset 1'}, 1, 200, id='name'),
+    pytest.param({'prefix': 'test dat'}, 2, 200, id='prefix'),
     pytest.param({'dataset_type': 'foreign'}, 2, 200, id='type'),
+    pytest.param({'doi': 'test_doi'}, 1, 200, id='doi'),
     pytest.param({'dataset_id': 'DOESNOTEXIST'}, 0, 200, id='id-not-exists')
 ])
 def test_datasets(client, data, query, size, status_code):
diff --git a/tests/app/v1/routers/test_entries.py b/tests/app/v1/routers/test_entries.py
index bb8706ac7d9988861420882071a76dedf6c3a8a7..afd0f82457357cc395a8a492f06dc891bede6668 100644
--- a/tests/app/v1/routers/test_entries.py
+++ b/tests/app/v1/routers/test_entries.py
@@ -427,7 +427,9 @@ def test_entries_raw(client, data, query, files, total, files_per_entry, status_
     pytest.param({}, {'re_pattern': 'test_entry_02/.*\\.json'}, 1, 1, 200, id='re-filter-entries-and-files'),
     pytest.param({}, {'glob_pattern': '*.json', 're_pattern': '.*\\.aux'}, 23, 4, 200, id='re-overwrites-glob'),
     pytest.param({}, {'re_pattern': '**'}, -1, -1, 422, id='bad-re-pattern'),
-    pytest.param({}, {'compress': True}, 23, 5, 200, id='compress')
+    pytest.param({}, {'compress': True}, 23, 5, 200, id='compress'),
+    pytest.param({}, {'include_files': ['1.aux']}, 23, 1, 200, id='file'),
+    pytest.param({}, {'include_files': ['1.aux', '2.aux']}, 23, 2, 200, id='files')
 ])
 @pytest.mark.parametrize('http_method', ['post', 'get'])
 def test_entries_download_raw(client, data, query, files, total, files_per_entry, status_code, http_method):
@@ -463,7 +465,10 @@ def test_entry_raw(client, data, entry_id, files_per_entry, status_code):
     pytest.param('id_01', {'glob_pattern': '*.json'}, 1, 200, id='glob'),
     pytest.param('id_01', {'re_pattern': '[a-z]*\\.aux'}, 4, 200, id='re'),
     pytest.param('id_01', {'re_pattern': '**'}, -1, 422, id='bad-re-pattern'),
-    pytest.param('id_01', {'compress': True}, 5, 200, id='compress')])
+    pytest.param('id_01', {'compress': True}, 5, 200, id='compress'),
+    pytest.param('id_01', {'include_files': ['1.aux']}, 1, 200, id='file'),
+    pytest.param('id_01', {'include_files': ['1.aux', '2.aux']}, 2, 200, id='files')
+])
 def test_entry_raw_download(client, data, entry_id, files, files_per_entry, status_code):
     response = client.get('entries/%s/raw/download?%s' % (entry_id, urlencode(files, doseq=True)))
     assert_response(response, status_code)
@@ -601,6 +606,19 @@ def test_entry_archive(client, data, entry_id, status_code):
         assert_archive_response(response.json())
 
 
+@pytest.mark.parametrize('entry_id, status_code', [
+    pytest.param('id_01', 200, id='id'),
+    pytest.param('id_02', 404, id='404-not-visible'),
+    pytest.param('doesnotexist', 404, id='404-does-not-exist')])
+def test_entry_archive_download(client, data, entry_id, status_code):
+    response = client.get('entries/%s/archive/download' % entry_id)
+    assert_response(response, status_code)
+    if status_code == 200:
+        archive = response.json()
+        assert 'metadata' in archive
+        assert 'run' in archive
+
+
 @pytest.mark.parametrize('entry_id, required, status_code', [
     pytest.param('id_01', '*', 200, id='full'),
     pytest.param('id_02', '*', 404, id='404'),
@@ -625,7 +643,9 @@ n_elements = 'results.material.n_elements'
 
 
 @pytest.mark.parametrize('query, status_code, total', post_query_test_parameters(
-    'entry_id', total=23, material_prefix='results.material.', entry_prefix=''))
+    'entry_id', total=23, material_prefix='results.material.', entry_prefix='') + [
+    pytest.param({'pid': '123'}, 200, 1, id='number-valued-string')
+])
 @pytest.mark.parametrize('test_method', [
     pytest.param(perform_entries_metadata_test, id='metadata'),
     pytest.param(perform_entries_raw_download_test, id='raw-download'),
diff --git a/tests/app/v1/routers/test_entries_edit.py b/tests/app/v1/routers/test_entries_edit.py
new file mode 100644
index 0000000000000000000000000000000000000000..032b75c5385dad7973f84a09e57837eb8db7ff17
--- /dev/null
+++ b/tests/app/v1/routers/test_entries_edit.py
@@ -0,0 +1,301 @@
+#
+# Copyright The NOMAD Authors.
+#
+# This file is part of NOMAD. See https://nomad-lab.eu for further info.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+import pytest
+
+from nomad import utils
+from nomad.search.v1 import search
+from nomad.datamodel import Dataset
+from nomad import processing as proc
+
+from tests.utils import ExampleData
+
+
+logger = utils.get_logger(__name__)
+
+
+class TestEditRepo():
+
+    def query(self, *uploads):
+        return {'upload_id:any': uploads}
+
+    @pytest.fixture(autouse=True)
+    def set_api(self, client, elastic, mongo):
+        self.api = client
+
+    @pytest.fixture(autouse=True)
+    def example_datasets(self, test_user, other_test_user, mongo):
+        self.example_dataset = Dataset(
+            dataset_id='example_ds', name='example_ds', user_id=test_user.user_id)
+        self.example_dataset.a_mongo.create()
+
+        self.other_example_dataset = Dataset(
+            dataset_id='other_example_ds', name='other_example_ds',
+            user_id=other_test_user.user_id)
+        self.other_example_dataset.a_mongo.create()
+
+    @pytest.fixture(autouse=True)
+    def example_data(self, test_user, other_test_user, raw_files, elastic, mongo):
+        # TODO
+        example_data = ExampleData()
+
+        example_data.create_entry(
+            upload_id='upload_1', uploader=test_user, published=True, with_embargo=False)
+        example_data.create_entry(
+            upload_id='upload_2', uploader=test_user, published=True, with_embargo=True)
+        example_data.create_entry(
+            upload_id='upload_2', uploader=test_user, published=True, with_embargo=True)
+        example_data.create_entry(
+            upload_id='upload_3', uploader=other_test_user, published=True, with_embargo=False)
+
+        example_data.save()
+
+    @pytest.fixture(autouse=True)
+    def auth(self, test_user_auth):
+        self.test_user_auth = test_user_auth
+
+    def perform_edit(self, query=None, verify=False, **kwargs):
+        actions = {}
+        for key, value in kwargs.items():
+            if isinstance(value, list):
+                actions[key] = [dict(value=i) for i in value]
+            else:
+                actions[key] = dict(value=value)
+
+        data = dict(actions=actions)
+        if query is not None:
+            data.update(query=query)
+        if verify:
+            data.update(verify=verify)
+
+        return self.api.post('entries/edit', headers=self.test_user_auth, json=data)
+
+    def assert_edit(self, rv, quantity: str, success: bool, message: bool, status_code: int = 200):
+        data = rv.json()
+        assert rv.status_code == status_code, data
+        actions = data.get('actions')
+        assert actions is not None
+        assert [quantity] == list(actions.keys())
+        quantity_actions = actions[quantity]
+        if not isinstance(quantity_actions, list):
+            quantity_actions = [quantity_actions]
+        has_failure = False
+        has_message = False
+        for action in quantity_actions:
+            has_failure = has_failure or not action['success']
+            has_message = has_message or ('message' in action)
+        assert not has_failure == success
+        assert has_message == message
+
+    def mongo(self, *args, edited: bool = True, **kwargs):
+        for calc_id in args:
+            calc = proc.Calc.objects(calc_id='test_entry_id_%d' % calc_id).first()
+            assert calc is not None
+            metadata = calc.metadata
+            if edited:
+                assert metadata.get('last_edit') is not None
+            for key, value in kwargs.items():
+                if metadata.get(key) != value:
+                    return False
+        return True
+
+    def assert_elastic(self, *args, invert: bool = False, **kwargs):
+        def assert_entry(get_entries):
+            for arg in args:
+                entry_id = 'test_entry_id_%d' % arg
+                entries = list(get_entries(entry_id))
+                assert len(entries) > 0, entry_id
+                for entry in entries:
+                    for key, value in kwargs.items():
+                        if key in ['authors', 'owners']:
+                            ids = [user['user_id'] for user in entry.get(key)]
+                            if ids != value:
+                                return False
+                        else:
+                            if entry.get(key) != value:
+                                return False
+
+            return True
+
+        # test v1 data
+        assert invert != assert_entry(
+            lambda id: search(owner=None, query=dict(entry_id=id)).data)
+
+    def test_edit_all_properties(self, test_user, other_test_user):
+        edit_data = dict(
+            comment='test_edit_props',
+            references=['http://test', 'http://test2'],
+            coauthors=[other_test_user.user_id],
+            shared_with=[other_test_user.user_id])
+        rv = self.perform_edit(**edit_data, query=self.query('upload_1'))
+        result = rv.json()
+        assert rv.status_code == 200, result
+        actions = result.get('actions')
+        for key in edit_data:
+            assert key in actions
+            quantity_actions = actions.get(key)
+            if not isinstance(quantity_actions, list):
+                quantity_actions = [quantity_actions]
+            for quantity_action in quantity_actions:
+                assert quantity_action['success']
+
+        assert self.mongo(1, comment='test_edit_props')
+        assert self.mongo(1, references=['http://test', 'http://test2'])
+        assert self.mongo(1, coauthors=[other_test_user.user_id])
+        assert self.mongo(1, shared_with=[other_test_user.user_id])
+
+        self.assert_elastic(1, comment='test_edit_props')
+        self.assert_elastic(1, references=['http://test', 'http://test2'])
+        self.assert_elastic(1, authors=[test_user.user_id, other_test_user.user_id])
+        self.assert_elastic(1, owners=[test_user.user_id, other_test_user.user_id])
+
+        edit_data = dict(
+            comment='',
+            references=[],
+            coauthors=[],
+            shared_with=[])
+        rv = self.perform_edit(**edit_data, query=self.query('upload_1'))
+        result = rv.json()
+        assert rv.status_code == 200
+        actions = result.get('actions')
+        for key in edit_data:
+            assert key in actions
+            quantity_actions = actions.get(key)
+            if not isinstance(quantity_actions, list):
+                quantity_actions = [quantity_actions]
+            for quantity_action in quantity_actions:
+                assert quantity_action['success']
+
+        assert self.mongo(1, comment=None)
+        assert self.mongo(1, references=[])
+        assert self.mongo(1, coauthors=[])
+        assert self.mongo(1, shared_with=[])
+
+        self.assert_elastic(1, comment=None)
+        self.assert_elastic(1, references=[])
+        self.assert_elastic(1, authors=[test_user.user_id])
+        self.assert_elastic(1, owners=[test_user.user_id])
+
+    def test_edit_all(self):
+        rv = self.perform_edit(comment='test_edit_all')
+        self.assert_edit(rv, quantity='comment', success=True, message=False)
+        assert self.mongo(1, 2, 3, comment='test_edit_all')
+        self.assert_elastic(1, 2, 3, comment='test_edit_all')
+        assert not self.mongo(4, comment='test_edit_all', edited=False)
+        self.assert_elastic(4, comment='test_edit_all', edited=False, invert=True)
+
+    def test_edit_multi(self):
+        rv = self.perform_edit(comment='test_edit_multi', query=self.query('upload_1', 'upload_2'))
+        self.assert_edit(rv, quantity='comment', success=True, message=False)
+        assert self.mongo(1, 2, 3, comment='test_edit_multi')
+        self.assert_elastic(1, 2, 3, comment='test_edit_multi')
+        assert not self.mongo(4, comment='test_edit_multi', edited=False)
+        self.assert_elastic(4, comment='test_edit_multi', edited=False, invert=True)
+
+    def test_edit_some(self):
+        rv = self.perform_edit(comment='test_edit_some', query=self.query('upload_1'))
+        self.assert_edit(rv, quantity='comment', success=True, message=False)
+        assert self.mongo(1, comment='test_edit_some')
+        self.assert_elastic(1, comment='test_edit_some')
+        assert not self.mongo(2, 3, 4, comment='test_edit_some', edited=False)
+        self.assert_elastic(2, 3, 4, comment='test_edit_some', edited=False, invert=True)
+
+    def test_edit_verify(self):
+        rv = self.perform_edit(
+            comment='test_edit_verify', verify=True, query=self.query('upload_1'))
+        self.assert_edit(rv, quantity='comment', success=True, message=False)
+        assert not self.mongo(1, comment='test_edit_verify', edited=False)
+
+    def test_edit_empty_list(self, other_test_user):
+        rv = self.perform_edit(coauthors=[other_test_user.user_id], query=self.query('upload_1'))
+        self.assert_edit(rv, quantity='coauthors', success=True, message=False)
+        rv = self.perform_edit(coauthors=[], query=self.query('upload_1'))
+        self.assert_edit(rv, quantity='coauthors', success=True, message=False)
+        assert self.mongo(1, coauthors=[])
+
+    def test_edit_duplicate_value(self, other_test_user):
+        rv = self.perform_edit(coauthors=[other_test_user.user_id, other_test_user.user_id], query=self.query('upload_1'))
+        self.assert_edit(rv, status_code=400, quantity='coauthors', success=False, message=True)
+
+    def test_edit_uploader_as_coauthor(self, test_user):
+        rv = self.perform_edit(coauthors=[test_user.user_id], query=self.query('upload_1'))
+        self.assert_edit(rv, status_code=400, quantity='coauthors', success=False, message=True)
+
+    def test_edit_ds(self):
+        rv = self.perform_edit(
+            datasets=[self.example_dataset.name], query=self.query('upload_1'))
+        self.assert_edit(rv, quantity='datasets', success=True, message=False)
+        assert self.mongo(1, datasets=[self.example_dataset.dataset_id])
+
+    def test_edit_ds_remove_doi(self):
+        rv = self.perform_edit(
+            datasets=[self.example_dataset.name], query=self.query('upload_1'))
+
+        assert rv.status_code == 200
+        rv = self.api.post('datasets/%s/doi' % self.example_dataset.name, headers=self.test_user_auth)
+        assert rv.status_code == 200
+        rv = self.perform_edit(datasets=[], query=self.query('upload_1'))
+        assert rv.status_code == 400
+        data = rv.json()
+        assert not data['success']
+        assert self.example_dataset.name in data['message']
+        assert Dataset.m_def.a_mongo.get(dataset_id=self.example_dataset.dataset_id) is not None
+
+    def test_edit_ds_remove(self):
+        rv = self.perform_edit(
+            datasets=[self.example_dataset.name], query=self.query('upload_1'))
+        assert rv.status_code == 200
+        rv = self.perform_edit(datasets=[], query=self.query('upload_1'))
+        assert rv.status_code == 200
+        with pytest.raises(KeyError):
+            assert Dataset.m_def.a_mongo.get(dataset_id=self.example_dataset.dataset_id) is None
+
+    def test_edit_ds_user_namespace(self, test_user):
+        assert Dataset.m_def.a_mongo.objects(
+            name=self.other_example_dataset.name).first() is not None
+
+        rv = self.perform_edit(
+            datasets=[self.other_example_dataset.name], query=self.query('upload_1'))
+
+        self.assert_edit(rv, quantity='datasets', success=True, message=True)
+        new_dataset = Dataset.m_def.a_mongo.objects(
+            name=self.other_example_dataset.name,
+            user_id=test_user.user_id).first()
+        assert new_dataset is not None
+        assert self.mongo(1, datasets=[new_dataset.dataset_id])
+
+    def test_edit_new_ds(self, test_user):
+        rv = self.perform_edit(datasets=['new_dataset'], query=self.query('upload_1'))
+        self.assert_edit(rv, quantity='datasets', success=True, message=True)
+        new_dataset = Dataset.m_def.a_mongo.objects(name='new_dataset').first()
+        assert new_dataset is not None
+        assert new_dataset.user_id == test_user.user_id
+        assert self.mongo(1, datasets=[new_dataset.dataset_id])
+
+    def test_edit_bad_user(self):
+        rv = self.perform_edit(coauthors=['bad_user'], query=self.query('upload_1'))
+        self.assert_edit(rv, status_code=400, quantity='coauthors', success=False, message=True)
+
+    def test_edit_user(self, other_test_user):
+        rv = self.perform_edit(coauthors=[other_test_user.user_id], query=self.query('upload_1'))
+        self.assert_edit(rv, quantity='coauthors', success=True, message=False)
+
+    @pytest.mark.skip(reason='Not necessary during transition. Fails because uploader is not editable anyways.')
+    def test_admin_only(self, other_test_user):
+        rv = self.perform_edit(uploader=other_test_user.user_id)
+        assert rv.status_code != 200
diff --git a/tests/app/v1/routers/test_info.py b/tests/app/v1/routers/test_info.py
new file mode 100644
index 0000000000000000000000000000000000000000..73007bb34e40aef157b7376b6b72435863c48f57
--- /dev/null
+++ b/tests/app/v1/routers/test_info.py
@@ -0,0 +1,33 @@
+#
+# Copyright The NOMAD Authors.
+#
+# This file is part of NOMAD. See https://nomad-lab.eu for further info.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+
+def test_info(client, elastic):
+    rv = client.get('info')
+    assert rv.status_code == 200
+
+    data = rv.json()
+    assert 'codes' in data
+    assert 'parsers' in data
+    assert 'statistics' in data
+    assert len(data['parsers']) >= len(data['codes'])
+    assert len(data['domains']) >= 1
+    assert rv.status_code == 200
+
+    rv = client.get('info')
+    assert rv.status_code == 200
diff --git a/tests/app/v1/routers/test_users.py b/tests/app/v1/routers/test_users.py
index f1c513e6dfc2b726d4043b4abf840d40e06aee77..d08aadc6b6a2f11212493c23e2fbc6ac5b70a69b 100644
--- a/tests/app/v1/routers/test_users.py
+++ b/tests/app/v1/routers/test_users.py
@@ -30,3 +30,35 @@ def test_me_auth_required(client):
 def test_me_auth_bad_token(client):
     response = client.get('users/me', headers={'Authentication': 'Bearer NOTATOKEN'})
     assert response.status_code == 401
+
+
+def test_invite(client, test_user_auth, no_warn):
+    rv = client.put(
+        'users/invite', headers=test_user_auth, json={
+            'first_name': 'John',
+            'last_name': 'Doe',
+            'affiliation': 'Affiliation',
+            'email': 'john.doe@affiliation.edu'
+        })
+    assert rv.status_code == 200
+    data = rv.json()
+    keys = data.keys()
+    required_keys = ['name', 'email', 'user_id']
+    assert all(key in keys for key in required_keys)
+
+
+def test_users(client):
+    rv = client.get('users?prefix=Sheldon')
+    assert rv.status_code == 200
+
+    data = rv.json()
+    assert len(data['data']) == 1
+    user = data['data'][0]
+
+    for key in ['name', 'user_id']:
+        assert key in user
+
+    for value in user.values():
+        assert value is not None
+
+    assert 'email' not in user
diff --git a/tests/normalizing/test_system.py b/tests/normalizing/test_system.py
index 49dde1d4bab6e3741605eebcb3d24ff2ee842b75..cee4934f0566c50cf8944258762426dae164b0f3 100644
--- a/tests/normalizing/test_system.py
+++ b/tests/normalizing/test_system.py
@@ -116,6 +116,10 @@ def assert_normalized(entry_archive: datamodel.EntryArchive):
 
         assert metadata[key] != config.services.unavailable_value, '%s must not be unavailable' % key
 
+    assert entry_archive.metadata
+    assert entry_archive.metadata.quantities
+    assert len(entry_archive.metadata.quantities) > 0
+
     # check if the result can be dumped
     dump_json(entry_archive.m_to_dict())
 
diff --git a/tests/processing/test_data.py b/tests/processing/test_data.py
index e242a31825296aba59c798da7fa8c6d21f95417e..8f9cb3563d278bd77ebf69f5c2ed7c96bdb15eba 100644
--- a/tests/processing/test_data.py
+++ b/tests/processing/test_data.py
@@ -126,7 +126,10 @@ def assert_processing(upload: Upload, published: bool = False, process='process_
             with upload_files.raw_file(path) as f:
                 f.read()
 
-        # check some domain metadata
+        # check some (domain) metadata
+        assert entry_metadata.quantities
+        assert len(entry_metadata.quantities) > 0
+
         assert entry_metadata.n_atoms > 0
         assert len(entry_metadata.atoms) > 0
         assert len(entry_metadata.processing_errors) == 0