diff --git a/arkindex/project/api_v1.py b/arkindex/project/api_v1.py
index 28513017b9bf4b43a669921f788bd60a7aae52eb..d0eeb0b6d5fed58ac681e4b819dfd26935e5f657 100644
--- a/arkindex/project/api_v1.py
+++ b/arkindex/project/api_v1.py
@@ -1,5 +1,9 @@
 from django.urls import path
 from django.views.generic.base import RedirectView
+from django.views.decorators.cache import cache_page
+from rest_framework.schemas import get_schema_view
+from arkindex.project.openapi import SchemaGenerator
+import arkindex
 
 from arkindex.documents.api.elements import (
     ElementsList, RelatedElementsList, ElementRetrieve, CorpusList, CorpusRetrieve, ElementTranscriptions,
@@ -32,6 +36,9 @@ from arkindex.users.api import (
     UserRetrieve, UserCreate, UserEmailLogin, UserEmailVerification, PasswordReset, PasswordResetConfirm,
 )
 
+# Cache the OpenAPI schema view for a day
+schema_view = cache_page(86400, key_prefix=arkindex.VERSION)(get_schema_view(generator_class=SchemaGenerator))
+
 api = [
 
     # Elements
@@ -160,4 +167,7 @@ api = [
 
     # Management tools
     path('reindex/', ReindexStart.as_view(), name='reindex-start'),
+
+    # OpenAPI Schema
+    path('openapi/', schema_view, name='openapi-schema'),
 ]
diff --git a/arkindex/project/openapi/generators.py b/arkindex/project/openapi/generators.py
index 11b239f991bc695f5a1047b0c36142ec7ffa71e1..2e2a58ea35bbaa391a41101d4256bcad5345ebfc 100644
--- a/arkindex/project/openapi/generators.py
+++ b/arkindex/project/openapi/generators.py
@@ -7,14 +7,32 @@ PATCH_FILE = Path(__file__).absolute().parent / 'patch.yml'
 
 
 class SchemaGenerator(BaseSchemaGenerator):
+    """
+    Custom schema generator that applies the custom patches from patch.yml.
+
+    Django REST Framework allows generating schemas depending on the authentication;
+    if a user is not an admin for example, the permissions will be checked, and some
+    endpoints may be ignored.  While this can be useful, it makes schema generation-
+    specific code in views or serializers, for example for default values, much more
+    complex as there are more specific cases to gracefully handle, and could require
+    re-downloading the schema in the API client on each login/logout.
+    This can also help with caching as the generated schema simply never changes.
+
+    Therefore, to avoid this situation, we ask DRF to generate "public" schemas all
+    the time, as if we were running manage.py generateschema.
+    """
+
+    def get_schema(self, *args, **kwargs):
+        # Always send public=True and no request
+        schema = super().get_schema(request=None, public=False)
 
-    def get_schema(self, **kwargs):
         with PATCH_FILE.open() as f:
             patch = yaml.safe_load(f)
-        schema = super().get_schema(**kwargs)
+
         self.patch_paths(schema['paths'], patch.pop('paths', {}))
         schema.update(patch)
         schema['info']['version'] = arkindex.VERSION
+
         return schema
 
     def patch_paths(self, paths, patches):
diff --git a/arkindex/project/openapi/patch.yml b/arkindex/project/openapi/patch.yml
index 1ef26a664196ea5610d30e2d7ce9594b20d9cbec..660a1fbde78e635f09b186837eb6c24755f8077e 100644
--- a/arkindex/project/openapi/patch.yml
+++ b/arkindex/project/openapi/patch.yml
@@ -3,7 +3,7 @@ info:
   contact:
     name: Teklia
     url: https://www.teklia.com/
-    email: paris@teklia.com
+    email: contact@teklia.com
 components:
   securitySchemes:
     sessionAuth:
diff --git a/arkindex/project/tests/openapi/__init__.py b/arkindex/project/tests/openapi/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/arkindex/project/tests/openapi/test_generators.py b/arkindex/project/tests/openapi/test_generators.py
new file mode 100644
index 0000000000000000000000000000000000000000..24547721fe4ab0a0e79231eb7699882a03fff93d
--- /dev/null
+++ b/arkindex/project/tests/openapi/test_generators.py
@@ -0,0 +1,90 @@
+from unittest import TestCase
+from unittest.mock import patch
+from arkindex.project.openapi import SchemaGenerator
+
+
+class TestSchemaGenerator(TestCase):
+
+    @patch('arkindex.project.openapi.generators.arkindex.VERSION', new='0.0')
+    @patch('arkindex.project.openapi.generators.yaml.safe_load', return_value={})
+    def test_auto_version(self, mock):
+        """
+        Ensure the schema version is set to the auto-detected backend's version
+        """
+        schema = SchemaGenerator().get_schema()
+        self.assertEqual(schema['info']['version'], '0.0')
+
+    def test_patch_paths_assertions(self):
+        gen = SchemaGenerator()
+
+        with self.assertRaisesRegex(AssertionError, 'OpenAPI path /somewhere/ does not exist'):
+            gen.patch_paths(
+                paths={},
+                patches={'/somewhere/': {}}
+            )
+
+        with self.assertRaisesRegex(AssertionError, 'Method patch on OpenAPI path /somewhere/ does not exist'):
+            gen.patch_paths(
+                paths={
+                    '/somewhere/': {
+                        'get': {}
+                    }
+                },
+                patches={
+                    '/somewhere/': {
+                        'patch': {}
+                    }
+                }
+            )
+
+    def test_patch_paths(self):
+        patches = {
+            '/somewhere/': {
+                'get': {
+                    'operationId': 'PatchedSomewhere'
+                },
+                'post': {
+                    'responses': {
+                        '409': {
+                            'description': 'Conflict'
+                        }
+                    }
+                }
+            }
+        }
+        expected = {
+            '/somewhere/': {
+                'get': {
+                    'operationId': 'PatchedSomewhere'
+                },
+                'post': {
+                    'operationId': 'PostSomewhere',
+                    'responses': {
+                        '418': {
+                            'description': "I'm a teapot"
+                        },
+                        '409': {
+                            'description': 'Conflict'
+                        }
+                    }
+                }
+            }
+        }
+        actual = {
+            '/somewhere/': {
+                'get': {
+                    'operationId': 'GetSomewhere',
+                },
+                'post': {
+                    'operationId': 'PostSomewhere',
+                    'responses': {
+                        '418': {
+                            'description': "I'm a teapot",
+                        }
+                    }
+                }
+            }
+        }
+        SchemaGenerator().patch_paths(actual, patches)
+
+        self.assertDictEqual(actual, expected)
diff --git a/arkindex/project/tests/test_openapi.py b/arkindex/project/tests/openapi/test_schemas.py
similarity index 81%
rename from arkindex/project/tests/test_openapi.py
rename to arkindex/project/tests/openapi/test_schemas.py
index cb5518a99ac7d04c1899c73798b22606dd0e0ba5..cf07415cbbd3d48e12339226e96ef046d6a0283c 100644
--- a/arkindex/project/tests/test_openapi.py
+++ b/arkindex/project/tests/openapi/test_schemas.py
@@ -1,5 +1,4 @@
 from unittest import TestCase
-from unittest.mock import patch
 from django.test import RequestFactory
 from rest_framework import serializers
 from rest_framework.request import Request
@@ -24,7 +23,7 @@ def create_view(view_cls, method, request):
     return view
 
 
-class TestOpenAPI(TestCase):
+class TestAutoSchema(TestCase):
 
     def test_no_deprecated(self):
         """
@@ -370,87 +369,3 @@ class TestOpenAPI(TestCase):
                 }
             }
         )
-
-    @patch('arkindex.project.openapi.generators.arkindex.VERSION', new='0.0')
-    @patch('arkindex.project.openapi.generators.yaml.safe_load', return_value={})
-    def test_auto_version(self, mock):
-        """
-        Ensure the schema version is set to the auto-detected backend's version
-        """
-        schema = SchemaGenerator().get_schema()
-        self.assertEqual(schema['info']['version'], '0.0')
-
-    def test_patch_paths_assertions(self):
-        gen = SchemaGenerator()
-
-        with self.assertRaisesRegex(AssertionError, 'OpenAPI path /somewhere/ does not exist'):
-            gen.patch_paths(
-                paths={},
-                patches={'/somewhere/': {}}
-            )
-
-        with self.assertRaisesRegex(AssertionError, 'Method patch on OpenAPI path /somewhere/ does not exist'):
-            gen.patch_paths(
-                paths={
-                    '/somewhere/': {
-                        'get': {}
-                    }
-                },
-                patches={
-                    '/somewhere/': {
-                        'patch': {}
-                    }
-                }
-            )
-
-    def test_patch_paths(self):
-        patches = {
-            '/somewhere/': {
-                'get': {
-                    'operationId': 'PatchedSomewhere'
-                },
-                'post': {
-                    'responses': {
-                        '409': {
-                            'description': 'Conflict'
-                        }
-                    }
-                }
-            }
-        }
-        expected = {
-            '/somewhere/': {
-                'get': {
-                    'operationId': 'PatchedSomewhere'
-                },
-                'post': {
-                    'operationId': 'PostSomewhere',
-                    'responses': {
-                        '418': {
-                            'description': "I'm a teapot"
-                        },
-                        '409': {
-                            'description': 'Conflict'
-                        }
-                    }
-                }
-            }
-        }
-        actual = {
-            '/somewhere/': {
-                'get': {
-                    'operationId': 'GetSomewhere',
-                },
-                'post': {
-                    'operationId': 'PostSomewhere',
-                    'responses': {
-                        '418': {
-                            'description': "I'm a teapot",
-                        }
-                    }
-                }
-            }
-        }
-        SchemaGenerator().patch_paths(actual, patches)
-
-        self.assertDictEqual(actual, expected)
diff --git a/arkindex/project/tests/openapi/test_view.py b/arkindex/project/tests/openapi/test_view.py
new file mode 100644
index 0000000000000000000000000000000000000000..8060fd626383d1ae536d1ab879341770b6fdbe46
--- /dev/null
+++ b/arkindex/project/tests/openapi/test_view.py
@@ -0,0 +1,39 @@
+from arkindex.project.tests import FixtureAPITestCase
+from io import StringIO
+from django.core.management import call_command
+from django.urls import reverse
+from rest_framework import status
+
+
+class TestSchemaView(FixtureAPITestCase):
+
+    def test_constant_schema(self):
+        """
+        Ensure that what a SchemaView outputs is always exactly the same
+        as what manage.py generateschema outputs.
+
+        Note that we use the JSON output format instead of the normal YAML because JSON will not allow
+        Python-specific objects, while PyYAML would use YAML tags such as "!!python/object",
+        and it is much faster to render.
+        """
+        stdout = StringIO()
+        call_command(
+            'generateschema',
+            generator_class='arkindex.project.openapi.SchemaGenerator',
+            format='openapi-json',
+            stdout=stdout,
+        )
+        expected_schema = stdout.getvalue().strip()
+
+        def _test_schema():
+            response = self.client.get(reverse('api:openapi-schema') + '?format=openapi-json')
+            self.assertEqual(response.status_code, status.HTTP_200_OK)
+            self.assertEqual(response.content.decode('utf-8'), expected_schema)
+
+        _test_schema()
+
+        self.client.force_login(self.user)
+        _test_schema()
+
+        self.client.force_login(self.superuser)
+        _test_schema()