diff --git a/gin.api.php b/gin.api.php
new file mode 100644
index 0000000000000000000000000000000000000000..fda7f5938acde16977366f599014e3d0ff470db4
--- /dev/null
+++ b/gin.api.php
@@ -0,0 +1,58 @@
+<?php
+
+/**
+ * @file
+ * Hooks for gin theme.
+ */
+
+/**
+ * @addtogroup hooks
+ * @{
+ */
+
+/**
+ * Register routes to apply Gin’s content edit form layout.
+ *
+ * Leverage this hook to achieve a consistent user interface layout on
+ * administrative edit forms, similar to the node edit forms. Any module
+ * providing a custom entity type or form mode may wish to implement this
+ * hook for their form routes. Please note that not every content entity
+ * form route should enable the Gin edit form layout, for example the
+ * delete entity form does not need it.
+ *
+ * @return array
+ *   An array of route names.
+ *
+ * @see GinContentFormHelper->isContentForm()
+ * @see hook_gin_content_form_routes_alter()
+ */
+function hook_gin_content_form_routes() {
+  return [
+    // Layout a custom node form.
+    'entity.node.my_custom_form',
+
+    // Layout a custom entity type edit form.
+    'entity.my_type.edit_form',
+  ];
+}
+
+/**
+ * Alter the registered routes to enable or disable Gin’s edit form layout.
+ *
+ * @param array $routes
+ *   The list of routes.
+ *
+ * @return array
+ *   An array of route names.
+ *
+ * @see GinContentFormHelper->isContentForm()
+ * @see hook_gin_content_form_routes()
+ */
+function hook_gin_content_form_routes_alter(array &$routes) {
+  // Example: disable Gin edit form layout customizations for an entity type.
+  $routes = array_diff($routes, ['entity.my_type.edit_form']);
+}
+
+/**
+ * @} End of "addtogroup hooks".
+ */
diff --git a/includes/form.theme b/includes/form.theme
index 85ae10f8ef8e0a757aa72951faf2ff6e07c837b2..9e5446b81593be5f391c65692434b673fa7c2083 100644
--- a/includes/form.theme
+++ b/includes/form.theme
@@ -6,86 +6,14 @@
  */
 
 use Drupal\Core\Form\FormStateInterface;
+use Drupal\gin\GinContentFormHelper;
 use Drupal\gin\GinSettings;
 
 /**
  * Implements form_alter_HOOK() for some major form changes.
  */
 function gin_form_alter(&$form, $form_state, $form_id) {
-  // Are we on an edit form?
-  if (_gin_is_content_form($form, $form_state, $form_id)) {
-    // Action buttons.
-    if (isset($form['actions'])) {
-      if (isset($form['actions']['preview'])) {
-        // Put Save after Preview.
-        $save_weight = $form['actions']['preview']['#weight'] ? $form['actions']['preview']['#weight'] + 1 : 11;
-        $form['actions']['submit']['#weight'] = $save_weight;
-      }
-
-      // Move entity_save_and_addanother_node after preview.
-      if (isset($form['actions']['entity_save_and_addanother_node'])) {
-        // Put Save after Preview.
-        $save_weight = $form['actions']['entity_save_and_addanother_node']['#weight'];
-        $form['actions']['preview']['#weight'] = $save_weight - 1;
-      }
-
-      // Create gin_actions group.
-      $form['gin_actions'] = [
-        '#type' => 'container',
-        '#weight' => -1,
-        '#multilingual' => TRUE,
-        '#attributes' => [
-          'class' => [
-            'gin-sticky',
-          ],
-        ],
-      ];
-      // Assign status to gin_actions.
-      $form['status']['#group'] = 'gin_actions';
-
-      // Create actions group.
-      $form['gin_actions']['actions'] = [
-        '#type' => 'actions',
-        '#weight' => 130,
-      ];
-
-      // Move all actions over.
-      $form['gin_actions']['actions'] = ($form['actions']) ?? [];
-      // Now let's just remove delete, as we'll move that over to gin_sidebar.
-      unset($form['gin_actions']['actions']['delete']);
-
-      // Create gin_sidebar group.
-      $form['gin_sidebar'] = [
-        '#group' => 'meta',
-        '#type' => 'container',
-        '#weight' => 99,
-        '#multilingual' => TRUE,
-        '#attributes' => [
-          'class' => [
-            'gin-sidebar',
-          ],
-        ],
-      ];
-      // Copy footer over.
-      $form['gin_sidebar']['footer'] = ($form['footer']) ?? [];
-      // Copy delete action.
-      $form['gin_sidebar']['actions'] = [];
-      $form['gin_sidebar']['actions']['#type'] = ($form['actions']['#type']) ?? [];
-      $form['gin_sidebar']['actions']['delete'] = ($form['actions']['delete']) ?? [];
-    }
-
-    // Attach library.
-    $form['#attached']['library'][] = 'gin/edit_form';
-  }
-
-  // If not logged in hide changed and author node info on add forms.
-  $not_logged_in = \Drupal::currentUser()->isAnonymous();
-  $route = \Drupal::routeMatch()->getRouteName();
-
-  if ($not_logged_in && $route == 'node.add') {
-    unset($form['meta']['changed']);
-    unset($form['meta']['author']);
-  }
+  \Drupal::classResolver(GinContentFormHelper::class)->formAlter($form, $form_state, $form_id);
 
   // User form (Login, Register or Forgot password).
   if (strpos($form_id, 'user_login') !== FALSE || strpos($form_id, 'user_register') !== FALSE || strpos($form_id, 'user_pass') !== FALSE) {
diff --git a/includes/helper.theme b/includes/helper.theme
index 7fc893759b9dddf35621ec0eccfedb3a1462ede4..db71510a44f93cf870892dd05f527c987f1d01b4 100644
--- a/includes/helper.theme
+++ b/includes/helper.theme
@@ -135,49 +135,3 @@ function _gin_validate_path_logo($path) {
   }
   return FALSE;
 }
-
-/**
- * Check if were on a content edit form.
- */
-function _gin_is_content_form($form = NULL, $form_state = NULL, $form_id = NULL) {
-  $is_content_form = FALSE;
-
-  // Get route name.
-  $route_name = \Drupal::routeMatch()->getRouteName();
-
-  // Routes to include.
-  $route_names = [
-    'node.add',
-    'entity.node.content_translation_add',
-    'entity.node.content_translation_edit',
-    'quick_node_clone.node.quick_clone',
-    'entity.node.edit_form',
-  ];
-
-  if (
-    in_array($route_name, $route_names, TRUE) ||
-    ($form_state && ($form_state->getBuildInfo()['base_form_id'] ?? NULL) === 'node_form') ||
-    ($route_name === 'entity.group_content.create_form' && strpos($form_id, 'group_node') === FALSE)
-  ) {
-    $is_content_form = TRUE;
-  }
-
-  // Forms to exclude.
-  // If media library widget, don't use new content edit form.
-  // gin_preprocess_html is not triggered here, so checking
-  // the form id is enough.
-  $form_ids_to_ignore = [
-    'media_library_add_form_',
-    'views_form_media_library_widget_',
-    'views_exposed_form',
-    'date_recur_modular_sierra_occurrences_modal',
-  ];
-
-  foreach ($form_ids_to_ignore as $form_id_to_ignore) {
-    if ($form_id && strpos($form_id, $form_id_to_ignore) !== FALSE) {
-      $is_content_form = FALSE;
-    }
-  }
-
-  return $is_content_form;
-}
diff --git a/includes/html.theme b/includes/html.theme
index 0efc5fb84d05a5f9835f14c1e757081b100cbc87..ce0bb844ebf1eb1ceb2312e8c9ae2fd8895b911d 100644
--- a/includes/html.theme
+++ b/includes/html.theme
@@ -5,6 +5,7 @@
  * html.theme
  */
 
+use Drupal\gin\GinContentFormHelper;
 use Drupal\gin\GinSettings;
 
 /**
@@ -32,7 +33,7 @@ function gin_preprocess_html(&$variables) {
     }
 
     // Edit form? Use the new Gin Edit form layout.
-    if (_gin_is_content_form()) {
+    if (\Drupal::classResolver(GinContentFormHelper::class)->isContentForm()) {
       $variables['attributes']['class'][] = 'gin--edit-form';
     }
 
diff --git a/includes/page.theme b/includes/page.theme
index 5a67884d7916e0f0dacbda365657500b2d1b6a92..05b520c1e873046b8d437c737f55a71976a9f63c 100644
--- a/includes/page.theme
+++ b/includes/page.theme
@@ -5,6 +5,7 @@
  * page.theme
  */
 
+use Drupal\gin\GinContentFormHelper;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\gin\GinSettings;
 
@@ -46,6 +47,12 @@ function gin_theme_suggestions_page_alter(&$suggestions, $variables) {
     $arg = str_replace(["/", '-'], ['_', '_'], $path);
     $suggestions[] = 'page__' . $arg;
   }
+
+  // The node page template is required to use the node content form.
+  if (\Drupal::classResolver(GinContentFormHelper::class)->isContentForm()
+    && !in_array('page__node', $suggestions)) {
+    $suggestions[] = 'page__node';
+  }
 }
 
 /**
diff --git a/src/GinContentFormHelper.php b/src/GinContentFormHelper.php
new file mode 100644
index 0000000000000000000000000000000000000000..19dac31b6d430b0f6dca0210234a5d7becbe3cd2
--- /dev/null
+++ b/src/GinContentFormHelper.php
@@ -0,0 +1,256 @@
+<?php
+
+namespace Drupal\gin;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\Theme\ThemeManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Service to handle content form overrides.
+ */
+class GinContentFormHelper implements ContainerInjectionInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The current user object.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The module handler service.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
+  /**
+   * The current route match.
+   *
+   * @var \Drupal\Core\Routing\RouteMatchInterface
+   */
+  protected $routeMatch;
+
+  /**
+   * The theme manager.
+   *
+   * @var \Drupal\Core\Theme\ThemeManagerInterface
+   */
+  protected $themeManager;
+
+  /**
+   * GinContentFormHelper constructor.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The current route match.
+   * @param \Drupal\Core\Theme\ThemeManagerInterface $theme_manager
+   *   The theme manager.
+   */
+  public function __construct(AccountInterface $current_user, ModuleHandlerInterface $module_handler, RouteMatchInterface $route_match, ThemeManagerInterface $theme_manager) {
+    $this->currentUser = $current_user;
+    $this->moduleHandler = $module_handler;
+    $this->routeMatch = $route_match;
+    $this->themeManager = $theme_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('current_user'),
+      $container->get('module_handler'),
+      $container->get('current_route_match'),
+      $container->get('theme.manager'),
+    );
+  }
+
+  /**
+   * Add some major form overrides.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   * @param string $form_id
+   *   The form id.
+   *
+   * @see hook_form_alter()
+   */
+  public function formAlter(array &$form, FormStateInterface $form_state, $form_id) {
+    // Are we on an edit form?
+    if (!$this->isContentForm($form, $form_state, $form_id)) {
+      return;
+    }
+
+    // Provide a default meta form element if not already provided.
+    // @see NodeForm::form()
+    $form['advanced']['#attributes']['class'][] = 'entity-meta';
+    if (!isset($form['meta'])) {
+      $form['meta'] = [
+        '#type' => 'container',
+        '#group' => 'advanced',
+        '#weight' => -10,
+        '#title' => $this->t('Status'),
+        '#attributes' => ['class' => ['entity-meta__header']],
+        '#tree' => TRUE,
+        '#access' => TRUE,
+      ];
+    }
+
+    // Specify necessary node form theme and library.
+    // @see claro_form_node_form_alter
+    $form['#theme'] = ['node_edit_form'];
+    $form['#attached']['library'][] = 'claro/node-form';
+    $form['#attached']['library'][] = 'gin/edit_form';
+
+    // Ensure correct settings for advanced, meta and revision form elements.
+    $form['advanced']['#type'] = 'container';
+    $form['advanced']['#accordion'] = TRUE;
+    $form['meta']['#type'] = 'container';
+    $form['meta']['#access'] = TRUE;
+
+    $form['revision_information']['#type'] = 'container';
+    $form['revision_information']['#group'] = 'meta';
+    $form['revision_information']['#attributes']['class'][] = 'entity-meta__revision';
+
+    // Action buttons.
+    if (isset($form['actions'])) {
+      if (isset($form['actions']['preview'])) {
+        // Put Save after Preview.
+        $save_weight = $form['actions']['preview']['#weight'] ? $form['actions']['preview']['#weight'] + 1 : 11;
+        $form['actions']['submit']['#weight'] = $save_weight;
+      }
+
+      // Create gin_actions group.
+      $form['gin_actions'] = [
+        '#type' => 'container',
+        '#weight' => -1,
+        '#multilingual' => TRUE,
+        '#attributes' => [
+          'class' => [
+            'gin-sticky',
+          ],
+        ],
+      ];
+      // Assign status to gin_actions.
+      $form['status']['#group'] = 'gin_actions';
+
+      // Create actions group.
+      $form['gin_actions']['actions'] = [
+        '#type' => 'actions',
+        '#weight' => 130,
+      ];
+      // Add Preview to gin_actions actions.
+      $form['gin_actions']['actions']['preview'] = ($form['actions']['preview']) ?? [];
+      // Add Submit to gin_actions actions.
+      $form['gin_actions']['actions']['submit'] = ($form['actions']['submit']) ?? [];
+
+      // Create gin_sidebar group.
+      $form['gin_sidebar'] = [
+        '#group' => 'meta',
+        '#type' => 'container',
+        '#weight' => 99,
+        '#multilingual' => TRUE,
+        '#attributes' => [
+          'class' => [
+            'gin-sidebar',
+          ],
+        ],
+      ];
+      // Copy footer over.
+      $form['gin_sidebar']['footer'] = ($form['footer']) ?? [];
+      // Copy actions over.
+      $form['gin_sidebar']['actions'] = ($form['actions']) ?? [];
+      // Unset previous added preview & submit.
+      unset($form['gin_sidebar']['actions']['preview']);
+      unset($form['gin_sidebar']['actions']['submit']);
+    }
+
+    // Attach library.
+    $form['#attached']['library'][] = 'gin/gin_editform';
+
+    // If not logged in hide changed and author node info on add forms.
+    $not_logged_in = $this->currentUser->isAnonymous();
+    $route = $this->routeMatch->getRouteName();
+
+    if ($not_logged_in && $route == 'node.add') {
+      unset($form['meta']['changed']);
+      unset($form['meta']['author']);
+    }
+
+  }
+
+  /**
+   * Check if we´re on a content edit form.
+   *
+   * _gin_is_content_form() is replaced by
+   * \Drupal::classResolver(GinContentFormHelper::class)->isContentForm().
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   * @param string $form_id
+   *   The form id.
+   */
+  public function isContentForm(array $form = NULL, FormStateInterface $form_state = NULL, $form_id = NULL) {
+    $is_content_form = FALSE;
+
+    // Get route name.
+    $route_name = $this->routeMatch->getRouteName();
+
+    // Routes to include.
+    $route_names = [
+      'node.add',
+      'entity.node.content_translation_add',
+      'quick_node_clone.node.quick_clone',
+      'entity.node.edit_form',
+    ];
+
+    $additional_routes = $this->moduleHandler->invokeAll('gin_content_form_routes');
+    $route_names = array_merge($additional_routes, $route_names);
+    $this->moduleHandler->alter('gin_content_form_routes', $route_names);
+    $this->themeManager->alter('gin_content_form_routes', $route_names);
+
+    if (
+      in_array($route_name, $route_names, TRUE) ||
+      ($form_state && ($form_state->getBuildInfo()['base_form_id'] ?? NULL) === 'node_form') ||
+      ($route_name === 'entity.group_content.create_form' && strpos($form_id, 'group_node') === FALSE)
+    ) {
+      $is_content_form = TRUE;
+    }
+
+    // Forms to exclude.
+    // If media library widget, don't use new content edit form.
+    // gin_preprocess_html is not triggered here, so checking
+    // the form id is enough.
+    $form_ids_to_ignore = [
+      'media_library_add_form_',
+      'views_form_media_library_widget_',
+      'views_exposed_form',
+      'date_recur_modular_sierra_occurrences_modal',
+    ];
+
+    foreach ($form_ids_to_ignore as $form_id_to_ignore) {
+      if ($form_id && strpos($form_id, $form_id_to_ignore) !== FALSE) {
+        $is_content_form = FALSE;
+      }
+    }
+
+    return $is_content_form;
+  }
+
+}