diff --git a/docs/how-to/modeladmin.md b/docs/how-to/modeladmin.md
new file mode 100644
index 00000000..f7f0f48e
--- /dev/null
+++ b/docs/how-to/modeladmin.md
@@ -0,0 +1,34 @@
+# Translatable ModelAdmin
+
+Wagtail Localize supports translation of custom Wagtail's [ModelAdmin](https://docs.wagtail.org/en/stable/reference/contrib/modeladmin/index.html) registered models.
+
+## Installation
+
+Add `wagtail_localize.modeladmin` to your `INSTALLED_APPS`:
+
+```python
+INSTALLED_APPS = [
+    # ...
+    "wagtail_localize.modeladmin",
+]
+```
+
+## How to use
+
+When registering your custom models you can use the supplied `TranslatableModelAdmin` in place of Wagtail's `ModelAdmin` class.
+
+```python
+from wagtail.contrib.modeladmin.options import modeladmin_register
+from wagtail_localize.modeladmin.options import TranslatableModelAdmin
+
+from .models import MyTranslatableModel
+
+
+class MyTranslatableModelAdmin(TranslatableModelAdmin):
+    model = MyTranslatableModel
+
+
+modeladmin_register(MyTranslatableModelAdmin)
+```
+
+That's it! You can translate your custom ModelAdmin models in the admin dashboard the same way you would Wagtail snippets.
diff --git a/mkdocs.yml b/mkdocs.yml
index d039f578..960a5c54 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -49,6 +49,7 @@ nav:
   - How To Guides:
       - Installation guide: how-to/installation.md
       - Configuring translatable fields: how-to/field-configuration.md
+      - Translatable ModelAdmin: how-to/modeladmin.md
       - Integrations:
           - Pontoon: how-to/integrations/pontoon.md
           - Machine Translation: how-to/integrations/machine-translation.md
diff --git a/wagtail_localize/modeladmin/__init__.py b/wagtail_localize/modeladmin/__init__.py
new file mode 100644
index 00000000..77d021fe
--- /dev/null
+++ b/wagtail_localize/modeladmin/__init__.py
@@ -0,0 +1,10 @@
+from django import VERSION as DJANGO_VERSION
+
+
+if DJANGO_VERSION >= (3, 2):
+    # The declaration is only needed for older Django versions
+    pass
+else:
+    default_app_config = (
+        "wagtail_localize.modeladmin.apps.WagtailLocalizeModelAdminAppConfig"
+    )
diff --git a/wagtail_localize/modeladmin/apps.py b/wagtail_localize/modeladmin/apps.py
new file mode 100644
index 00000000..cf8e18b9
--- /dev/null
+++ b/wagtail_localize/modeladmin/apps.py
@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class WagtailLocalizeModelAdminAppConfig(AppConfig):
+    name = "wagtail_localize.modeladmin"
+    label = "wagtail_localize_modeladmin"
+    verbose_name = "Wagtail localize modeladmin"
diff --git a/wagtail_localize/modeladmin/helpers.py b/wagtail_localize/modeladmin/helpers.py
new file mode 100644
index 00000000..ad3665ee
--- /dev/null
+++ b/wagtail_localize/modeladmin/helpers.py
@@ -0,0 +1,80 @@
+from urllib.parse import urlencode
+
+from django.contrib.admin.utils import quote
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+from wagtail.contrib.modeladmin.helpers import ButtonHelper, PageButtonHelper
+from wagtail.contrib.modeladmin.views import InspectView
+from wagtail.core.models import Locale, Page, TranslatableMixin
+
+from wagtail_localize.models import TranslationSource
+
+
+class TranslatableButtonHelper(ButtonHelper):
+    def get_buttons_for_obj(self, obj, **kwargs):
+        btns = super().get_buttons_for_obj(obj, **kwargs)
+        user = self.request.user
+        next_url = self.request.get_full_path()
+        if isinstance(self.view, InspectView):
+            classname = "button button-secondary"
+        else:
+            classname = "button button-secondary button-small"
+        btns += list(get_translation_buttons(obj, user, next_url, classname))
+        return btns
+
+
+class TranslatablePageButtonHelper(TranslatableButtonHelper, PageButtonHelper):
+    pass
+
+
+def get_translation_buttons(obj, user, next_url=None, classname=""):
+    """
+    Generate listing buttons for translating/syncing objects in modeladmin.
+    """
+    model = type(obj)
+
+    if issubclass(model, TranslatableMixin) and user.has_perm(
+        "wagtail_localize.submit_translation"
+    ):
+
+        # If there's at least one locale that we haven't translated into yet, show "Translate" button
+        if isinstance(obj, Page):
+            has_locale_to_translate_to = Locale.objects.exclude(
+                id__in=obj.get_translations(inclusive=True)
+                .exclude(alias_of__isnull=False)
+                .values_list("locale_id", flat=True)
+            ).exists()
+        else:
+            has_locale_to_translate_to = Locale.objects.exclude(
+                id__in=obj.get_translations(inclusive=True).values_list(
+                    "locale_id", flat=True
+                )
+            ).exists()
+
+        if has_locale_to_translate_to:
+            url = reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=[model._meta.app_label, model._meta.model_name, quote(obj.pk)],
+            )
+            yield {
+                "url": url,
+                "label": _("Translate"),
+                "classname": classname,
+                "title": _("Translate"),
+            }
+
+        # If the object is the source for translations, show "Sync translated" button
+        source = TranslationSource.objects.get_for_instance_or_none(obj)
+        if source is not None and source.translations.filter(enabled=True).exists():
+            url = reverse("wagtail_localize:update_translations", args=[source.id])
+            if next_url is not None:
+                url += "?" + urlencode({"next": next_url})
+
+            yield {
+                "url": url,
+                "label": _("Sync translated %(model_name)s")
+                % {"model_name": obj._meta.verbose_name_plural},
+                "classname": classname,
+                "title": _("Sync translated %(model_name)s")
+                % {"model_name": obj._meta.verbose_name_plural},
+            }
diff --git a/wagtail_localize/modeladmin/options.py b/wagtail_localize/modeladmin/options.py
new file mode 100644
index 00000000..63fdf540
--- /dev/null
+++ b/wagtail_localize/modeladmin/options.py
@@ -0,0 +1,57 @@
+from django.conf import settings
+from django.core.exceptions import ImproperlyConfigured
+from wagtail.contrib.modeladmin.options import ModelAdmin
+from wagtail.core.models import TranslatableMixin
+
+from .helpers import TranslatableButtonHelper, TranslatablePageButtonHelper
+from .views import (
+    TranslatableChooseParentView,
+    TranslatableCreateView,
+    TranslatableDeleteView,
+    TranslatableEditView,
+    TranslatableHistoryView,
+    TranslatableIndexView,
+    TranslatableInspectView,
+)
+
+
+class TranslatableModelAdmin(ModelAdmin):
+    index_view_class = TranslatableIndexView
+    create_view_class = TranslatableCreateView
+    edit_view_class = TranslatableEditView
+    inspect_view_class = TranslatableInspectView
+    delete_view_class = TranslatableDeleteView
+    history_view_class = TranslatableHistoryView
+    choose_parent_view_class = TranslatableChooseParentView
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+
+        if "wagtail_localize.modeladmin" not in settings.INSTALLED_APPS:
+            raise ImproperlyConfigured(
+                'To use the TranslatableModelAdmin class "wagtail_localize.modeladmin" '
+                "must be added to your INSTALLED_APPS setting."
+            )
+
+        if not issubclass(self.model, TranslatableMixin):
+            raise ImproperlyConfigured(
+                f"Model `{self.model}` used in translatable admin `{self.__class__}` "
+                f"must subclass the `{TranslatableMixin}` class."
+            )
+
+    def get_button_helper_class(self):
+        if self.button_helper_class:
+            return self.button_helper_class
+        if self.is_pagemodel:
+            return TranslatablePageButtonHelper
+        return TranslatableButtonHelper
+
+    def get_templates(self, action="index"):
+        app_label = self.opts.app_label.lower()
+        model_name = self.opts.model_name.lower()
+        return [
+            "wagtail_localize/modeladmin/%s/%s/translatable_%s.html"
+            % (app_label, model_name, action),
+            "wagtail_localize/modeladmin/%s/translatable_%s.html" % (app_label, action),
+            "wagtail_localize/modeladmin/translatable_%s.html" % (action,),
+        ]
diff --git a/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/includes/button.html b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/includes/button.html
new file mode 100644
index 00000000..f28691f8
--- /dev/null
+++ b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/includes/button.html
@@ -0,0 +1 @@
+<a{% if button.url %} href="{{ button.url }}{% if locale %}?locale={{ locale.language_code }}{% endif %}"{% endif %} class="{{ button.classname }}" title="{{ button.title }}"{% if button.target %} target="{{ button.target }}"{% endif %}{% if button.rel %} rel="{{ button.rel }}"{% endif %}>{{ button.label }}</a>
diff --git a/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/includes/header_with_breadcrumb.html b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/includes/header_with_breadcrumb.html
new file mode 100644
index 00000000..5305ed02
--- /dev/null
+++ b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/includes/header_with_breadcrumb.html
@@ -0,0 +1,5 @@
+{% extends "wagtailadmin/shared/header_with_locale_selector.html" %}
+
+{% block breadcrumb %}
+  {% include "modeladmin/includes/breadcrumb.html" %}
+{% endblock %}
diff --git a/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_choose_parent.html b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_choose_parent.html
new file mode 100644
index 00000000..99b98786
--- /dev/null
+++ b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_choose_parent.html
@@ -0,0 +1 @@
+{% extends "modeladmin/choose_parent.html" %}
diff --git a/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_create.html b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_create.html
new file mode 100644
index 00000000..4d792ffc
--- /dev/null
+++ b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_create.html
@@ -0,0 +1,7 @@
+{% extends "modeladmin/create.html" %}
+
+{% block header %}
+  {% include "wagtailadmin/shared/header_with_locale_selector.html" with title=view.get_page_title subtitle=view.get_page_subtitle icon=view.header_icon tabbed=1 merged=1 %}
+{% endblock %}
+
+{% block form_action %}{{ view.create_url }}?locale={{ locale.language_code }}{% endblock %}
diff --git a/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_delete.html b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_delete.html
new file mode 100644
index 00000000..477a8080
--- /dev/null
+++ b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_delete.html
@@ -0,0 +1 @@
+{% extends "modeladmin/delete.html" %}
diff --git a/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_edit.html b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_edit.html
new file mode 100644
index 00000000..e6112ce7
--- /dev/null
+++ b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_edit.html
@@ -0,0 +1,34 @@
+{% extends "modeladmin/edit.html" %}
+{% load i18n wagtailadmin_tags %}
+
+{% block header %}
+  {% if wagtail_version >= "2.15" %}
+    {% include "modeladmin/includes/header_with_history.html" with title=view.get_page_title subtitle=view.get_page_subtitle icon=view.header_icon tabbed=1 merged=1 latest_log_entry=latest_log_entry history_url=history_url %}
+  {% else %}
+    {% include "wagtailadmin/shared/header_with_locale_selector.html" with title=view.get_page_title subtitle=view.get_page_subtitle icon=view.header_icon tabbed=1 merged=1 %}
+  {% endif %}
+{% endblock %}
+
+{% block form_actions %}
+  <div class="dropdown dropup dropdown-button match-width">
+    <button type="submit" class="button action-save button-longrunning" data-clicked-text="{% trans 'Saving…' %}">
+      {% icon name="spinner" %}<em>{% trans "Save" %}</em>
+    </button>
+
+    {% if user_can_delete %}
+      <div class="dropdown-toggle">{% icon name="arrow-up" %}</div>
+      <ul>
+        {% if translation %}
+          <li>
+            <form method="POST">
+              <input type="hidden" name="localize-restart-translation">
+              <button class="button">{% trans "Start Synced translation" %}</button>
+            </form>
+          </li>
+        {% endif %}
+        <li><a href="{{ view.delete_url }}" class="shortcut">{% trans "Delete" %}</a></li>
+      </ul>
+    {% endif %}
+
+  </div>
+{% endblock %}
diff --git a/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_history.html b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_history.html
new file mode 100644
index 00000000..4c756f93
--- /dev/null
+++ b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_history.html
@@ -0,0 +1 @@
+{% extends "modeladmin/history.html" %}
diff --git a/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_index.html b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_index.html
new file mode 100644
index 00000000..02e37c86
--- /dev/null
+++ b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_index.html
@@ -0,0 +1,24 @@
+{% extends "modeladmin/index.html" %}
+{% load i18n modeladmin_tags wagtailadmin_tags %}
+
+{% block header_extra %}
+  <div class="right header-right">
+    <div class="col">
+      {% include 'wagtailadmin/shared/locale_selector.html' with class='c-dropdown--large' %}
+    </div>
+    {% if user_can_create %}
+      <div class="actionbutton col">
+        {% include 'wagtail_localize/modeladmin/includes/button.html' with button=view.button_helper.add_button %}
+      </div>
+    {% endif %}
+    {% if view.list_export %}
+      <div class="dropdown dropdown-button match-width col">
+        <a href="?export=xlsx&{{ request.GET.urlencode }}" class="button bicolor button--icon">{% icon name="download" wrapped=1 %}{% trans 'Download XLSX' %}</a>
+        <div class="dropdown-toggle">{% icon name="arrow-down" %}</div>
+        <ul>
+          <li><a  class="button bicolor button--icon" href="?export=csv&{{ request.GET.urlencode }}">{% icon name="download" wrapped=1 %}{% trans 'Download CSV' %}</a></li>
+        </ul>
+      </div>
+    {% endif %}
+  </div>
+{% endblock %}
diff --git a/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_inspect.html b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_inspect.html
new file mode 100644
index 00000000..c53fa824
--- /dev/null
+++ b/wagtail_localize/modeladmin/templates/wagtail_localize/modeladmin/translatable_inspect.html
@@ -0,0 +1,5 @@
+{% extends "modeladmin/inspect.html" %}
+
+{% block header %}
+  {% include "wagtail_localize/modeladmin/includes/header_with_breadcrumb.html" with title=view.get_page_title subtitle=view.get_page_subtitle icon=view.header_icon tabbed=True %}
+{% endblock %}
diff --git a/wagtail_localize/modeladmin/tests.py b/wagtail_localize/modeladmin/tests.py
new file mode 100644
index 00000000..4cc47d47
--- /dev/null
+++ b/wagtail_localize/modeladmin/tests.py
@@ -0,0 +1,767 @@
+from urllib.parse import urlencode
+
+from django.contrib.admin.utils import quote
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group, Permission
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ImproperlyConfigured
+from django.test import RequestFactory, TestCase, override_settings
+from django.urls import reverse
+from wagtail.core.models import Locale, Page
+from wagtail.tests.utils import WagtailTestUtils
+
+from wagtail_localize.modeladmin import helpers
+from wagtail_localize.modeladmin.options import ModelAdmin, TranslatableModelAdmin
+from wagtail_localize.modeladmin.views import (
+    TranslatableIndexView,
+    TranslatableInspectView,
+)
+from wagtail_localize.models import Translation, TranslationSource
+from wagtail_localize.test.models import NonTranslatableModel, TestModel, TestPage
+from wagtail_localize.test.wagtail_hooks import TestModelAdmin, TestPageAdmin
+from wagtail_localize.tests.utils import assert_permission_denied
+
+
+def strip_user_perms():
+    """
+    Removes user permissions so they can still access admin and edit pages but can't submit anything for translation.
+    """
+    editors_group = Group.objects.get(name="Editors")
+    editors_group.permissions.filter(codename="submit_translation").delete()
+
+    for permission in Permission.objects.filter(
+        content_type=ContentType.objects.get_for_model(TestModel)
+    ):
+        editors_group.permissions.add(permission)
+
+    user = get_user_model().objects.get()
+    user.is_superuser = False
+    user.groups.add(editors_group)
+    user.save()
+
+
+class TestModelAdminViews(TestCase, WagtailTestUtils):
+    def setUp(self):
+        self.login()
+
+        self.en_locale = Locale.objects.get()
+        self.fr_locale = Locale.objects.create(language_code="fr")
+        self.de_locale = Locale.objects.create(language_code="de")
+
+        self.en_modeladmin = TestModel.objects.create(title="Test modeladmin")
+        self.modeladmin_source, created = TranslationSource.get_or_create_from_instance(
+            self.en_modeladmin
+        )
+        self.modeladmin_translation = Translation.objects.create(
+            source=self.modeladmin_source, target_locale=self.fr_locale
+        )
+        self.modeladmin_translation.save_target(publish=True)
+        self.fr_modeladmin = self.en_modeladmin.get_translation(self.fr_locale)
+
+    def test(self):
+        with self.assertRaises(ImproperlyConfigured):
+            TranslatableIndexView(
+                model_admin=type(
+                    "NonTranslatableModelAdmin",
+                    (ModelAdmin,),
+                    {"model": NonTranslatableModel},
+                )()
+            )
+
+    def test_index_view(self):
+        response = self.client.get(
+            reverse("wagtail_localize_test_testmodel_modeladmin_index")
+        )
+        self.assertContains(response, "Translate")
+
+    def test_create_view(self):
+        create_url = reverse("wagtail_localize_test_testmodel_modeladmin_create")
+        response = self.client.get(create_url)
+
+        self.assertContains(
+            response,
+            '<a href="/admin/wagtail_localize_test/testmodel/create/?locale=de"',
+        )
+
+        # Create a new DE object if and check for locale
+        response = self.client.post(create_url + "?locale=de", {"title": "New model"})
+        self.assertRedirects(
+            response,
+            reverse("wagtail_localize_test_testmodel_modeladmin_index") + "?locale=de",
+        )
+        obj = TestModel.objects.last()
+        self.assertEqual(obj.title, "New model")
+        self.assertEqual(obj.locale, self.de_locale)
+
+    def test_edit_view(self):
+        response = self.client.get(
+            reverse(
+                "wagtail_localize_test_testmodel_modeladmin_edit",
+                args=[self.en_modeladmin.pk],
+            )
+        )
+        self.assertContains(
+            response,
+            '<a href="/admin/wagtail_localize_test/testmodel/edit/{}/?locale=fr"'.format(
+                self.fr_modeladmin.pk
+            ),
+        )
+
+        # Check restart translation button is displayed
+        self.modeladmin_translation.enabled = False
+        self.modeladmin_translation.save()
+        response = self.client.get(
+            reverse(
+                "wagtail_localize_test_testmodel_modeladmin_edit",
+                args=[self.fr_modeladmin.pk],
+            )
+        )
+        self.assertContains(response, "Start Synced translation")
+
+        # Check restart translation triggers correctly
+        response = self.client.post(
+            reverse(
+                "wagtail_localize_test_testmodel_modeladmin_edit",
+                args=[self.fr_modeladmin.pk],
+            ),
+            {"localize-restart-translation": True},
+        )
+        self.assertRedirects(
+            response,
+            reverse(
+                "wagtail_localize_test_testmodel_modeladmin_edit",
+                args=[self.fr_modeladmin.pk],
+            ),
+        )
+        self.modeladmin_translation.refresh_from_db()
+        self.assertTrue(self.modeladmin_translation.enabled)
+
+        # Check existant FR translation renders the "edit_translation" view
+        response = self.client.get(
+            reverse(
+                "wagtail_localize_test_testmodel_modeladmin_edit",
+                args=[self.fr_modeladmin.pk],
+            )
+        )
+        self.assertContains(
+            response,
+            "Translation of TestModel object ({}) into French".format(
+                self.en_modeladmin.pk
+            ),
+        )
+
+    def test_inspect_view(self):
+        response = self.client.get(
+            reverse(
+                "wagtail_localize_test_testmodel_modeladmin_inspect",
+                args=[self.en_modeladmin.pk],
+            )
+        )
+        self.assertContains(
+            response,
+            '<a href="/admin/wagtail_localize_test/testmodel/inspect/{}/?locale=fr"'.format(
+                self.fr_modeladmin.pk
+            ),
+        )
+        self.assertContains(response, "Translate")
+        self.assertContains(response, "Sync translated test models")
+
+        # Create DE translation and check "Translate" button not showing
+        Translation.objects.create(
+            source=self.modeladmin_source, target_locale=self.de_locale
+        ).save_target(publish=True)
+
+        response = self.client.get(
+            reverse(
+                "wagtail_localize_test_testmodel_modeladmin_inspect",
+                args=[self.fr_modeladmin.pk],
+            )
+        )
+        self.assertNotContains(response, "Translate")
+        self.assertNotContains(response, "Sync translated test models")
+
+
+class TestModelAdminAdmin(TestCase, WagtailTestUtils):
+    def setUp(self):
+        self.login()
+        self.model_admin = TestModelAdmin()
+        self.page_admin = TestPageAdmin()
+
+    def test(self):
+        with self.assertRaises(ImproperlyConfigured):
+            type(
+                "NonTranslatableModelAdmin",
+                (TranslatableModelAdmin,),
+                {"model": NonTranslatableModel},
+            )()
+
+    def test_button_helper_getter(self):
+        self.assertEqual(
+            self.model_admin.get_button_helper_class(),
+            helpers.TranslatableButtonHelper,
+        )
+        self.model_admin.button_helper_class = helpers.ButtonHelper
+        self.assertEqual(
+            self.model_admin.get_button_helper_class(),
+            helpers.ButtonHelper,
+        )
+        self.assertEqual(
+            self.page_admin.get_button_helper_class(),
+            helpers.TranslatablePageButtonHelper,
+        )
+
+    def test_get_templates(self):
+        def result(action):
+            return [
+                "wagtail_localize/modeladmin/wagtail_localize_test/testmodel/translatable_%s.html"
+                % action,
+                "wagtail_localize/modeladmin/wagtail_localize_test/translatable_%s.html"
+                % action,
+                "wagtail_localize/modeladmin/translatable_%s.html" % action,
+            ]
+
+        self.assertEqual(self.model_admin.get_templates("index"), result("index"))
+        self.assertEqual(self.model_admin.get_templates("create"), result("create"))
+        self.assertEqual(self.model_admin.get_templates("edit"), result("edit"))
+        self.assertEqual(self.model_admin.get_templates("inspect"), result("inspect"))
+        self.assertEqual(self.model_admin.get_templates("delete"), result("delete"))
+        self.assertEqual(
+            self.model_admin.get_templates("choose_parent"), result("choose_parent")
+        )
+
+
+class TestModelAdminHelpers(TestCase, WagtailTestUtils):
+    def setUp(self):
+        self.user = self.login()
+        self.factory = RequestFactory()
+
+        self.model_admin = TestModelAdmin()
+        self.page_admin = TestPageAdmin()
+
+        self.en_locale = Locale.objects.get()
+        self.fr_locale = Locale.objects.create(language_code="fr")
+        self.de_locale = Locale.objects.create(language_code="de")
+
+        self.en_modeladmin = TestModel.objects.create(title="Test modeladmin")
+        self.modeladmin_source, created = TranslationSource.get_or_create_from_instance(
+            self.en_modeladmin
+        )
+        self.modeladmin_translation = Translation.objects.create(
+            source=self.modeladmin_source, target_locale=self.fr_locale
+        )
+        self.modeladmin_translation.save_target(publish=True)
+        self.fr_modeladmin = self.en_modeladmin.get_translation(self.fr_locale)
+
+        root_page = Page.objects.get(id=1)
+        root_page.get_children().delete()
+        root_page.refresh_from_db()
+
+        self.en_modeladmin_page = root_page.add_child(instance=TestPage(title="Test"))
+        (
+            self.modeladmin_page_source,
+            created,
+        ) = TranslationSource.get_or_create_from_instance(self.en_modeladmin_page)
+        self.modeladmin_page_translation = Translation.objects.create(
+            source=self.modeladmin_page_source, target_locale=self.fr_locale
+        )
+        self.modeladmin_page_translation.save_target(publish=True)
+
+    def test_button_helper(self):
+        request = self.factory.get(
+            reverse("wagtail_localize_test_testmodel_modeladmin_index")
+        )
+        request.user = self.user
+
+        view = TranslatableIndexView(self.model_admin)
+        helper = self.model_admin.get_button_helper_class()(view, request)
+        buttons = helper.get_buttons_for_obj(self.en_modeladmin)
+        self.assertEqual(
+            buttons[-1]["classname"],
+            "button button-secondary button-small",
+        )
+
+        # Check inspect view sets different button classes
+        view = TranslatableInspectView(self.model_admin, str(self.en_modeladmin.pk))
+        helper = self.model_admin.get_button_helper_class()(view, request)
+        buttons = helper.get_buttons_for_obj(self.en_modeladmin)
+        self.assertEqual(buttons[-1]["classname"], "button button-secondary")
+
+    def test_get_translation_buttons(self):
+        btns = helpers.get_translation_buttons(
+            self.en_modeladmin, self.user, "/next/url/", "button-class"
+        )
+        self.assertEqual(
+            next(btns),
+            {
+                "url": reverse(
+                    "wagtail_localize_modeladmin:submit_translation",
+                    args=[
+                        self.en_modeladmin._meta.app_label,
+                        self.en_modeladmin._meta.model_name,
+                        quote(self.en_modeladmin.pk),
+                    ],
+                ),
+                "label": "Translate",
+                "classname": "button-class",
+                "title": "Translate",
+            },
+        )
+        self.assertEqual(
+            next(btns),
+            {
+                "url": reverse(
+                    "wagtail_localize:update_translations",
+                    args=[self.modeladmin_source.pk],
+                )
+                + "?"
+                + urlencode({"next": "/next/url/"}),
+                "label": "Sync translated test models",
+                "classname": "button-class",
+                "title": "Sync translated test models",
+            },
+        )
+
+        with self.assertRaises(StopIteration):
+            next(btns)
+
+    def test_get_translation_buttons_for_page(self):
+        btns = helpers.get_translation_buttons(self.en_modeladmin_page, self.user)
+        self.assertEqual(
+            next(btns),
+            {
+                "url": reverse(
+                    "wagtail_localize_modeladmin:submit_translation",
+                    args=[
+                        self.en_modeladmin_page._meta.app_label,
+                        self.en_modeladmin_page._meta.model_name,
+                        quote(self.en_modeladmin_page.pk),
+                    ],
+                ),
+                "label": "Translate",
+                "classname": "",
+                "title": "Translate",
+            },
+        )
+        self.assertEqual(
+            next(btns),
+            {
+                "url": reverse(
+                    "wagtail_localize:update_translations",
+                    args=[self.modeladmin_page_source.pk],
+                ),
+                "label": "Sync translated test pages",
+                "classname": "",
+                "title": "Sync translated test pages",
+            },
+        )
+
+        with self.assertRaises(StopIteration):
+            next(btns)
+
+    def test_get_translation_buttons_no_locale_to_translate_to(self):
+        Translation.objects.create(
+            source=self.modeladmin_source, target_locale=self.de_locale
+        ).save_target(publish=True)
+
+        btns = helpers.get_translation_buttons(self.en_modeladmin, self.user)
+        self.assertEqual(
+            next(btns),
+            {
+                "url": reverse(
+                    "wagtail_localize:update_translations",
+                    args=[self.modeladmin_source.pk],
+                ),
+                "label": "Sync translated test models",
+                "classname": "",
+                "title": "Sync translated test models",
+            },
+        )
+
+        with self.assertRaises(StopIteration):
+            next(btns)
+
+    def test_get_translation_buttons_no_user_perms(self):
+        strip_user_perms()
+
+        self.user.refresh_from_db()
+        btns = helpers.get_translation_buttons(self.en_modeladmin, self.user)
+
+        with self.assertRaises(StopIteration):
+            next(btns)
+
+
+@override_settings(
+    LANGUAGES=[
+        ("en", "English"),
+        ("fr", "French"),
+        ("de", "German"),
+        ("es", "Spanish"),
+    ],
+    WAGTAIL_CONTENT_LANGUAGES=[
+        ("en", "English"),
+        ("fr", "French"),
+        ("de", "German"),
+        ("es", "Spanish"),
+    ],
+)
+class TestTranslateModelAdminListingButton(TestCase, WagtailTestUtils):
+    def setUp(self):
+        self.login()
+
+        self.en_locale = Locale.objects.get()
+        self.fr_locale = Locale.objects.create(language_code="fr")
+        self.de_locale = Locale.objects.create(language_code="de")
+
+        self.en_modeladmin = TestModel.objects.create(
+            title="Test modeladmin", test_textfield="Test modeladmin"
+        )
+        self.fr_modeladmin = self.en_modeladmin.copy_for_translation(self.fr_locale)
+        self.fr_modeladmin.save()
+
+        self.not_translatable_modeladmin = NonTranslatableModel.objects.create()
+
+    def test(self):
+        response = self.client.get(
+            reverse("wagtail_localize_test_testmodel_modeladmin_index")
+        )
+        self.assertContains(
+            response,
+            (
+                f'href="/admin/localize/modeladmin/submit/wagtail_localize_test/testmodel/{self.en_modeladmin.id}/" '
+                f'class="button button-secondary button-small" title="Translate">Translate</a>'
+            ),
+        )
+
+    def test_hides_if_modeladmin_already_translated(self):
+        de_modeladmin = self.en_modeladmin.copy_for_translation(self.de_locale)
+        de_modeladmin.save()
+
+        response = self.client.get(
+            reverse("wagtail_localize_test_testmodel_modeladmin_index")
+        )
+
+        self.assertNotContains(response, "Translate")
+
+    def test_hides_if_modeladmin_isnt_translatable(self):
+        de_modeladmin = self.en_modeladmin.copy_for_translation(self.de_locale)
+        de_modeladmin.save()
+
+        response = self.client.get(
+            reverse("wagtail_localize_test_testmodel_modeladmin_index")
+        )
+
+        self.assertNotContains(response, "Translate")
+
+    def test_hides_if_user_doesnt_have_permission(self):
+        strip_user_perms()
+
+        response = self.client.get(
+            reverse("wagtail_localize_test_testmodel_modeladmin_index")
+        )
+
+        self.assertNotContains(response, "Translate")
+
+
+@override_settings(
+    LANGUAGES=[
+        ("en", "English"),
+        ("fr", "French"),
+        ("de", "German"),
+        ("es", "Spanish"),
+    ],
+    WAGTAIL_CONTENT_LANGUAGES=[
+        ("en", "English"),
+        ("fr", "French"),
+        ("de", "German"),
+        ("es", "Spanish"),
+    ],
+)
+class TestSubmitModelAdminTranslation(TestCase, WagtailTestUtils):
+    def setUp(self):
+        self.login()
+
+        self.en_locale = Locale.objects.get()
+        self.fr_locale = Locale.objects.create(language_code="fr")
+        self.de_locale = Locale.objects.create(language_code="de")
+
+        self.en_modeladmin = TestModel.objects.create(
+            title="Test modeladmin", test_textfield="Test modeladmin"
+        )
+
+        self.not_translatable_modeladmin = NonTranslatableModel.objects.create()
+
+    def test_get_submit_modeladmin_translation(self):
+        response = self.client.get(
+            reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=["wagtail_localize_test", "testmodel", self.en_modeladmin.id],
+            )
+        )
+
+        self.assertEqual(response.status_code, 200)
+
+        self.assertListEqual(
+            list(response.context["form"]["locales"].field.queryset),
+            [self.de_locale, self.fr_locale],
+        )
+
+        # More than one locale so show "Select all"
+        self.assertFalse(response.context["form"]["select_all"].field.widget.is_hidden)
+
+        # ModelAdmin can't have children so hide include_subtree
+        self.assertTrue(
+            response.context["form"]["include_subtree"].field.widget.is_hidden
+        )
+
+    def test_get_submit_modeladmin_translation_when_not_modeladmin(self):
+        response = self.client.get(
+            reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=["wagtailcore", "page", 1],
+            ),
+            # Need to follow as Django will initiall redirect to /en/admin/
+            follow=True,
+        )
+
+        self.assertEqual(response.status_code, 404)
+
+    def test_get_submit_modeladmin_translation_when_invalid_model(self):
+        response = self.client.get(
+            reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=["wagtailcore", "foo", 1],
+            ),
+            # Need to follow as Django will initiall redirect to /en/admin/
+            follow=True,
+        )
+
+        self.assertEqual(response.status_code, 404)
+
+    def test_get_submit_modeladmin_translation_when_not_translatable(self):
+        response = self.client.get(
+            reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=[
+                    "wagtail_localize_test",
+                    "nontranslatablemodel",
+                    self.not_translatable_modeladmin.id,
+                ],
+            ),
+            # Need to follow as Django will initiall redirect to /en/admin/
+            follow=True,
+        )
+
+        self.assertEqual(response.status_code, 404)
+
+    def test_get_submit_modeladmin_translation_without_permissions(self):
+        strip_user_perms()
+
+        response = self.client.get(
+            reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=["wagtail_localize_test", "testmodel", self.en_modeladmin.id],
+            )
+        )
+
+        assert_permission_denied(self, response)
+
+    def test_get_submit_modeladmin_translation_when_already_translated(self):
+        # Locales that have been translated into shouldn't be included
+        translation = self.en_modeladmin.copy_for_translation(self.de_locale)
+        translation.save()
+
+        response = self.client.get(
+            reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=["wagtail_localize_test", "testmodel", self.en_modeladmin.id],
+            )
+        )
+
+        self.assertEqual(response.status_code, 200)
+
+        self.assertListEqual(
+            list(response.context["form"]["locales"].field.queryset), [self.fr_locale]
+        )
+
+        # Since there is only one locale, the "Select All" checkbox should be hidden
+        self.assertTrue(response.context["form"]["select_all"].field.widget.is_hidden)
+
+    def test_post_submit_modeladmin_translation(self):
+        response = self.client.post(
+            reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=["wagtail_localize_test", "testmodel", self.en_modeladmin.id],
+            ),
+            {"locales": [self.fr_locale.id]},
+        )
+
+        translation = Translation.objects.get()
+        self.assertEqual(translation.source.locale, self.en_locale)
+        self.assertEqual(translation.target_locale, self.fr_locale)
+        self.assertTrue(translation.created_at)
+
+        # The translated modeladmin should've been created
+        translated_modeladmin = self.en_modeladmin.get_translation(self.fr_locale)
+        self.assertEqual(translated_modeladmin.test_textfield, "Test modeladmin")
+
+        self.assertRedirects(
+            response,
+            reverse(
+                "wagtail_localize_test_testmodel_modeladmin_edit",
+                args=[translated_modeladmin.id],
+            ),
+        )
+
+    def test_post_submit_modeladmin_translation_into_multiple_locales(self):
+        response = self.client.post(
+            reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=["wagtail_localize_test", "testmodel", self.en_modeladmin.id],
+            ),
+            {"locales": [self.fr_locale.id, self.de_locale.id]},
+        )
+
+        self.assertRedirects(
+            response,
+            reverse(
+                "wagtail_localize_test_testmodel_modeladmin_edit",
+                args=[self.en_modeladmin.id],
+            ),
+        )
+
+        # Check French translation
+        fr_translation = Translation.objects.get(target_locale=self.fr_locale)
+        self.assertEqual(fr_translation.source.locale, self.en_locale)
+        self.assertTrue(fr_translation.created_at)
+
+        # Check German translation
+        de_translation = Translation.objects.get(target_locale=self.de_locale)
+        self.assertEqual(de_translation.source.locale, self.en_locale)
+        self.assertTrue(de_translation.created_at)
+
+    def test_post_submit_modeladmin_translation_with_missing_locale(self):
+        response = self.client.post(
+            reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=["wagtail_localize_test", "testmodel", self.en_modeladmin.id],
+            ),
+            {"locales": []},
+        )
+
+        self.assertEqual(response.status_code, 200)
+        self.assertFalse(Translation.objects.exists())
+        self.assertFormError(response, "form", "locales", ["This field is required."])
+
+    def test_post_submit_modeladmin_translation_without_permissions(self):
+        strip_user_perms()
+
+        response = self.client.post(
+            reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=["wagtail_localize_test", "testmodel", self.en_modeladmin.id],
+            ),
+            {"locales": [self.fr_locale.id]},
+        )
+
+        assert_permission_denied(self, response)
+
+
+@override_settings(
+    LANGUAGES=[
+        ("en", "English"),
+        ("fr", "French"),
+        ("de", "German"),
+        ("es", "Spanish"),
+    ],
+    WAGTAIL_CONTENT_LANGUAGES=[
+        ("en", "English"),
+        ("fr", "French"),
+        ("de", "German"),
+        ("es", "Spanish"),
+    ],
+)
+class TestUpdateModelAdminTranslations(TestCase, WagtailTestUtils):
+    def setUp(self):
+        self.login()
+
+        self.en_locale = Locale.objects.get()
+        self.fr_locale = Locale.objects.create(language_code="fr")
+        self.de_locale = Locale.objects.create(language_code="de")
+
+        # Create modeladmin object and FR translation
+        self.en_modeladmin = TestModel.objects.create(
+            title="Test modeladmin", test_textfield="Test modeladmin"
+        )
+        self.modeladmin_source, created = TranslationSource.get_or_create_from_instance(
+            self.en_modeladmin
+        )
+        self.modeladmin_translation = Translation.objects.create(
+            source=self.modeladmin_source, target_locale=self.fr_locale
+        )
+        self.modeladmin_translation.save_target(publish=True)
+        self.fr_modeladmin = self.en_modeladmin.get_translation(self.fr_locale)
+
+    def test_get_update_modeladmin_translation(self):
+        response = self.client.get(
+            reverse(
+                "wagtail_localize:update_translations",
+                args=[self.modeladmin_source.id],
+            )
+        )
+
+        self.assertEqual(response.status_code, 200)
+
+        self.assertEqual(
+            response.context["translations"],
+            [
+                {
+                    "title": str(self.fr_modeladmin),
+                    "locale": self.fr_locale,
+                    "edit_url": reverse(
+                        "wagtail_localize_test_testmodel_modeladmin_edit",
+                        args=[self.fr_modeladmin.id],
+                    ),
+                }
+            ],
+        )
+
+    def test_post_update_modeladmin_translation(self):
+        self.en_modeladmin.test_textfield = "Edited modeladmin"
+        self.en_modeladmin.save()
+
+        response = self.client.post(
+            reverse(
+                "wagtail_localize:update_translations",
+                args=[self.modeladmin_source.id],
+            )
+        )
+
+        self.assertRedirects(
+            response,
+            reverse("wagtail_localize_test_testmodel_modeladmin_index"),
+        )
+
+        # The FR version shouldn't be updated yet
+        self.fr_modeladmin.refresh_from_db()
+        self.assertEqual(self.fr_modeladmin.test_textfield, "Test modeladmin")
+
+    def test_post_update_modeladmin_translation_with_publish_translations(self):
+        self.en_modeladmin.test_textfield = "Edited modeladmin"
+        self.en_modeladmin.save()
+
+        response = self.client.post(
+            reverse(
+                "wagtail_localize:update_translations",
+                args=[self.modeladmin_source.id],
+            ),
+            {"publish_translations": "on"},
+        )
+
+        self.assertRedirects(
+            response,
+            reverse("wagtail_localize_test_testmodel_modeladmin_index"),
+        )
+
+        # The FR version should be updated
+        self.fr_modeladmin.refresh_from_db()
+        self.assertEqual(self.fr_modeladmin.test_textfield, "Edited modeladmin")
diff --git a/wagtail_localize/modeladmin/views.py b/wagtail_localize/modeladmin/views.py
new file mode 100644
index 00000000..3a4ea201
--- /dev/null
+++ b/wagtail_localize/modeladmin/views.py
@@ -0,0 +1,215 @@
+from django.apps import apps
+from django.conf import settings
+from django.contrib.admin.utils import unquote
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import ImproperlyConfigured
+from django.http import Http404
+from django.shortcuts import get_object_or_404
+from django.urls import NoReverseMatch, reverse
+from django.utils.decorators import method_decorator
+from django.utils.translation import gettext as _
+from wagtail import VERSION as WAGTAIL_VERSION
+from wagtail.contrib.modeladmin.views import (
+    ChooseParentView,
+    CreateView,
+    DeleteView,
+    EditView,
+    IndexView,
+    InspectView,
+)
+from wagtail.core.models import Locale, TranslatableMixin
+from wagtail.utils.version import get_main_version
+
+from wagtail_localize.models import Translation
+from wagtail_localize.views import edit_translation
+from wagtail_localize.views.submit_translations import SubmitTranslationView
+
+
+if WAGTAIL_VERSION >= (2, 15):
+    from wagtail.contrib.modeladmin.views import HistoryView
+else:
+    HistoryView = object
+
+
+class TranslatableViewMixin:
+    def __init__(self, *args, **kwargs):
+        self.locale = None
+        super().__init__(*args, **kwargs)
+
+        if "wagtail_localize.modeladmin" not in settings.INSTALLED_APPS:
+            raise ImproperlyConfigured(
+                'To use the TranslatableView class "wagtail_localize.modeladmin" '
+                "must be added to your INSTALLED_APPS setting."
+            )
+
+        if not issubclass(self.model, TranslatableMixin):
+            raise ImproperlyConfigured(
+                f"Model `{self.model}` used in translatable view `{self.__class__}` "
+                f"must subclass the `{TranslatableMixin}` class."
+            )
+
+    @method_decorator(login_required)
+    def dispatch(self, request, *args, **kwargs):
+        if getattr(self, "instance", None):
+            self.locale = self.instance.locale
+        if "locale" in request.GET:
+            self.locale = get_object_or_404(Locale, language_code=request.GET["locale"])
+        if not self.locale:
+            self.locale = Locale.get_active()
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["locale"] = self.locale
+        context["translations"] = []
+        context["wagtail_version"] = get_main_version()
+        return context
+
+
+class TranslatableIndexView(TranslatableViewMixin, IndexView):
+    def get_filters(self, request):
+        filters = super().get_filters(request)
+        # Update the 'lookup_params' part of the filters tuple to filter objects
+        # using the currently active locale
+        filters[2]["locale"] = self.locale.id
+        return filters
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["translations"] = [
+            {
+                "locale": locale,
+                "url": self.index_url + "?locale=" + locale.language_code,
+            }
+            for locale in Locale.objects.exclude(id=self.locale.id)
+        ]
+        return context
+
+
+class TranslatableCreateView(TranslatableViewMixin, CreateView):
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs["instance"].locale = self.locale
+        return kwargs
+
+    def get_success_url(self):
+        return self.index_url + "?locale=" + self.locale.language_code
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["translations"] = [
+            {
+                "locale": locale,
+                "url": self.create_url + "?locale=" + locale.language_code,
+            }
+            for locale in Locale.objects.exclude(id=self.locale.id)
+        ]
+        return context
+
+
+class TranslatableEditView(TranslatableViewMixin, EditView):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Attempt to find the translation for the edited instance or set to None
+        self.translation = Translation.objects.filter(
+            source__object_id=self.instance.translation_key,
+            target_locale_id=self.instance.locale_id,
+        ).first()
+
+    @method_decorator(login_required)
+    def dispatch(self, request, *args, **kwargs):
+        # Check if the user has clicked the "Start Synced translation" menu item
+        if (
+            request.method == "POST"
+            and "localize-restart-translation" in request.POST
+            and self.translation
+            and not self.translation.enabled
+        ):
+            return edit_translation.restart_translation(
+                request, self.translation, self.instance
+            )
+
+        # Overrides the edit view if the object is translatable and the target of a translation
+        if self.translation and self.translation.enabled:
+            return edit_translation.edit_translation(
+                request, self.translation, self.instance
+            )
+
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["translation"] = self.translation
+        context["translations"] = [
+            {
+                "locale": translation.locale,
+                "url": self.url_helper.get_action_url("edit", translation.pk)
+                + "?locale="
+                + translation.locale.language_code,
+            }
+            for translation in self.instance.get_translations().select_related("locale")
+        ]
+        return context
+
+
+class TranslatableInspectView(TranslatableViewMixin, InspectView):
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context["translations"] = [
+            {
+                "locale": translation.locale,
+                "url": self.url_helper.get_action_url("inspect", translation.pk)
+                + "?locale="
+                + translation.locale.language_code,
+            }
+            for translation in self.instance.get_translations().select_related("locale")
+        ]
+        return context
+
+
+class TranslatableDeleteView(TranslatableViewMixin, DeleteView):
+    pass
+
+
+class TranslatableHistoryView(TranslatableViewMixin, HistoryView):
+    pass
+
+
+class TranslatableChooseParentView(TranslatableViewMixin, ChooseParentView):
+    pass
+
+
+class SubmitModelAdminTranslationView(SubmitTranslationView):
+    def get_title(self):
+        return _("Translate {model_name}").format(
+            model_name=self.object._meta.verbose_name
+        )
+
+    def get_object(self):
+        try:
+            model = apps.get_model(self.kwargs["app_label"], self.kwargs["model_name"])
+        except LookupError:
+            raise Http404
+        if not issubclass(model, TranslatableMixin):
+            raise Http404
+        return get_object_or_404(model, pk=unquote(self.kwargs["pk"]))
+
+    def get_default_success_url(self, translated_object=None):
+        pk = translated_object.pk if translated_object else self.kwargs["pk"]
+        try:
+            return reverse(
+                "{app_label}_{model_name}_modeladmin_edit".format(**self.kwargs),
+                args=[pk],
+            )
+        except NoReverseMatch:
+            raise Http404
+
+    def get_success_message(self, locales):
+        return _(
+            "The {model_name} '{object}' was successfully submitted for translation into {locales}"
+        ).format(
+            model_name=self.object._meta.verbose_name,
+            object=str(self.object),
+            locales=locales,
+        )
diff --git a/wagtail_localize/modeladmin/wagtail_hooks.py b/wagtail_localize/modeladmin/wagtail_hooks.py
new file mode 100644
index 00000000..cc1bbe05
--- /dev/null
+++ b/wagtail_localize/modeladmin/wagtail_hooks.py
@@ -0,0 +1,24 @@
+from django.urls import include, path
+from wagtail.core import hooks
+
+from .views import SubmitModelAdminTranslationView
+
+
+@hooks.register("register_admin_urls")
+def register_admin_urls():
+    urls = [
+        path(
+            "submit/<slug:app_label>/<slug:model_name>/<str:pk>/",
+            SubmitModelAdminTranslationView.as_view(),
+            name="submit_translation",
+        ),
+    ]
+    return [
+        path(
+            "localize/modeladmin/",
+            include(
+                (urls, "wagtail_localize_modeladmin"),
+                namespace="wagtail_localize_modeladmin",
+            ),
+        )
+    ]
diff --git a/wagtail_localize/models.py b/wagtail_localize/models.py
index 1481da98..09d6b3f6 100644
--- a/wagtail_localize/models.py
+++ b/wagtail_localize/models.py
@@ -47,6 +47,7 @@
     get_translatable_models,
 )
 from wagtail.core.utils import find_available_slug
+from wagtail.snippets.models import get_snippet_models
 
 from .compat import DATE_FORMAT
 from .fields import copy_synchronised_fields
@@ -116,7 +117,7 @@ def get_edit_url(instance):
     if isinstance(instance, Page):
         return reverse("wagtailadmin_pages:edit", args=[instance.id])
 
-    else:
+    elif instance._meta.model in get_snippet_models():
         return reverse(
             "wagtailsnippets:edit",
             args=[
@@ -126,6 +127,15 @@ def get_edit_url(instance):
             ],
         )
 
+    elif "wagtail_localize.modeladmin" in settings.INSTALLED_APPS:
+        return reverse(
+            "{app_label}_{model_name}_modeladmin_edit".format(
+                app_label=instance._meta.app_label,
+                model_name=instance._meta.model_name,
+            ),
+            args=[quote(instance.pk)],
+        )
+
 
 def get_schema_version(app_label):
     """
diff --git a/wagtail_localize/test/migrations/0022_nontranslatablemodel.py b/wagtail_localize/test/migrations/0022_nontranslatablemodel.py
new file mode 100644
index 00000000..73dd1e97
--- /dev/null
+++ b/wagtail_localize/test/migrations/0022_nontranslatablemodel.py
@@ -0,0 +1,31 @@
+# Generated by Django 4.0.3 on 2022-03-31 11:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        (
+            "wagtail_localize_test",
+            "0021_testuuidmodel_testuuidsnippet",
+        ),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="NonTranslatableModel",
+            fields=[
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("title", models.CharField(blank=True, max_length=255)),
+            ],
+        ),
+    ]
diff --git a/wagtail_localize/test/models.py b/wagtail_localize/test/models.py
index 0fb30e78..dc5aa131 100644
--- a/wagtail_localize/test/models.py
+++ b/wagtail_localize/test/models.py
@@ -336,6 +336,10 @@ class TestModel(TranslatableMixin):
     ]
 
 
+class NonTranslatableModel(models.Model):
+    title = models.CharField(max_length=255, blank=True)
+
+
 class InheritedTestModel(TestModel):
     class Meta:
         unique_together = None
diff --git a/wagtail_localize/test/settings.py b/wagtail_localize/test/settings.py
index 909dae30..0fd2e16a 100644
--- a/wagtail_localize/test/settings.py
+++ b/wagtail_localize/test/settings.py
@@ -38,6 +38,7 @@
 INSTALLED_APPS = [
     "wagtail_localize",
     "wagtail_localize.locales",
+    "wagtail_localize.modeladmin",
     "wagtail_localize.test",
     "wagtail.contrib.search_promotions",
     "wagtail.contrib.forms",
diff --git a/wagtail_localize/test/wagtail_hooks.py b/wagtail_localize/test/wagtail_hooks.py
new file mode 100644
index 00000000..d00654b1
--- /dev/null
+++ b/wagtail_localize/test/wagtail_hooks.py
@@ -0,0 +1,28 @@
+from wagtail.contrib.modeladmin.options import (
+    ModelAdmin,
+    ModelAdminGroup,
+    modeladmin_register,
+)
+
+from wagtail_localize.modeladmin.options import TranslatableModelAdmin
+
+from .models import NonTranslatableModel, TestModel, TestPage
+
+
+class TestPageAdmin(TranslatableModelAdmin):
+    model = TestPage
+
+
+class TestModelAdmin(TranslatableModelAdmin):
+    model = TestModel
+    inspect_view_enabled = True
+
+
+class NonTranslatableModelAdmin(ModelAdmin):
+    model = NonTranslatableModel
+
+
+@modeladmin_register
+class ModelAdminAdmin(ModelAdminGroup):
+    items = (TestPageAdmin, TestModelAdmin, NonTranslatableModelAdmin)
+    menu_label = "Model Admin"
diff --git a/wagtail_localize/views/edit_translation.py b/wagtail_localize/views/edit_translation.py
index 7b148b17..0dbc90bd 100644
--- a/wagtail_localize/views/edit_translation.py
+++ b/wagtail_localize/views/edit_translation.py
@@ -5,6 +5,7 @@
 
 import polib
 
+from django.conf import settings
 from django.contrib.admin.utils import quote
 from django.contrib.auth import get_user_model
 from django.core.exceptions import PermissionDenied, ValidationError
@@ -728,6 +729,55 @@ def edit_translation(request, translation, instance):
             }
         )
 
+    def get_edit_url(instance):
+        if isinstance(instance, Page):
+            return reverse("wagtailadmin_pages:edit", args=[instance.id])
+
+        elif instance._meta.model in get_snippet_models():
+            return reverse(
+                "wagtailsnippets:edit",
+                args=[
+                    instance._meta.app_label,
+                    instance._meta.model_name,
+                    quote(instance.pk),
+                ],
+            )
+
+        elif "wagtail_localize.modeladmin" in settings.INSTALLED_APPS:
+            return reverse(
+                "{app_label}_{model_name}_modeladmin_edit".format(
+                    app_label=instance._meta.app_label,
+                    model_name=instance._meta.model_name,
+                ),
+                args=[quote(instance.pk)],
+            )
+
+    def get_submit_translation_url(instance):
+        if isinstance(instance, Page):
+            return reverse(
+                "wagtail_localize:submit_page_translation", args=[instance.id]
+            )
+
+        elif instance._meta.model in get_snippet_models():
+            return reverse(
+                "wagtail_localize:submit_snippet_translation",
+                args=[
+                    instance._meta.app_label,
+                    instance._meta.model_name,
+                    quote(instance.id),
+                ],
+            )
+
+        elif "wagtail_localize.modeladmin" in settings.INSTALLED_APPS:
+            return reverse(
+                "wagtail_localize_modeladmin:submit_translation",
+                args=[
+                    instance._meta.app_label,
+                    instance._meta.model_name,
+                    quote(instance.id),
+                ],
+            )
+
     def get_source_object_info(segment):
         instance = segment.get_source_instance()
 
@@ -736,32 +786,15 @@ def get_source_object_info(segment):
                 "title": str(instance),
                 "isLive": instance.live,
                 "liveUrl": instance.full_url,
-                "editUrl": reverse("wagtailadmin_pages:edit", args=[instance.id]),
-                "createTranslationRequestUrl": reverse(
-                    "wagtail_localize:submit_page_translation", args=[instance.id]
-                ),
+                "editUrl": get_edit_url(instance),
+                "createTranslationRequestUrl": get_submit_translation_url(instance),
             }
-
         else:
             return {
                 "title": str(instance),
                 "isLive": True,
-                "editUrl": reverse(
-                    "wagtailsnippets:edit",
-                    args=[
-                        instance._meta.app_label,
-                        instance._meta.model_name,
-                        quote(instance.id),
-                    ],
-                ),
-                "createTranslationRequestUrl": reverse(
-                    "wagtail_localize:submit_snippet_translation",
-                    args=[
-                        instance._meta.app_label,
-                        instance._meta.model_name,
-                        quote(instance.id),
-                    ],
-                ),
+                "editUrl": get_edit_url(instance),
+                "createTranslationRequestUrl": get_submit_translation_url(instance),
             }
 
     def get_dest_object_info(segment):
@@ -774,21 +807,13 @@ def get_dest_object_info(segment):
                 "title": str(instance),
                 "isLive": instance.live,
                 "liveUrl": instance.full_url,
-                "editUrl": reverse("wagtailadmin_pages:edit", args=[instance.id]),
+                "editUrl": get_edit_url(instance),
             }
-
         else:
             return {
                 "title": str(instance),
                 "isLive": True,
-                "editUrl": reverse(
-                    "wagtailsnippets:edit",
-                    args=[
-                        instance._meta.app_label,
-                        instance._meta.model_name,
-                        quote(instance.id),
-                    ],
-                ),
+                "editUrl": get_edit_url(instance),
             }
 
     def get_translation_progress(segment, locale):
@@ -911,18 +936,7 @@ def get_translation_progress(segment, locale):
                                 "code": translated_instance.locale.language_code,
                                 "displayName": translated_instance.locale.get_display_name(),
                             },
-                            "editUrl": reverse(
-                                "wagtailadmin_pages:edit", args=[translated_instance.id]
-                            )
-                            if isinstance(translated_instance, Page)
-                            else reverse(
-                                "wagtailsnippets:edit",
-                                args=[
-                                    translated_instance._meta.app_label,
-                                    translated_instance._meta.model_name,
-                                    quote(translated_instance.id),
-                                ],
-                            ),
+                            "editUrl": get_edit_url(translated_instance),
                         }
                         for translated_instance in instance.get_translations().select_related(
                             "locale"
@@ -1084,13 +1098,21 @@ def restart_translation(request, translation, instance):
 
     if isinstance(instance, Page):
         return redirect("wagtailadmin_pages:edit", instance.id)
-    else:
+    elif instance._meta.model in get_snippet_models():
         return redirect(
             "wagtailsnippets:edit",
             instance._meta.app_label,
             instance._meta.model_name,
             quote(instance.pk),
         )
+    elif "wagtail_localize.modeladmin" in settings.INSTALLED_APPS:
+        return redirect(
+            "{app_label}_{model_name}_modeladmin_edit".format(
+                app_label=instance._meta.app_label,
+                model_name=instance._meta.model_name,
+            ),
+            instance_pk=quote(instance.pk),
+        )
 
 
 @api_view(["PUT", "DELETE"])
diff --git a/wagtail_localize/views/update_translations.py b/wagtail_localize/views/update_translations.py
index d21688bd..02128585 100644
--- a/wagtail_localize/views/update_translations.py
+++ b/wagtail_localize/views/update_translations.py
@@ -1,4 +1,5 @@
 from django import forms
+from django.conf import settings
 from django.contrib import messages
 from django.contrib.admin.utils import quote
 from django.core.exceptions import PermissionDenied, ValidationError
@@ -11,6 +12,7 @@
 from django.views.generic.detail import SingleObjectMixin
 from wagtail.admin.views.pages.utils import get_valid_next_url_from_request
 from wagtail.core.models import Page
+from wagtail.snippets.models import get_snippet_models
 
 from wagtail_localize.models import TranslationSource
 from wagtail_localize.views.submit_translations import TranslationComponentManager
@@ -60,7 +62,8 @@ def get_default_success_url(self):
 
         if isinstance(instance, Page):
             return reverse("wagtailadmin_explore", args=[instance.get_parent().id])
-        else:
+
+        elif instance._meta.model in get_snippet_models():
             return reverse(
                 "wagtailsnippets:edit",
                 args=[
@@ -70,10 +73,19 @@ def get_default_success_url(self):
                 ],
             )
 
+        elif "wagtail_localize.modeladmin" in settings.INSTALLED_APPS:
+            return reverse(
+                "{app_label}_{model_name}_modeladmin_index".format(
+                    app_label=instance._meta.app_label,
+                    model_name=instance._meta.model_name,
+                )
+            )
+
     def get_edit_url(self, instance):
         if isinstance(instance, Page):
             return reverse("wagtailadmin_pages:edit", args=[instance.id])
-        else:
+
+        elif instance._meta.model in get_snippet_models():
             return reverse(
                 "wagtailsnippets:edit",
                 args=[
@@ -83,6 +95,15 @@ def get_edit_url(self, instance):
                 ],
             )
 
+        elif "wagtail_localize.modeladmin" in settings.INSTALLED_APPS:
+            return reverse(
+                "{app_label}_{model_name}_modeladmin_edit".format(
+                    app_label=instance._meta.app_label,
+                    model_name=instance._meta.model_name,
+                ),
+                args=[quote(instance.pk)],
+            )
+
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         context.update(