diff --git a/docs/howto/plugins/apis.md b/docs/howto/plugins/apis.md
new file mode 100644
index 0000000000000000000000000000000000000000..50e28d79e51465c97a0770f282add396a9a54147
--- /dev/null
+++ b/docs/howto/plugins/apis.md
@@ -0,0 +1,96 @@
+# How to write an API
+
+APIs allow you to add more APIs to the NOMAD app. More specifically you can create
+a [FastAPI](https://fastapi.tiangolo.com) apps that can be mounted into the main NOMAD app alongside other apis
+such as `/api/v1`, `/optimade`, etc.
+
+This documentation shows you how to write a plugin entry point for an API.
+You should read the [documentation on getting started with plugins](./plugins.md)
+to have a basic understanding of how plugins and plugin entry points work in the NOMAD ecosystem.
+
+## Getting started
+
+You can use our [template repository](https://github.com/FAIRmat-NFDI/nomad-plugin-template) to
+create an initial structure for a plugin containing an API.
+The relevant part of the repository layout will look something like this:
+
+```txt
+nomad-example
+   ├── src
+   │   ├── nomad_example
+   │   │   ├── apis
+   │   │   │   ├── __init__.py
+   │   │   │   ├── myapi.py
+   ├── LICENSE.txt
+   ├── README.md
+   └── pyproject.toml
+```
+
+See the documentation on [plugin development guidelines](./plugins.md#plugin-development-guidelines)
+for more details on the best development practices for plugins, including linting, testing and documenting.
+
+## API entry point
+
+The entry point defines basic information about your API and is used to automatically
+load it into a NOMAD distribution. It is an instance of a `APIEntryPoint` or its subclass and it contains a `load` method which returns a `fastapi.FastAPI` app instance.
+Furthermore, it allows you to define a path prefix for your API.
+The entry point should be defined in `*/apis/__init__.py` like this:
+
+```python
+from pydantic import Field
+from nomad.config.models.plugins import APIEntryPoint
+
+
+class MyAPIEntryPoint(APIEntryPoint):
+
+    def load(self):
+        from nomad_example.apis.myapi import app
+
+        return app
+
+
+myapi = MyAPIEntryPoint(
+    prefix = 'myapi',
+    name = 'MyAPI',
+    description = 'My custom API.',
+)
+```
+
+Here you can see that a new subclass of `APIEntryPoint` was defined. In this new class you have to override the `load` method to determine the FastAPI app that makes your API.
+In the reference you can see all of the available [configuration options for a `APIEntryPoint`](../../reference/plugins.md#apientrypoint).
+
+The entry point instance should then be added to the `[project.entry-points.'nomad.plugin']` table in `pyproject.toml` in order for it to be automatically detected:
+
+```toml
+[project.entry-points.'nomad.plugin']
+myapi = "nomad_example.apis:myapi"
+```
+
+## The FastAPI app
+
+The `load`-method of an API entry point has to return an instance of a `fastapi.FastAPI`.
+This app should be implemented in a separate file (e.g. `*/apis/myapi.py`) and could look like this:
+
+```python
+from fastapi import FastAPI
+from nomad.config import config
+
+myapi_entry_point = config.get_plugin_entry_point('nomad_example.apis:myapi')
+
+app = FastAPI(
+    root_path=f'{config.services.api_base}/{myapi_entry_points.prefix}'
+)
+
+app.get('/')
+async def root():
+    return {"message": "Hello World"}
+```
+
+Read the official [FastAPI documentation](https://fastapi.tiangolo.com/tutorial/) to learn how to build apps and APIs with
+FastAPI.
+
+If you run NOMAD with this plugin following our [Oasis installation documentation](../oasis/install.md) and our [plugin installation documentation](../oasis/plugins_install.md), you can curl this API and should receive the message:
+
+```sh
+curl localhost/nomad-oasis/myapi
+```
diff --git a/docs/reference/plugins.md b/docs/reference/plugins.md
index 1178e2eec3fb6b925f73ba50791d5a9a6fd5413c..5b7324de2491114493f1c15610e4277111e053f2 100644
--- a/docs/reference/plugins.md
+++ b/docs/reference/plugins.md
@@ -15,6 +15,7 @@ This is a list of the available plugin entry point configuration models.
 {{ pydantic_model('nomad.config.models.plugins.NormalizerEntryPoint') }}
 {{ pydantic_model('nomad.config.models.plugins.ParserEntryPoint') }}
 {{ pydantic_model('nomad.config.models.plugins.SchemaPackageEntryPoint') }}
+{{ pydantic_model('nomad.config.models.plugins.APIEntryPoint') }}
 
 ## Default plugin entry points
 
diff --git a/mkdocs.yml b/mkdocs.yml
index 13bc1d39cc34960163f093714320c9c8814bfce9..dd8e8e8603e07591967a3bbf53c10dc8ffe3924f 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -40,6 +40,7 @@ nav:
           - Write a normalizer: howto/plugins/normalizers.md
           - Write a parser: howto/plugins/parsers.md
           - Write a schema package: howto/plugins/schema_packages.md
+          - Write an API: howto/plugins/apis.md
       - Customization:
           - Write a YAML schema package: howto/customization/basics.md
           - Define ELNs: howto/customization/elns.md
diff --git a/nomad/app/main.py b/nomad/app/main.py
index d916e20773b1698f1c20d8dfc2eb2f66608add73..2168e5fd965f7e7bd939d5779e7f707dd2cf7c6f 100644
--- a/nomad/app/main.py
+++ b/nomad/app/main.py
@@ -29,6 +29,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
 
 from nomad import infrastructure
 from nomad.config import config
+from nomad.config.models.plugins import APIEntryPoint
 
 from .v1.main import app as v1_app
 from .static import app as static_files_app, GuiFiles
@@ -96,6 +97,12 @@ if config.resources.enabled:
 
     app.mount(f'{app_base}/resources', resources_app)
 
+# Add API plugins
+for entry_point in config.plugins.entry_points.filtered_values():
+    if isinstance(entry_point, APIEntryPoint):
+        api_app = entry_point.load()
+        app.mount(f'{app_base}/{entry_point.prefix}', api_app)
+
 # Make sure to mount this last, as it is a catch-all routes that are not yet mounted.
 app.mount(app_base, static_files_app)
 
diff --git a/nomad/app/v1/routers/entries.py b/nomad/app/v1/routers/entries.py
index c921c0edbc4dbea73f04fe2245936da63b662c55..53ebf6d4f2f685c41cfe8b6b19cb59863163978d 100644
--- a/nomad/app/v1/routers/entries.py
+++ b/nomad/app/v1/routers/entries.py
@@ -1506,7 +1506,7 @@ async def post_entry_edit(
             key = to_key(path_segment)
             repeated_sub_section = isinstance(next_key, int)
 
-            next_value = [] if repeated_sub_section else {}
+            next_value: Union[list, dict] = [] if repeated_sub_section else {}
 
             if isinstance(section_data, list):
                 if section_data[key] is None:
diff --git a/nomad/config/models/plugins.py b/nomad/config/models/plugins.py
index 9416446d42edcbcf0c8c8c62737ce1f1f6bd0669..506114afc6e97b2faba9ed6486086202250cd231 100644
--- a/nomad/config/models/plugins.py
+++ b/nomad/config/models/plugins.py
@@ -37,6 +37,7 @@ if TYPE_CHECKING:
     from nomad.metainfo import SchemaPackage
     from nomad.normalizing import Normalizer as NormalizerBaseClass
     from nomad.parsing import Parser as ParserBaseClass
+    from fastapi import FastAPI
 
 
 class EntryPoint(BaseModel):
@@ -299,6 +300,44 @@ class ExampleUploadEntryPoint(EntryPoint):
         )
 
 
+class APIEntryPoint(EntryPoint, metaclass=ABCMeta):
+    """Base model for API plugin entry points."""
+
+    entry_point_type: Literal['api'] = Field(
+        'api', description='Specifies the entry point type.'
+    )
+
+    prefix: str = Field(
+        None,
+        description=(
+            'The prefix for the API. The URL for the API will be the base URL of the NOMAD '
+            'installation followed by this prefix. The prefix must not collide with any other '
+            'API prefixes. There is no default, this field must be set.'
+        ),
+    )
+
+    @root_validator(pre=True)
+    def prefix_must_be_defined_and_valid(cls, v):
+        import urllib.parse
+
+        if 'prefix' not in v:
+            raise ValueError('prefix must be defined')
+        if not v['prefix']:
+            raise ValueError('prefix must be defined')
+        if urllib.parse.quote(v['prefix']) != v['prefix']:
+            raise ValueError('prefix must be a valid URL path')
+
+        v['prefix'] = v['prefix'].strip('/')
+        return v
+
+    @abstractmethod
+    def load(self) -> 'FastAPI':
+        """Used to lazy-load the API instance. You should override this
+        method in your subclass. Note that any Python module imports required
+        for the API should be done within this function as well."""
+        pass
+
+
 class PluginBase(BaseModel):
     """
     Base model for a NOMAD plugin.
@@ -536,6 +575,7 @@ EntryPointType = Union[
     NormalizerEntryPoint,
     AppEntryPoint,
     ExampleUploadEntryPoint,
+    APIEntryPoint,
 ]
 
 
diff --git a/tests/app/v1/test_models.py b/tests/app/v1/test_models.py
index 26627f4384247f4b699f26207f6bc7ba233df027..8b86751a837823ce14ca3ac9bdb454c525fafc16 100644
--- a/tests/app/v1/test_models.py
+++ b/tests/app/v1/test_models.py
@@ -122,7 +122,6 @@ def test_module():
                 '*':
                     entry:
                         process_status: '*'
-                    '*': '*'
             """,
             [
                 'users.m_children.me.uploads.m_request.query.is_published:False',
@@ -134,7 +133,6 @@ def test_module():
                 'users.m_children.me.datasets.m_children.*.doi:*',
                 'search.m_children.*.entry.process_status:*',
                 'search.m_children.*.entry:GraphEntryRequest',
-                'search.m_children.*.*:*',
             ],
             None,
             id='ok',
@@ -184,7 +182,6 @@ def test_validation(request_yaml: str, paths: List[str], error_path: str):
         export_kwargs = dict(
             exclude_unset=True, exclude_defaults=False, exclude_none=False
         )
-        print(request.json(indent=2, **export_kwargs))
         for path in paths:
             assert_path(request, path)
 
diff --git a/tests/config/models/test_plugins.py b/tests/config/models/test_plugins.py
index 9c9018245e38649f292d3bdc578bccc000c8a8f8..5bfda6aec624a9d5049fa0ca5e759e178da9e68f 100644
--- a/tests/config/models/test_plugins.py
+++ b/tests/config/models/test_plugins.py
@@ -21,6 +21,7 @@ import pytest
 
 from nomad.config.models.plugins import (
     ExampleUploadEntryPoint,
+    APIEntryPoint,
     example_upload_path_prefix,
 )
 
@@ -117,3 +118,40 @@ def test_example_upload_entry_point_invalid(config, error, monkeypatch):
             entry_point.load()
 
         assert exc_info.match(error)
+
+
+@pytest.mark.parametrize(
+    'config, error, value',
+    [
+        pytest.param({}, 'prefix must be defined', None, id='prefix-must-be-defined'),
+        pytest.param(
+            {'prefix': None},
+            'prefix must be defined',
+            None,
+            id='prefix-must-not-be-none',
+        ),
+        pytest.param(
+            {'prefix': ''}, 'prefix must be defined', None, id='prefix-must-not-empty'
+        ),
+        pytest.param({'prefix': '/foo/bar/'}, None, 'foo/bar', id='prefix-slashes'),
+        pytest.param(
+            {'prefix': 'not_$url& save'},
+            'prefix must be a valid URL path',
+            None,
+            id='prefix-is-valid-url',
+        ),
+    ],
+)
+def test_api_entry_point_invalid(config, error, value):
+    class MyAPIEntryPoint(APIEntryPoint):
+        def load(self):
+            pass
+
+    if error:
+        with pytest.raises(Exception) as exc_info:
+            MyAPIEntryPoint(**config)
+        assert exc_info.match(error)
+
+    if not error:
+        entry_point = MyAPIEntryPoint(**config)
+        assert entry_point.prefix == value