diff --git a/.prettierrc.json b/.prettierrc.json
index 99c247ae5..f3c0e3054 100644
--- a/.prettierrc.json
+++ b/.prettierrc.json
@@ -15,7 +15,7 @@
   "tabWidth": 2,
   "trailingComma": "es5",
   "useTabs": false,
-  "importOrder": ["^((api-mocks|components|data|formio|hooks|map|story-utils|types)/(.*)|(api|api-mocks|cache|Context|errors|headers|i18n|sdk|sentry|types|utils))$", "^[./]"],
+  "importOrder": ["^((api-mocks|components|data|formio|hooks|map|routes|story-utils|types)/(.*)|(api|api-mocks|cache|Context|errors|headers|i18n|routes|sdk|sentry|types|utils))$", "^[./]"],
   "importOrderSeparation": true,
   "importOrderSortSpecifiers": true
 }
diff --git a/.storybook/main.mts b/.storybook/main.mts
index 9917db5d9..c6604d812 100644
--- a/.storybook/main.mts
+++ b/.storybook/main.mts
@@ -17,7 +17,6 @@ const config: StorybookConfig = {
     'storybook-react-intl',
     'storybook-addon-remix-react-router',
     '@storybook/addon-coverage',
-    '@storybook/addon-webpack5-compiler-babel',
   ],
   framework: {
     name: '@storybook/react-vite',
diff --git a/codecov.yml b/codecov.yml
index 35f677c24..984721139 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -1,3 +1,8 @@
 ---
 
 flag_management: {}
+
+ignore:
+  - "src/api-mocks/*"
+  - "src/story-utils/*"
+  - "src/**/mocks.{js,jsx}"
diff --git a/package-lock.json b/package-lock.json
index 3297a6fc6..3ef62e3f9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -70,10 +70,10 @@
         "@storybook/react-vite": "^8.4.7",
         "@storybook/test-runner": "^0.20.0",
         "@storybook/types": "^8.4.7",
-        "@testing-library/dom": ">=8.20.0",
-        "@testing-library/jest-dom": "^5.14.1",
-        "@testing-library/react": "^14.0.0",
-        "@testing-library/user-event": "^14.4.3",
+        "@testing-library/dom": "^10.4.0",
+        "@testing-library/jest-dom": "^6.6.3",
+        "@testing-library/react": "^16.2.0",
+        "@testing-library/user-event": "^14.6.0",
         "@trivago/prettier-plugin-sort-imports": "^4.3.0",
         "@utrecht/component-library-css": "1.0.0-alpha.604",
         "@utrecht/component-library-react": "1.0.0-alpha.353",
@@ -6255,24 +6255,6 @@
         "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0"
       }
     },
-    "node_modules/@storybook/test/node_modules/@testing-library/dom": {
-      "version": "10.4.0",
-      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
-      "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
-      "dependencies": {
-        "@babel/code-frame": "^7.10.4",
-        "@babel/runtime": "^7.12.5",
-        "@types/aria-query": "^5.0.1",
-        "aria-query": "5.3.0",
-        "chalk": "^4.1.0",
-        "dom-accessibility-api": "^0.5.9",
-        "lz-string": "^1.5.0",
-        "pretty-format": "^27.0.2"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
     "node_modules/@storybook/test/node_modules/@testing-library/jest-dom": {
       "version": "6.5.0",
       "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.5.0.tgz",
@@ -6309,6 +6291,18 @@
       "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
       "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="
     },
+    "node_modules/@storybook/test/node_modules/@testing-library/user-event": {
+      "version": "14.5.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
+      "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
+      "engines": {
+        "node": ">=12",
+        "npm": ">=6"
+      },
+      "peerDependencies": {
+        "@testing-library/dom": ">=7.21.4"
+      }
+    },
     "node_modules/@storybook/test/node_modules/ansi-styles": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -6323,29 +6317,6 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
-    "node_modules/@storybook/test/node_modules/aria-query": {
-      "version": "5.3.0",
-      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
-      "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
-      "dependencies": {
-        "dequal": "^2.0.3"
-      }
-    },
-    "node_modules/@storybook/test/node_modules/chalk": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-      "dependencies": {
-        "ansi-styles": "^4.1.0",
-        "supports-color": "^7.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/chalk?sponsor=1"
-      }
-    },
     "node_modules/@storybook/test/node_modules/has-flag": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -6622,29 +6593,27 @@
       }
     },
     "node_modules/@testing-library/dom": {
-      "version": "9.3.4",
-      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
-      "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
-      "dev": true,
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
+      "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
       "dependencies": {
         "@babel/code-frame": "^7.10.4",
         "@babel/runtime": "^7.12.5",
         "@types/aria-query": "^5.0.1",
-        "aria-query": "5.1.3",
+        "aria-query": "5.3.0",
         "chalk": "^4.1.0",
         "dom-accessibility-api": "^0.5.9",
         "lz-string": "^1.5.0",
         "pretty-format": "^27.0.2"
       },
       "engines": {
-        "node": ">=14"
+        "node": ">=18"
       }
     },
     "node_modules/@testing-library/dom/node_modules/ansi-styles": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
       "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
       "dependencies": {
         "color-convert": "^2.0.1"
       },
@@ -6659,7 +6628,6 @@
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
       "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-      "dev": true,
       "dependencies": {
         "ansi-styles": "^4.1.0",
         "supports-color": "^7.1.0"
@@ -6675,7 +6643,6 @@
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
       "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-      "dev": true,
       "engines": {
         "node": ">=8"
       }
@@ -6684,7 +6651,6 @@
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
       "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-      "dev": true,
       "dependencies": {
         "has-flag": "^4.0.0"
       },
@@ -6693,23 +6659,21 @@
       }
     },
     "node_modules/@testing-library/jest-dom": {
-      "version": "5.17.0",
-      "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz",
-      "integrity": "sha512-ynmNeT7asXyH3aSVv4vvX4Rb+0qjOhdNHnO/3vuZNqPmhDpV/+rCSGwQ7bLcmU2cJ4dvoheIO85LQj0IbJHEtg==",
+      "version": "6.6.3",
+      "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz",
+      "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==",
       "dev": true,
       "dependencies": {
-        "@adobe/css-tools": "^4.0.1",
-        "@babel/runtime": "^7.9.2",
-        "@types/testing-library__jest-dom": "^5.9.1",
+        "@adobe/css-tools": "^4.4.0",
         "aria-query": "^5.0.0",
         "chalk": "^3.0.0",
         "css.escape": "^1.5.1",
-        "dom-accessibility-api": "^0.5.6",
-        "lodash": "^4.17.15",
+        "dom-accessibility-api": "^0.6.3",
+        "lodash": "^4.17.21",
         "redent": "^3.0.0"
       },
       "engines": {
-        "node": ">=8",
+        "node": ">=14",
         "npm": ">=6",
         "yarn": ">=1"
       }
@@ -6742,6 +6706,12 @@
         "node": ">=8"
       }
     },
+    "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+      "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+      "dev": true
+    },
     "node_modules/@testing-library/jest-dom/node_modules/has-flag": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -6764,27 +6734,37 @@
       }
     },
     "node_modules/@testing-library/react": {
-      "version": "14.2.1",
-      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.2.1.tgz",
-      "integrity": "sha512-sGdjws32ai5TLerhvzThYFbpnF9XtL65Cjf+gB0Dhr29BGqK+mAeN7SURSdu+eqgET4ANcWoC7FQpkaiGvBr+A==",
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.2.0.tgz",
+      "integrity": "sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==",
       "dev": true,
       "dependencies": {
-        "@babel/runtime": "^7.12.5",
-        "@testing-library/dom": "^9.0.0",
-        "@types/react-dom": "^18.0.0"
+        "@babel/runtime": "^7.12.5"
       },
       "engines": {
-        "node": ">=14"
+        "node": ">=18"
       },
       "peerDependencies": {
-        "react": "^18.0.0",
-        "react-dom": "^18.0.0"
+        "@testing-library/dom": "^10.0.0",
+        "@types/react": "^18.0.0 || ^19.0.0",
+        "@types/react-dom": "^18.0.0 || ^19.0.0",
+        "react": "^18.0.0 || ^19.0.0",
+        "react-dom": "^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
       }
     },
     "node_modules/@testing-library/user-event": {
-      "version": "14.5.2",
-      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
-      "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
+      "version": "14.6.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.0.tgz",
+      "integrity": "sha512-+jsfK7kVJbqnCYtLTln8Ja/NmVrZRwBJHmHR9IxIVccMWSOZ6Oy0FkDJNeyVu4QSpMNmRfy10Xb76ObRDlWWBQ==",
+      "dev": true,
       "engines": {
         "node": ">=12",
         "npm": ">=6"
@@ -7035,67 +7015,6 @@
         "@types/istanbul-lib-report": "*"
       }
     },
-    "node_modules/@types/jest": {
-      "version": "28.1.3",
-      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-28.1.3.tgz",
-      "integrity": "sha512-Tsbjk8Y2hkBaY/gJsataeb4q9Mubw9EOz7+4RjPkzD5KjTvHHs7cpws22InaoXxAVAhF5HfFbzJjo6oKWqSZLw==",
-      "dev": true,
-      "dependencies": {
-        "jest-matcher-utils": "^28.0.0",
-        "pretty-format": "^28.0.0"
-      }
-    },
-    "node_modules/@types/jest/node_modules/@jest/schemas": {
-      "version": "28.1.3",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz",
-      "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==",
-      "dev": true,
-      "dependencies": {
-        "@sinclair/typebox": "^0.24.1"
-      },
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
-      }
-    },
-    "node_modules/@types/jest/node_modules/@sinclair/typebox": {
-      "version": "0.24.51",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz",
-      "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==",
-      "dev": true
-    },
-    "node_modules/@types/jest/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/@types/jest/node_modules/pretty-format": {
-      "version": "28.1.3",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz",
-      "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==",
-      "dev": true,
-      "dependencies": {
-        "@jest/schemas": "^28.1.3",
-        "ansi-regex": "^5.0.1",
-        "ansi-styles": "^5.0.0",
-        "react-is": "^18.0.0"
-      },
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
-      }
-    },
-    "node_modules/@types/jest/node_modules/react-is": {
-      "version": "18.2.0",
-      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
-      "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
-      "dev": true
-    },
     "node_modules/@types/js-cookie": {
       "version": "2.2.7",
       "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz",
@@ -7160,15 +7079,6 @@
         "csstype": "^3.0.2"
       }
     },
-    "node_modules/@types/react-dom": {
-      "version": "18.2.22",
-      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.22.tgz",
-      "integrity": "sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==",
-      "dev": true,
-      "dependencies": {
-        "@types/react": "*"
-      }
-    },
     "node_modules/@types/react-transition-group": {
       "version": "4.4.10",
       "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
@@ -7194,15 +7104,6 @@
       "integrity": "sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==",
       "dev": true
     },
-    "node_modules/@types/testing-library__jest-dom": {
-      "version": "5.14.9",
-      "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz",
-      "integrity": "sha512-FSYhIjFlfOpGSRyVoMBMuS3ws5ehFQODymf3vlI7U1K8c7PHwWwFY7VREfmsuzHSOnoKs/9/Y983ayOs7eRzqw==",
-      "dev": true,
-      "dependencies": {
-        "@types/jest": "*"
-      }
-    },
     "node_modules/@types/tough-cookie": {
       "version": "4.0.5",
       "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
@@ -7920,12 +7821,11 @@
       }
     },
     "node_modules/aria-query": {
-      "version": "5.1.3",
-      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
-      "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
-      "dev": true,
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+      "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
       "dependencies": {
-        "deep-equal": "^2.0.5"
+        "dequal": "^2.0.3"
       }
     },
     "node_modules/array-buffer-byte-length": {
@@ -9728,38 +9628,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/deep-equal": {
-      "version": "2.2.3",
-      "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
-      "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
-      "dev": true,
-      "dependencies": {
-        "array-buffer-byte-length": "^1.0.0",
-        "call-bind": "^1.0.5",
-        "es-get-iterator": "^1.1.3",
-        "get-intrinsic": "^1.2.2",
-        "is-arguments": "^1.1.1",
-        "is-array-buffer": "^3.0.2",
-        "is-date-object": "^1.0.5",
-        "is-regex": "^1.1.4",
-        "is-shared-array-buffer": "^1.0.2",
-        "isarray": "^2.0.5",
-        "object-is": "^1.1.5",
-        "object-keys": "^1.1.1",
-        "object.assign": "^4.1.4",
-        "regexp.prototype.flags": "^1.5.1",
-        "side-channel": "^1.0.4",
-        "which-boxed-primitive": "^1.0.2",
-        "which-collection": "^1.0.1",
-        "which-typed-array": "^1.1.13"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
     "node_modules/deep-is": {
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -9883,15 +9751,6 @@
       "resolved": "https://registry.npmjs.org/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz",
       "integrity": "sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w=="
     },
-    "node_modules/diff-sequences": {
-      "version": "28.1.1",
-      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-28.1.1.tgz",
-      "integrity": "sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==",
-      "dev": true,
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
-      }
-    },
     "node_modules/diffable-html": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/diffable-html/-/diffable-html-4.1.0.tgz",
@@ -10189,26 +10048,6 @@
         "node": ">= 0.4"
       }
     },
-    "node_modules/es-get-iterator": {
-      "version": "1.1.3",
-      "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
-      "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
-      "dev": true,
-      "dependencies": {
-        "call-bind": "^1.0.2",
-        "get-intrinsic": "^1.1.3",
-        "has-symbols": "^1.0.3",
-        "is-arguments": "^1.1.1",
-        "is-map": "^2.0.2",
-        "is-set": "^2.0.2",
-        "is-string": "^1.0.7",
-        "isarray": "^2.0.5",
-        "stop-iteration-iterator": "^1.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
     "node_modules/es-iterator-helpers": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz",
@@ -13852,133 +13691,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/jest-diff": {
-      "version": "28.1.3",
-      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-28.1.3.tgz",
-      "integrity": "sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==",
-      "dev": true,
-      "dependencies": {
-        "chalk": "^4.0.0",
-        "diff-sequences": "^28.1.1",
-        "jest-get-type": "^28.0.2",
-        "pretty-format": "^28.1.3"
-      },
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
-      }
-    },
-    "node_modules/jest-diff/node_modules/@jest/schemas": {
-      "version": "28.1.3",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz",
-      "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==",
-      "dev": true,
-      "dependencies": {
-        "@sinclair/typebox": "^0.24.1"
-      },
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
-      }
-    },
-    "node_modules/jest-diff/node_modules/@sinclair/typebox": {
-      "version": "0.24.51",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz",
-      "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==",
-      "dev": true
-    },
-    "node_modules/jest-diff/node_modules/ansi-styles": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
-      "dependencies": {
-        "color-convert": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-diff/node_modules/chalk": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-      "dev": true,
-      "dependencies": {
-        "ansi-styles": "^4.1.0",
-        "supports-color": "^7.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/chalk?sponsor=1"
-      }
-    },
-    "node_modules/jest-diff/node_modules/has-flag": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-      "dev": true,
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/jest-diff/node_modules/jest-get-type": {
-      "version": "28.0.2",
-      "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-28.0.2.tgz",
-      "integrity": "sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA==",
-      "dev": true,
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
-      }
-    },
-    "node_modules/jest-diff/node_modules/pretty-format": {
-      "version": "28.1.3",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz",
-      "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==",
-      "dev": true,
-      "dependencies": {
-        "@jest/schemas": "^28.1.3",
-        "ansi-regex": "^5.0.1",
-        "ansi-styles": "^5.0.0",
-        "react-is": "^18.0.0"
-      },
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
-      }
-    },
-    "node_modules/jest-diff/node_modules/pretty-format/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-diff/node_modules/react-is": {
-      "version": "18.2.0",
-      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
-      "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
-      "dev": true
-    },
-    "node_modules/jest-diff/node_modules/supports-color": {
-      "version": "7.2.0",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-      "dev": true,
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/jest-docblock": {
       "version": "29.7.0",
       "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz",
@@ -14250,133 +13962,6 @@
       "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
       "dev": true
     },
-    "node_modules/jest-matcher-utils": {
-      "version": "28.1.3",
-      "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-28.1.3.tgz",
-      "integrity": "sha512-kQeJ7qHemKfbzKoGjHHrRKH6atgxMk8Enkk2iPQ3XwO6oE/KYD8lMYOziCkeSB9G4adPM4nR1DE8Tf5JeWH6Bw==",
-      "dev": true,
-      "dependencies": {
-        "chalk": "^4.0.0",
-        "jest-diff": "^28.1.3",
-        "jest-get-type": "^28.0.2",
-        "pretty-format": "^28.1.3"
-      },
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
-      }
-    },
-    "node_modules/jest-matcher-utils/node_modules/@jest/schemas": {
-      "version": "28.1.3",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz",
-      "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==",
-      "dev": true,
-      "dependencies": {
-        "@sinclair/typebox": "^0.24.1"
-      },
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
-      }
-    },
-    "node_modules/jest-matcher-utils/node_modules/@sinclair/typebox": {
-      "version": "0.24.51",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz",
-      "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==",
-      "dev": true
-    },
-    "node_modules/jest-matcher-utils/node_modules/ansi-styles": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
-      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
-      "dependencies": {
-        "color-convert": "^2.0.1"
-      },
-      "engines": {
-        "node": ">=8"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-matcher-utils/node_modules/chalk": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
-      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
-      "dev": true,
-      "dependencies": {
-        "ansi-styles": "^4.1.0",
-        "supports-color": "^7.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/chalk?sponsor=1"
-      }
-    },
-    "node_modules/jest-matcher-utils/node_modules/has-flag": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
-      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
-      "dev": true,
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/jest-matcher-utils/node_modules/jest-get-type": {
-      "version": "28.0.2",
-      "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-28.0.2.tgz",
-      "integrity": "sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA==",
-      "dev": true,
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
-      }
-    },
-    "node_modules/jest-matcher-utils/node_modules/pretty-format": {
-      "version": "28.1.3",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz",
-      "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==",
-      "dev": true,
-      "dependencies": {
-        "@jest/schemas": "^28.1.3",
-        "ansi-regex": "^5.0.1",
-        "ansi-styles": "^5.0.0",
-        "react-is": "^18.0.0"
-      },
-      "engines": {
-        "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0"
-      }
-    },
-    "node_modules/jest-matcher-utils/node_modules/pretty-format/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/jest-matcher-utils/node_modules/react-is": {
-      "version": "18.2.0",
-      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
-      "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
-      "dev": true
-    },
-    "node_modules/jest-matcher-utils/node_modules/supports-color": {
-      "version": "7.2.0",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
-      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
-      "dev": true,
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/jest-message-util": {
       "version": "29.7.0",
       "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
@@ -16622,22 +16207,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/object-is": {
-      "version": "1.1.6",
-      "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
-      "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
-      "dev": true,
-      "dependencies": {
-        "call-bind": "^1.0.7",
-        "define-properties": "^1.2.1"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
     "node_modules/object-keys": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@@ -18798,18 +18367,6 @@
       "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==",
       "dev": true
     },
-    "node_modules/stop-iteration-iterator": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",
-      "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==",
-      "dev": true,
-      "dependencies": {
-        "internal-slot": "^1.0.4"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
     "node_modules/storybook": {
       "version": "8.4.7",
       "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.4.7.tgz",
diff --git a/package.json b/package.json
index bba692856..f4e9403b3 100644
--- a/package.json
+++ b/package.json
@@ -115,10 +115,10 @@
     "@storybook/react-vite": "^8.4.7",
     "@storybook/test-runner": "^0.20.0",
     "@storybook/types": "^8.4.7",
-    "@testing-library/dom": ">=8.20.0",
-    "@testing-library/jest-dom": "^5.14.1",
-    "@testing-library/react": "^14.0.0",
-    "@testing-library/user-event": "^14.4.3",
+    "@testing-library/dom": "^10.4.0",
+    "@testing-library/jest-dom": "^6.6.3",
+    "@testing-library/react": "^16.2.0",
+    "@testing-library/user-event": "^14.6.0",
     "@trivago/prettier-plugin-sort-imports": "^4.3.0",
     "@utrecht/component-library-css": "1.0.0-alpha.604",
     "@utrecht/component-library-react": "1.0.0-alpha.353",
diff --git a/src/Context.js b/src/Context.js
index 331fd04d9..a6f148d35 100644
--- a/src/Context.js
+++ b/src/Context.js
@@ -27,14 +27,6 @@ const FormContext = React.createContext({
 });
 FormContext.displayName = 'FormContext';
 
-const AnalyticsToolsConfigContext = React.createContext({
-  govmetricSourceIdFormFinished: '',
-  govmetricSourceIdFormAborted: '',
-  govmetricSecureGuidFormFinished: '',
-  govmetricSecureGuidFormAborted: '',
-  enableGovmetricAnalytics: false,
-});
-
 const ConfigContext = React.createContext({
   baseUrl: '',
   clientBaseUrl: window.location.href,
@@ -51,10 +43,4 @@ FormioTranslations.displayName = 'FormioTranslations';
 const SubmissionContext = React.createContext({submission: null});
 SubmissionContext.displayName = 'SubmissionContext';
 
-export {
-  FormContext,
-  ConfigContext,
-  FormioTranslations,
-  SubmissionContext,
-  AnalyticsToolsConfigContext,
-};
+export {FormContext, ConfigContext, FormioTranslations, SubmissionContext};
diff --git a/src/api-mocks/submissions.js b/src/api-mocks/submissions.js
index bc2842d40..f40eb5764 100644
--- a/src/api-mocks/submissions.js
+++ b/src/api-mocks/submissions.js
@@ -73,9 +73,9 @@ export const mockSubmissionPost = (submission = buildSubmission()) =>
     return HttpResponse.json(submission, {status: 201});
   });
 
-export const mockSubmissionGet = () =>
+export const mockSubmissionGet = (submission = buildSubmission()) =>
   http.get(`${BASE_URL}submissions/:uuid`, () => {
-    return HttpResponse.json(SUBMISSION_DETAILS, {status: 200});
+    return HttpResponse.json(submission, {status: 200});
   });
 
 export const mockSubmissionStepGet = () =>
@@ -112,6 +112,13 @@ export const mockSubmissionSummaryGet = () =>
     )
   );
 
+export const mockSubmissionCompletePost = () =>
+  http.post(`${BASE_URL}submissions/:uuid/_complete`, () =>
+    HttpResponse.json({
+      statusUrl: `${BASE_URL}submissions/${SUBMISSION_DETAILS.id}/super-random-token/status`,
+    })
+  );
+
 /**
  * Simulate a successful backend processing status without payment.
  */
@@ -160,6 +167,6 @@ export const mockSubmissionProcessingStatusErrorGet = http.get(
     })
 );
 
-export const mockSubmissionPaymentStartGet = http.post(`${BASE_URL}payment/:uuid/demo/start`, () =>
-  HttpResponse.json({data: {method: 'get', action: 'https://example.com'}})
-);
+export const mockSubmissionPaymentStartPost = (
+  data = {type: 'get', url: 'https://example.com', data: {}}
+) => http.post(`${BASE_URL}payment/:uuid/demo/start`, () => HttpResponse.json(data));
diff --git a/src/components/AbortButton/AbortButton.jsx b/src/components/AbortButton/AbortButton.jsx
index 8fc4958ee..7078b469a 100644
--- a/src/components/AbortButton/AbortButton.jsx
+++ b/src/components/AbortButton/AbortButton.jsx
@@ -1,15 +1,14 @@
 import PropTypes from 'prop-types';
-import {useContext} from 'react';
 import {FormattedMessage, useIntl} from 'react-intl';
 
-import {AnalyticsToolsConfigContext} from 'Context';
 import {OFButton} from 'components/Button';
+import {useAnalyticsToolsConfig} from 'components/analytics/AnalyticsToolConfigProvider';
 import {buildGovMetricUrl} from 'components/analytics/utils';
 import useFormContext from 'hooks/useFormContext';
 
 const AbortButton = ({isAuthenticated, onDestroySession}) => {
   const intl = useIntl();
-  const analyticsToolsConfig = useContext(AnalyticsToolsConfigContext);
+  const analyticsToolsConfig = useAnalyticsToolsConfig();
   const form = useFormContext();
 
   const confirmationMessage = isAuthenticated
diff --git a/src/components/App.jsx b/src/components/App.jsx
index 238688802..c8406e627 100644
--- a/src/components/App.jsx
+++ b/src/components/App.jsx
@@ -1,60 +1,14 @@
-import {Navigate, Outlet, useMatch} from 'react-router-dom';
+import {Navigate, Outlet, useMatch, useSearchParams} from 'react-router-dom';
 
-import {Cosign, cosignRoutes} from 'components/CoSign';
-import ErrorBoundary from 'components/Errors/ErrorBoundary';
-import Form from 'components/Form';
-import SessionExpired from 'components/Sessions/SessionExpired';
-import {
-  CreateAppointment,
-  appointmentRoutes,
-  manageAppointmentRoutes,
-} from 'components/appointments';
-import formRoutes from 'components/formRoutes';
 import useFormContext from 'hooks/useFormContext';
-import useQuery from 'hooks/useQuery';
 import useZodErrorMap from 'hooks/useZodErrorMap';
 
-export const routes = [
-  {
-    path: 'afspraak-annuleren/*',
-    children: manageAppointmentRoutes,
-  },
-  {
-    path: 'afspraak-maken/*',
-    element: <CreateAppointment />,
-    children: appointmentRoutes,
-  },
-  {
-    path: 'cosign/*',
-    element: <Cosign />,
-    children: cosignRoutes,
-  },
-  {
-    path: 'sessie-verlopen',
-    element: (
-      <ErrorBoundary useCard>
-        <SessionExpired />
-      </ErrorBoundary>
-    ),
-  },
-  // All the rest goes to the formio-based form flow
-  {
-    path: '*',
-    element: (
-      <ErrorBoundary useCard>
-        <Form />
-      </ErrorBoundary>
-    ),
-    children: formRoutes,
-  },
-];
-
 /*
 Top level router - routing between an actual form or supporting screens.
  */
 const App = () => {
   const form = useFormContext();
-  const query = useQuery();
+  const [params] = useSearchParams();
   const appointmentMatch = useMatch('afspraak-maken/*');
   const appointmentCancelMatch = useMatch('afspraak-annuleren/*');
   const isSessionExpiryMatch = useMatch('sessie-verlopen');
@@ -69,7 +23,7 @@ const App = () => {
         replace
         to={{
           pathname: '../afspraak-maken',
-          search: `?${query}`,
+          search: `?${params}`,
         }}
       />
     );
diff --git a/src/components/App.stories.jsx b/src/components/App.stories.jsx
index 1dd589691..41a253747 100644
--- a/src/components/App.stories.jsx
+++ b/src/components/App.stories.jsx
@@ -10,9 +10,10 @@ import {
   mockSubmissionStepGet,
 } from 'api-mocks/submissions';
 import {mockLanguageChoicePut, mockLanguageInfoGet} from 'components/LanguageSelection/mocks';
+import routes from 'routes';
 import {ConfigDecorator, LayoutDecorator} from 'story-utils/decorators';
 
-import App, {routes as nestedRoutes} from './App';
+import App from './App';
 import {SUBMISSION_ALLOWED} from './constants';
 
 export default {
@@ -84,13 +85,6 @@ export default {
 };
 
 const Wrapper = ({form, showExternalHeader}) => {
-  const routes = [
-    {
-      path: '*',
-      element: <App />,
-      children: nestedRoutes,
-    },
-  ];
   const router = createMemoryRouter(routes, {
     initialEntries: ['/'],
     initialIndex: 0,
diff --git a/src/components/CoSign/Cosign.spec.jsx b/src/components/CoSign/Cosign.spec.jsx
index 146c579d7..9a6fa5f28 100644
--- a/src/components/CoSign/Cosign.spec.jsx
+++ b/src/components/CoSign/Cosign.spec.jsx
@@ -7,9 +7,9 @@ import {ConfigContext, FormContext} from 'Context';
 import {BASE_URL, buildForm} from 'api-mocks';
 import mswServer from 'api-mocks/msw-server';
 import {mockSubmissionGet, mockSubmissionSummaryGet} from 'api-mocks/submissions';
+import cosignRoutes from 'routes/cosign';
 
 import Cosign from './Cosign';
-import {default as nestedRoutes} from './routes';
 
 beforeEach(() => {
   localStorage.clear();
@@ -55,7 +55,7 @@ const routes = [
   {
     path: '/cosign/*',
     element: <Cosign />,
-    children: nestedRoutes,
+    children: cosignRoutes,
   },
 ];
 
diff --git a/src/components/CoSign/index.jsx b/src/components/CoSign/index.jsx
index 71db38b45..2a224845a 100644
--- a/src/components/CoSign/index.jsx
+++ b/src/components/CoSign/index.jsx
@@ -1,7 +1,6 @@
 import CoSignOld from './CoSignOld';
 import Cosign from './Cosign';
 import CosignDone from './CosignDone';
-import cosignRoutes from './routes';
 
 export default CoSignOld;
-export {Cosign, CosignDone, cosignRoutes};
+export {Cosign, CosignDone};
diff --git a/src/components/Form.jsx b/src/components/Form.jsx
index 3ead291d9..bf48cdc6e 100644
--- a/src/components/Form.jsx
+++ b/src/components/Form.jsx
@@ -1,102 +1,22 @@
-import PropTypes from 'prop-types';
-import React, {useContext, useEffect} from 'react';
+import {useContext, useEffect, useState} from 'react';
 import {useIntl} from 'react-intl';
-import {
-  Navigate,
-  Outlet,
-  Route,
-  Routes,
-  useLocation,
-  useMatch,
-  useNavigate,
-} from 'react-router-dom';
-import {useAsync, usePrevious} from 'react-use';
-import {useImmerReducer} from 'use-immer';
+import {Navigate, Outlet, useLocation, useNavigate, useSearchParams} from 'react-router-dom';
+import {usePrevious} from 'react-use';
 
-import {AnalyticsToolsConfigContext, ConfigContext} from 'Context';
-import {destroy, get} from 'api';
-import ErrorBoundary from 'components/Errors/ErrorBoundary';
+import {ConfigContext} from 'Context';
+import {destroy} from 'api';
+import FormProgressIndicator from 'components/FormProgressIndicator';
 import Loader from 'components/Loader';
-import {ConfirmationView, StartPaymentView} from 'components/PostCompletionViews';
-import ProgressIndicator from 'components/ProgressIndicator';
-import RequireSubmission from 'components/RequireSubmission';
-import {SessionTrackerModal} from 'components/Sessions';
-import {SubmissionSummary} from 'components/Summary';
-import {
-  PI_TITLE,
-  START_FORM_QUERY_PARAM,
-  STEP_LABELS,
-  SUBMISSION_ALLOWED,
-} from 'components/constants';
+import SubmissionProvider from 'components/SubmissionProvider';
+import AnalyticsToolsConfigProvider from 'components/analytics/AnalyticsToolConfigProvider';
+import {START_FORM_QUERY_PARAM} from 'components/constants';
 import {flagActiveSubmission, flagNoActiveSubmission} from 'data/submissions';
 import useAutomaticRedirect from 'hooks/useAutomaticRedirect';
 import useFormContext from 'hooks/useFormContext';
 import usePageViews from 'hooks/usePageViews';
-import useQuery from 'hooks/useQuery';
 import useRecycleSubmission from 'hooks/useRecycleSubmission';
-import Types from 'types';
 
 import FormDisplay from './FormDisplay';
-import {addFixedSteps, getStepsInfo} from './ProgressIndicator/utils';
-
-const initialState = {
-  submission: null,
-  submittedSubmission: null,
-  processingStatusUrl: '',
-  processingError: '',
-  completed: false,
-  startingError: '',
-};
-
-const reducer = (draft, action) => {
-  switch (action.type) {
-    case 'SUBMISSION_LOADED': {
-      // keep the submission instance in the state and set the current step to the
-      // first step of the form.
-      draft.submission = action.payload;
-      break;
-    }
-    case 'SUBMITTED': {
-      return {
-        ...initialState,
-        submittedSubmission: action.payload.submission,
-        processingStatusUrl: action.payload.processingStatusUrl,
-      };
-    }
-    case 'PROCESSING_FAILED': {
-      // set the error message in the state
-      draft.processingError = action.payload;
-      // put the submission back in the state as well, so we can re-submit
-      draft.submission = draft.submittedSubmission;
-      break;
-    }
-    case 'PROCESSING_SUCCEEDED': {
-      draft.processingError = null;
-      draft.completed = true;
-      break;
-    }
-    case 'CLEAR_PROCESSING_ERROR': {
-      draft.processingError = '';
-      break;
-    }
-    case 'DESTROY_SUBMISSION': {
-      return {
-        ...initialState,
-      };
-    }
-    case 'RESET': {
-      const initialState = action.payload;
-      return initialState;
-    }
-    case 'STARTING_ERROR': {
-      draft.startingError = action.payload;
-      break;
-    }
-    default: {
-      throw new Error(`Unknown action ${action.type}`);
-    }
-  }
-};
 
 /**
  * An OpenForms form.
@@ -110,301 +30,99 @@ const Form = () => {
   const form = useFormContext();
   const navigate = useNavigate();
   const shouldAutomaticallyRedirect = useAutomaticRedirect(form);
-  const queryParams = useQuery();
+  const [params] = useSearchParams();
   usePageViews();
   const intl = useIntl();
   const prevLocale = usePrevious(intl.locale);
-  const {pathname: currentPathname} = useLocation();
-
-  // TODO replace absolute path check with relative
-  const introductionMatch = useMatch('/introductie');
-  const stepMatch = useMatch('/stap/:step');
-  const summaryMatch = useMatch('/overzicht');
-  const paymentMatch = useMatch('/betalen');
-  const confirmationMatch = useMatch('/bevestiging');
+  const {state: routerState} = useLocation();
 
   // extract the declared properties and configuration
-  const {steps} = form;
   const config = useContext(ConfigContext);
 
-  // load the state management/reducer
-  const initialStateFromProps = {...initialState, step: steps[0]};
-  const [state, dispatch] = useImmerReducer(reducer, initialStateFromProps);
+  // figure out the submission in the state. If it's stored in the router state, extract
+  // it and set it in the React state to 'persist' it.
+  const submissionFromRouterState = routerState?.submission;
+  const [submission, setSubmission] = useState(null);
+  if (submission == null && submissionFromRouterState != null) {
+    setSubmission(submissionFromRouterState);
+  }
 
-  const onSubmissionLoaded = (submission, next = '') => {
-    dispatch({
-      type: 'SUBMISSION_LOADED',
-      payload: submission,
-    });
+  const onSubmissionLoaded = submission => {
+    setSubmission(submission);
     flagActiveSubmission();
-    // navigate to the first step
-    const firstStepRoute = `/stap/${form.steps[0].slug}`;
-    navigate(next ? next : firstStepRoute);
   };
 
   // if there is an active submission still, re-load that (relevant for hard-refreshes)
+  // TODO: should probably move to the router loader
   const [loading, setSubmissionId, removeSubmissionId] = useRecycleSubmission(
     form,
-    state.submission,
+    submission,
     onSubmissionLoaded
   );
 
-  const {value: analyticsToolsConfigInfo, loading: loadingAnalyticsConfig} = useAsync(async () => {
-    return await get(`${config.baseUrl}analytics/analytics-tools-config-info`);
-  }, [intl.locale]);
-
   useEffect(
     () => {
       if (prevLocale === undefined) return;
-      if (intl.locale !== prevLocale && state.submission) {
+      if (intl.locale !== prevLocale && submission) {
         removeSubmissionId();
-        dispatch({type: 'DESTROY_SUBMISSION'});
+        setSubmission(null);
         flagNoActiveSubmission();
         navigate(`/?${START_FORM_QUERY_PARAM}=1`);
       }
     },
-    [intl.locale, prevLocale, removeSubmissionId, state.submission] // eslint-disable-line react-hooks/exhaustive-deps
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [intl.locale, prevLocale, removeSubmissionId, submission]
   );
 
-  const onSubmissionObtained = submission => {
-    dispatch({
-      type: 'SUBMISSION_LOADED',
-      payload: submission,
-    });
-    flagActiveSubmission();
-    setSubmissionId(submission.id);
-  };
-
-  const onSubmitForm = processingStatusUrl => {
-    removeSubmissionId();
-    dispatch({
-      type: 'SUBMITTED',
-      payload: {
-        submission: state.submission,
-        processingStatusUrl,
-      },
-    });
-
-    if (submission?.payment.isRequired && !state.submission.payment.hasPaid) {
-      navigate('/betalen');
-    } else {
-      navigate('/bevestiging');
-    }
-  };
-
   const onDestroySession = async () => {
-    await destroy(`${config.baseUrl}authentication/${state.submission.id}/session`);
-
+    await destroy(`${config.baseUrl}authentication/${submission.id}/session`);
     removeSubmissionId();
-    dispatch({
-      type: 'RESET',
-      payload: initialStateFromProps,
-    });
+    setSubmission(null);
     navigate('/');
   };
 
-  const onProcessingFailure = errorMessage => {
-    // TODO: provide generic fallback message in case no explicit
-    // message is shown
-    dispatch({type: 'PROCESSING_FAILED', payload: errorMessage});
-    navigate('/overzicht');
-  };
-
   // handle redirect from payment provider to render appropriate page and include the
   // params as state for the next component.
-  if (queryParams.get('of_payment_status')) {
+  if (params.get('of_payment_status')) {
+    // TODO: store details in sessionStorage instead, to survive hard refreshes
     return (
       <Navigate
         replace
         to="/bevestiging"
         state={{
-          status: queryParams.get('of_payment_status'),
-          userAction: queryParams.get('of_payment_action'),
-          statusUrl: queryParams.get('of_submission_status'),
+          status: params.get('of_payment_status'),
+          userAction: params.get('of_payment_action'),
+          statusUrl: params.get('of_submission_status'),
         }}
       />
     );
   }
 
-  if (loading || loadingAnalyticsConfig || shouldAutomaticallyRedirect) {
+  if (loading || shouldAutomaticallyRedirect) {
     return <Loader modifiers={['centered']} />;
   }
 
-  // Progress Indicator
-
-  const isIntroductionPage = !!introductionMatch;
-  const isStartPage = !isIntroductionPage && !summaryMatch && stepMatch == null && !paymentMatch;
-  const submissionAllowedSpec = state.submission?.submissionAllowed ?? form.submissionAllowed;
-  const showOverview = submissionAllowedSpec !== SUBMISSION_ALLOWED.noWithoutOverview;
-  const submission = state.submission || state.submittedSubmission;
-  const isCompleted = state.completed;
-  const formName = form.name;
-  const needsPayment = submission ? submission.payment.isRequired : form.paymentRequired;
-
-  // Figure out the slug from the currently active step IF we're looking at a step
-  const stepSlug = stepMatch ? stepMatch.params.step : '';
-
-  // figure out the title for the mobile menu based on the state
-  let activeStepTitle;
-  if (isIntroductionPage) {
-    activeStepTitle = intl.formatMessage(STEP_LABELS.introduction);
-  } else if (isStartPage) {
-    activeStepTitle = intl.formatMessage(STEP_LABELS.login);
-  } else if (summaryMatch) {
-    activeStepTitle = intl.formatMessage(STEP_LABELS.overview);
-  } else if (paymentMatch) {
-    activeStepTitle = intl.formatMessage(STEP_LABELS.payment);
-  } else {
-    const step = steps.find(step => step.slug === stepSlug);
-    activeStepTitle = step.formDefinition;
-  }
-
-  const ariaMobileIconLabel = intl.formatMessage({
-    description: 'Progress step indicator toggle icon (mobile)',
-    defaultMessage: 'Toggle the progress status display',
-  });
-
-  const accessibleToggleStepsLabel = intl.formatMessage(
-    {
-      description: 'Active step accessible label in mobile progress indicator',
-      defaultMessage: 'Current step in form {formName}: {activeStepTitle}',
-    },
-    {formName, activeStepTitle}
-  );
-
-  // process the form/submission steps information into step data that can be passed
-  // to the progress indicator.
-  // If the form is marked to not display non-applicable steps at all, filter them out.
-  const showNonApplicableSteps = !form.hideNonApplicableSteps;
-  const updatedSteps =
-    // first, process all the form steps in a format suitable for the PI
-    getStepsInfo(steps, submission, currentPathname)
-      // then, filter out the non-applicable steps if they should not be displayed
-      .filter(step => showNonApplicableSteps || step.isApplicable);
-
-  const stepsToRender = addFixedSteps(
-    intl,
-    updatedSteps,
-    submission,
-    currentPathname,
-    showOverview,
-    needsPayment,
-    isCompleted,
-    !!form.introductionPageContent
-  );
-
-  // Show the progress indicator if enabled on the form AND we're not in the payment
-  // confirmation screen.
-  const progressIndicator =
-    form.showProgressIndicator && !confirmationMatch ? (
-      <ProgressIndicator
-        title={PI_TITLE}
-        formTitle={formName}
-        steps={stepsToRender}
-        ariaMobileIconLabel={ariaMobileIconLabel}
-        accessibleToggleStepsLabel={accessibleToggleStepsLabel}
-      />
-    ) : null;
-
-  if (state.startingError) throw state.startingError;
-
-  // Route the correct page based on URL
-  const router = (
-    <Routes>
-      <Route
-        path="overzicht"
-        element={
-          <ErrorBoundary useCard>
-            <SessionTrackerModal>
-              <RequireSubmission
-                retrieveSubmissionFromContext
-                processingError={state.processingError}
-                onConfirm={onSubmitForm}
-                component={SubmissionSummary}
-                onClearProcessingErrors={() => dispatch({type: 'CLEAR_PROCESSING_ERROR'})}
-                onDestroySession={onDestroySession}
-                form={form}
-              />
-            </SessionTrackerModal>
-          </ErrorBoundary>
-        }
-      />
-
-      <Route
-        path="betalen"
-        element={
-          <ErrorBoundary useCard>
-            <RequireSubmission
-              submission={state.submittedSubmission}
-              statusUrl={state.processingStatusUrl}
-              onFailure={onProcessingFailure}
-              onConfirmed={() => dispatch({type: 'PROCESSING_SUCCEEDED'})}
-              component={StartPaymentView}
-              donwloadPDFText={form.submissionReportDownloadLinkTitle}
-            />
-          </ErrorBoundary>
-        }
-      />
-
-      <Route
-        path="bevestiging"
-        element={
-          <ErrorBoundary useCard>
-            <ConfirmationView
-              statusUrl={state.processingStatusUrl}
-              onFailure={onProcessingFailure}
-              onConfirmed={() => dispatch({type: 'PROCESSING_SUCCEEDED'})}
-              downloadPDFText={form.submissionReportDownloadLinkTitle}
-            />
-          </ErrorBoundary>
-        }
-      />
-    </Routes>
-  );
-
-  // render the form step if there's an active submission (and no summary)
+  // render the container for the router and necessary context providers for deeply
+  // nested child components
   return (
-    <FormDisplay progressIndicator={progressIndicator}>
-      <AnalyticsToolsConfigContext.Provider value={analyticsToolsConfigInfo}>
+    <FormDisplay progressIndicator={<FormProgressIndicator submission={submission} />}>
+      <AnalyticsToolsConfigProvider>
         <SubmissionProvider
-          submission={state.submission}
-          onSubmissionObtained={onSubmissionObtained}
+          submission={submission}
+          onSubmissionObtained={submission => {
+            onSubmissionLoaded(submission);
+            setSubmissionId(submission.id);
+          }}
           onDestroySession={onDestroySession}
+          removeSubmissionId={removeSubmissionId}
         >
           <Outlet />
-          {router}
         </SubmissionProvider>
-      </AnalyticsToolsConfigContext.Provider>
+      </AnalyticsToolsConfigProvider>
     </FormDisplay>
   );
 };
 
 Form.propTypes = {};
 
-const SubmissionContext = React.createContext({
-  submission: null,
-  onSubmissionObtained: () => {},
-  onDestroySession: () => {},
-});
-
-const SubmissionProvider = ({
-  submission = null,
-  onSubmissionObtained,
-  onDestroySession,
-  children,
-}) => (
-  <SubmissionContext.Provider value={{submission, onSubmissionObtained, onDestroySession}}>
-    {children}
-  </SubmissionContext.Provider>
-);
-
-SubmissionProvider.propTypes = {
-  submission: Types.Submission,
-  onSubmissionObtained: PropTypes.func.isRequired,
-  onDestroySession: PropTypes.func.isRequired,
-};
-
-const useSubmissionContext = () => useContext(SubmissionContext);
-
 export default Form;
-export {useSubmissionContext, SubmissionProvider};
diff --git a/src/components/Form.spec.jsx b/src/components/Form.spec.jsx
index 86b850df7..b416e860b 100644
--- a/src/components/Form.spec.jsx
+++ b/src/components/Form.spec.jsx
@@ -1,26 +1,47 @@
-import {render, screen} from '@testing-library/react';
+import {render, screen, waitForElementToBeRemoved} from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import messagesEN from 'i18n/compiled/en.json';
 import {IntlProvider} from 'react-intl';
 import {RouterProvider, createMemoryRouter} from 'react-router-dom';
 
 import {ConfigContext, FormContext} from 'Context';
-import {BASE_URL, buildForm, mockAnalyticsToolConfigGet} from 'api-mocks';
+import {BASE_URL, buildForm, buildSubmission, mockAnalyticsToolConfigGet} from 'api-mocks';
 import mswServer from 'api-mocks/msw-server';
-import {mockSubmissionPost, mockSubmissionStepGet} from 'api-mocks/submissions';
-import {routes} from 'components/App';
+import {
+  mockSubmissionCompletePost,
+  mockSubmissionGet,
+  mockSubmissionPaymentStartPost,
+  mockSubmissionPost,
+  mockSubmissionProcessingStatusErrorGet,
+  mockSubmissionProcessingStatusGet,
+  mockSubmissionStepGet,
+  mockSubmissionSummaryGet,
+} from 'api-mocks/submissions';
+import {SUBMISSION_ALLOWED} from 'components/constants';
+import routes from 'routes';
 
 window.scrollTo = vi.fn();
 
+beforeAll(() => {
+  vi.stubGlobal('jest', {
+    advanceTimersByTime: vi.advanceTimersByTime.bind(vi),
+  });
+});
+
 beforeEach(() => {
   localStorage.clear();
 });
 
 afterEach(() => {
+  if (vi.isFakeTimers()) {
+    vi.runOnlyPendingTimers();
+    vi.useRealTimers();
+  }
   localStorage.clear();
 });
 
 afterAll(() => {
+  vi.unstubAllGlobals();
   vi.clearAllMocks();
 });
 
@@ -129,3 +150,129 @@ test('Navigation through form without introduction page', async () => {
   const formInput = await screen.findByLabelText('Component 1');
   expect(formInput).toBeVisible();
 });
+
+test('Submitting the form with failing background processing', async () => {
+  const user = userEvent.setup({
+    advanceTimers: vi.advanceTimersByTime,
+  });
+  // The summary page submits the form and needs to trigger the appropriate redirects.
+  // When the status check reports failure, we need to be redirected back to the summary
+  // page for a retry.
+  const form = buildForm({loginRequired: false, submissionStatementsConfiguration: []});
+  const submission = buildSubmission({
+    submissionAllowed: SUBMISSION_ALLOWED.yes,
+    payment: {
+      isRequired: false,
+      amount: undefined,
+      hasPaid: false,
+    },
+    MARKER: true,
+  });
+  mswServer.use(
+    mockAnalyticsToolConfigGet(),
+    mockSubmissionGet(submission),
+    mockSubmissionSummaryGet(),
+    mockSubmissionCompletePost(),
+    mockSubmissionProcessingStatusErrorGet
+  );
+
+  render(<Wrapper form={form} initialEntry={`/overzicht?submission_uuid=${submission.id}`} />);
+
+  expect(await screen.findByRole('heading', {name: 'Check and confirm'})).toBeVisible();
+
+  // confirm the submission and complete it
+  vi.useFakeTimers();
+  await user.click(screen.getByRole('button', {name: 'Confirm'}));
+  expect(await screen.findByRole('heading', {name: 'Processing...'})).toBeVisible();
+  const loader = await screen.findByRole('status');
+  vi.runOnlyPendingTimers();
+  vi.useRealTimers();
+  await waitForElementToBeRemoved(loader);
+
+  // due to the error we get redirected back to the summary page.
+  expect(await screen.findByRole('heading', {name: 'Check and confirm'})).toBeVisible();
+  expect(screen.getByText('Computer says no.')).toBeVisible();
+});
+
+test('Submitting the form with successful background processing', async () => {
+  const user = userEvent.setup({
+    advanceTimers: vi.advanceTimersByTime,
+  });
+  // The summary page submits the form and needs to trigger the appropriate redirects.
+  // When the status check reports failure, we need to be redirected back to the summary
+  // page for a retry.
+  const form = buildForm({loginRequired: false, submissionStatementsConfiguration: []});
+  const submission = buildSubmission({
+    submissionAllowed: SUBMISSION_ALLOWED.yes,
+    payment: {
+      isRequired: false,
+      amount: undefined,
+      hasPaid: false,
+    },
+    MARKER: true,
+  });
+  mswServer.use(
+    mockAnalyticsToolConfigGet(),
+    mockSubmissionGet(submission),
+    mockSubmissionSummaryGet(),
+    mockSubmissionCompletePost(),
+    mockSubmissionProcessingStatusGet
+  );
+
+  render(<Wrapper form={form} initialEntry={`/overzicht?submission_uuid=${submission.id}`} />);
+
+  expect(await screen.findByRole('heading', {name: 'Check and confirm'})).toBeVisible();
+
+  // confirm the submission and complete it
+  vi.useFakeTimers();
+  await user.click(screen.getByRole('button', {name: 'Confirm'}));
+  expect(await screen.findByRole('heading', {name: 'Processing...'})).toBeVisible();
+  const loader = await screen.findByRole('status');
+  vi.runOnlyPendingTimers();
+  vi.useRealTimers();
+  await waitForElementToBeRemoved(loader);
+
+  // on success, the summary page must display the reference obtained from the backend
+  expect(await screen.findByRole('heading', {name: 'Confirmation: OF-L337'})).toBeVisible();
+});
+
+test('Submitting form with payment requirement', async () => {
+  const user = userEvent.setup({
+    advanceTimers: vi.advanceTimersByTime,
+  });
+  // The summary page submits the form and needs to trigger the appropriate redirects.
+  // When the status check reports failure, we need to be redirected back to the summary
+  // page for a retry.
+  const form = buildForm({loginRequired: false, submissionStatementsConfiguration: []});
+  const submission = buildSubmission({
+    submissionAllowed: SUBMISSION_ALLOWED.yes,
+    payment: {
+      isRequired: true,
+      amount: '42.69',
+      hasPaid: false,
+    },
+    MARKER: true,
+  });
+  mswServer.use(
+    mockAnalyticsToolConfigGet(),
+    mockSubmissionGet(submission),
+    mockSubmissionSummaryGet(),
+    mockSubmissionCompletePost(),
+    mockSubmissionProcessingStatusGet,
+    mockSubmissionPaymentStartPost(null)
+  );
+
+  render(<Wrapper form={form} initialEntry={`/overzicht?submission_uuid=${submission.id}`} />);
+  expect(await screen.findByRole('heading', {name: 'Check and confirm'})).toBeVisible();
+
+  // confirm the submission and complete it
+  vi.useFakeTimers();
+  await user.click(screen.getByRole('button', {name: 'Confirm'}));
+  expect(await screen.findByRole('heading', {name: 'Processing...'})).toBeVisible();
+  const loader = await screen.findByRole('status');
+  vi.runOnlyPendingTimers();
+  vi.useRealTimers();
+  await waitForElementToBeRemoved(loader);
+
+  expect(await screen.findByText('A payment is required for this product.')).toBeVisible();
+});
diff --git a/src/components/FormProgressIndicator.jsx b/src/components/FormProgressIndicator.jsx
new file mode 100644
index 000000000..c6685228c
--- /dev/null
+++ b/src/components/FormProgressIndicator.jsx
@@ -0,0 +1,121 @@
+import {useIntl} from 'react-intl';
+import {matchPath, useLocation, useMatch} from 'react-router-dom';
+
+import ProgressIndicator from 'components/ProgressIndicator';
+import {addFixedSteps, getStepsInfo} from 'components/ProgressIndicator/utils';
+import {PI_TITLE, STEP_LABELS, SUBMISSION_ALLOWED} from 'components/constants';
+import useFormContext from 'hooks/useFormContext';
+import Types from 'types';
+
+const getProgressIndicatorSteps = ({intl, form, submission, currentPathname, isCompleted}) => {
+  const submissionAllowedSpec = submission?.submissionAllowed ?? form.submissionAllowed;
+  const showOverview = submissionAllowedSpec !== SUBMISSION_ALLOWED.noWithoutOverview;
+  const needsPayment = submission?.payment.isRequired ?? form.paymentRequired;
+
+  const showNonApplicableSteps = !form.hideNonApplicableSteps;
+  const filteredSteps =
+    // first, process all the form steps in a format suitable for the PI
+    getStepsInfo(form.steps, submission, currentPathname)
+      // then, filter out the non-applicable steps if they should not be displayed
+      .filter(step => showNonApplicableSteps || step.isApplicable);
+
+  return addFixedSteps(
+    intl,
+    filteredSteps,
+    submission,
+    currentPathname,
+    showOverview,
+    needsPayment,
+    isCompleted,
+    !!form.introductionPageContent
+  );
+};
+
+/**
+ * Determine the 'step' title to render for the accessible mobile menu label.
+ * @param  {IntlShape} intl  The `useIntl` return value.
+ * @param  {String} pathname The pathname ('url') of the current location.
+ * @param  {Object} form     The Open Forms form instance being rendered.
+ * @return {String}          The (formatted) string for the step title/name.
+ */
+const getMobileStepTitle = (intl, pathname, form) => {
+  // TODO replace absolute path check with relative
+  if (matchPath('/introductie', pathname)) {
+    return intl.formatMessage(STEP_LABELS.introduction);
+  }
+  if (matchPath('/startpagina', pathname)) {
+    return intl.formatMessage(STEP_LABELS.login);
+  }
+
+  const stepMatch = matchPath('/stap/:step', pathname);
+  if (stepMatch) {
+    const slug = stepMatch.params.step;
+    const step = form.steps.find(step => step.slug === slug);
+    return step.formDefinition;
+  }
+
+  if (matchPath('/overzicht', pathname)) {
+    return intl.formatMessage(STEP_LABELS.overview);
+  }
+  if (matchPath('/betalen', pathname)) {
+    return intl.formatMessage(STEP_LABELS.payment);
+  }
+
+  // we *may* end up here in tests that haven't set up all routes and so path matches
+  // fail.
+  /* istanbul ignore next */
+  return '';
+};
+
+/**
+ * Component to configure the progress indicator for a specific form.
+ *
+ * This component encapsulates the render/no render behaviour of the progress indicator
+ * by looking at the form configuration settings.
+ */
+const FormProgressIndicator = ({submission}) => {
+  const form = useFormContext();
+  const {pathname: currentPathname, state: routerState} = useLocation();
+  const confirmationMatch = useMatch('/bevestiging');
+  const intl = useIntl();
+
+  // don't render anything if the form is configured to never display the progress
+  // indicator, or we're on the final confirmation page
+  if (!form.showProgressIndicator || confirmationMatch) {
+    return null;
+  }
+
+  // otherwise collect the necessary information to render the PI.
+  const isCompleted = !!routerState?.statusUrl;
+  const steps = getProgressIndicatorSteps({intl, form, submission, currentPathname, isCompleted});
+
+  const ariaMobileIconLabel = intl.formatMessage({
+    description: 'Progress step indicator toggle icon (mobile)',
+    defaultMessage: 'Toggle the progress status display',
+  });
+
+  const activeStepTitle = getMobileStepTitle(intl, currentPathname, form);
+  const accessibleToggleStepsLabel = intl.formatMessage(
+    {
+      description: 'Active step accessible label in mobile progress indicator',
+      defaultMessage: 'Current step in form {formName}: {activeStepTitle}',
+    },
+    {formName: form.name, activeStepTitle}
+  );
+
+  return (
+    <ProgressIndicator
+      title={PI_TITLE}
+      formTitle={form.name}
+      steps={steps}
+      ariaMobileIconLabel={ariaMobileIconLabel}
+      accessibleToggleStepsLabel={accessibleToggleStepsLabel}
+    />
+  );
+};
+
+FormProgressIndicator.propTypes = {
+  submission: Types.Submission,
+};
+
+export default FormProgressIndicator;
diff --git a/src/components/FormStart/index.jsx b/src/components/FormStart/index.jsx
index 160a62146..e7e70d7b8 100644
--- a/src/components/FormStart/index.jsx
+++ b/src/components/FormStart/index.jsx
@@ -7,12 +7,12 @@ import {ConfigContext} from 'Context';
 import Body from 'components/Body';
 import Card from 'components/Card';
 import ExistingSubmissionOptions from 'components/ExistingSubmissionOptions';
-import {useSubmissionContext} from 'components/Form';
 import FormMaximumSubmissions from 'components/FormMaximumSubmissions';
 import {LiteralsProvider} from 'components/Literal';
 import Loader from 'components/Loader';
 import LoginOptions from 'components/LoginOptions';
 import MaintenanceMode from 'components/MaintenanceMode';
+import {useSubmissionContext} from 'components/SubmissionProvider';
 import {
   AuthenticationErrors,
   useDetectAuthErrorMessages,
diff --git a/src/components/FormStart/tests.spec.jsx b/src/components/FormStart/tests.spec.jsx
index d9b218610..255cfe145 100644
--- a/src/components/FormStart/tests.spec.jsx
+++ b/src/components/FormStart/tests.spec.jsx
@@ -9,7 +9,7 @@ import {ConfigContext, FormContext} from 'Context';
 import {BASE_URL, buildForm, buildSubmission} from 'api-mocks';
 import mswServer from 'api-mocks/msw-server';
 import {mockSubmissionPost} from 'api-mocks/submissions';
-import {SubmissionProvider} from 'components/Form';
+import SubmissionProvider from 'components/SubmissionProvider';
 
 import FormStart from './index';
 
@@ -52,6 +52,7 @@ const Wrap = ({
               onSubmissionObtained?.();
             }}
             onDestroySession={() => {}}
+            removeSubmissionId={vi.fn()}
           >
             <RouterProvider router={router} />
           </SubmissionProvider>
diff --git a/src/components/FormStep/FormStep.stories.jsx b/src/components/FormStep/FormStep.stories.jsx
index b7bb322a4..58a0cb0bb 100644
--- a/src/components/FormStep/FormStep.stories.jsx
+++ b/src/components/FormStep/FormStep.stories.jsx
@@ -10,7 +10,7 @@ import {
   mockEmailVerificationPost,
   mockEmailVerificationVerifyCodePost,
 } from 'components/EmailVerification/mocks';
-import {SubmissionProvider} from 'components/Form';
+import SubmissionProvider from 'components/SubmissionProvider';
 import {AnalyticsToolsDecorator, ConfigDecorator} from 'story-utils/decorators';
 import {sleep} from 'utils';
 
@@ -29,6 +29,7 @@ export default {
   args: {
     onSubmissionObtained: fn(),
     onDestroySession: fn(),
+    removeSubmissionId: fn(),
   },
   argTypes: {
     submission: {control: false},
@@ -56,6 +57,7 @@ const render = ({
   submission,
   onSubmissionObtained,
   onDestroySession,
+  removeSubmissionId,
   // story args
   formioConfiguration,
   validationErrors = undefined,
@@ -83,6 +85,7 @@ const render = ({
         submission={submission}
         onSubmissionObtained={onSubmissionObtained}
         onDestroySession={onDestroySession}
+        removeSubmissionId={removeSubmissionId}
       >
         <FormStep />
       </SubmissionProvider>
diff --git a/src/components/FormStep/index.jsx b/src/components/FormStep/index.jsx
index 87de28f1b..425fb64ba 100644
--- a/src/components/FormStep/index.jsx
+++ b/src/components/FormStep/index.jsx
@@ -36,11 +36,11 @@ import {get} from 'api';
 import ButtonsToolbar from 'components/ButtonsToolbar';
 import Card, {CardTitle} from 'components/Card';
 import {EmailVerificationModal} from 'components/EmailVerification';
-import {useSubmissionContext} from 'components/Form';
 import FormStepDebug from 'components/FormStepDebug';
 import {LiteralsProvider} from 'components/Literal';
 import Loader from 'components/Loader';
 import PreviousLink from 'components/PreviousLink';
+import {useSubmissionContext} from 'components/SubmissionProvider';
 import {SummaryProgress} from 'components/SummaryProgress';
 import FormStepSaveModal from 'components/modals/FormStepSaveModal';
 import {
diff --git a/src/components/LoginOptions/index.jsx b/src/components/LoginOptions/index.jsx
index ddcb88b43..0462037b0 100644
--- a/src/components/LoginOptions/index.jsx
+++ b/src/components/LoginOptions/index.jsx
@@ -1,16 +1,16 @@
 import PropTypes from 'prop-types';
 import React from 'react';
 import {FormattedMessage} from 'react-intl';
+import {useSearchParams} from 'react-router-dom';
 
 import {Literal} from 'components/Literal';
 import {getCosignLoginUrl, getLoginUrl} from 'components/utils';
-import useQuery from 'hooks/useQuery';
 import Types from 'types';
 
 import LoginOptionsDisplay from './LoginOptionsDisplay';
 
 const LoginOptions = ({form, onFormStart, extraNextParams = {}, isolateCosignOptions = true}) => {
-  const queryParams = useQuery();
+  const [params] = useSearchParams();
 
   const loginAsYourselfOptions = [];
   const loginAsGemachtigdeOptions = [];
@@ -35,7 +35,7 @@ const LoginOptions = ({form, onFormStart, extraNextParams = {}, isolateCosignOpt
   });
 
   if (form.cosignLoginOptions) {
-    const cosignCode = queryParams.get('code');
+    const cosignCode = params.get('code');
     form.cosignLoginOptions.forEach(option => {
       const loginUrl = getCosignLoginUrl(option, cosignCode ? {code: cosignCode} : undefined);
       cosignLoginOptions.push({
diff --git a/src/components/PostCompletionViews/ConfirmationView.jsx b/src/components/PostCompletionViews/ConfirmationView.jsx
index fcf20653c..2b06b77d4 100644
--- a/src/components/PostCompletionViews/ConfirmationView.jsx
+++ b/src/components/PostCompletionViews/ConfirmationView.jsx
@@ -1,11 +1,13 @@
 import PropTypes from 'prop-types';
 import React, {useContext} from 'react';
 import {FormattedMessage, defineMessage, useIntl} from 'react-intl';
-import {useLocation} from 'react-router-dom';
+import {useLocation, useSearchParams} from 'react-router-dom';
 
 import Body from 'components/Body';
 import ErrorMessage from 'components/Errors/ErrorMessage';
 import {GovMetricSnippet} from 'components/analytics';
+import useFormContext from 'hooks/useFormContext';
+import {DEBUG} from 'utils';
 
 import PostCompletionView from './PostCompletionView';
 import StatusUrlPoller, {SubmissionStatusContext} from './StatusUrlPoller';
@@ -105,19 +107,42 @@ ConfirmationViewDisplay.propTypes = {
   downloadPDFText: PropTypes.node,
 };
 
-const ConfirmationView = ({statusUrl, onFailure, onConfirmed, downloadPDFText}) => {
+const ConfirmationView = ({onFailureNavigateTo, onConfirmed}) => {
+  const form = useFormContext();
+  // TODO: take statusUrl from session storage instead of router state / query params,
+  // which is the best tradeoff between security and convenience (state doesn't survive
+  // hard refreshes, query params is prone to accidental information leaking)
+  const location = useLocation();
+  const [params] = useSearchParams();
+  const statusUrl = params.get('statusUrl') ?? location.state?.statusUrl;
+
+  if (DEBUG && !statusUrl) {
+    throw new Error(
+      'You must pass the status URL via the router state (preferably) or query params.'
+    );
+  }
+
   return (
-    <StatusUrlPoller statusUrl={statusUrl} onFailure={onFailure} onConfirmed={onConfirmed}>
-      <ConfirmationViewDisplay downloadPDFText={downloadPDFText} />
+    <StatusUrlPoller
+      statusUrl={statusUrl}
+      onFailureNavigateTo={onFailureNavigateTo}
+      onConfirmed={onConfirmed}
+    >
+      <ConfirmationViewDisplay downloadPDFText={form.submissionReportDownloadLinkTitle} />
     </StatusUrlPoller>
   );
 };
 
 ConfirmationView.propTypes = {
-  statusUrl: PropTypes.string,
-  onFailure: PropTypes.func,
+  /**
+   * Location to navigate to on failure.
+   */
+  onFailureNavigateTo: PropTypes.string,
+  /**
+   * Optional callback to invoke when processing was successful.
+   * @deprecated
+   */
   onConfirmed: PropTypes.func,
-  downloadPDFText: PropTypes.node,
 };
 
 export {ConfirmationViewDisplay};
diff --git a/src/components/PostCompletionViews/ConfirmationView.stories.jsx b/src/components/PostCompletionViews/ConfirmationView.stories.jsx
index 1a3702184..2cc28d91a 100644
--- a/src/components/PostCompletionViews/ConfirmationView.stories.jsx
+++ b/src/components/PostCompletionViews/ConfirmationView.stories.jsx
@@ -6,7 +6,7 @@ import {AnalyticsToolsDecorator, withForm, withSubmissionPollInfo} from 'story-u
 import {ConfirmationViewDisplay} from './ConfirmationView';
 
 export default {
-  title: 'Private API / Post completion views / Confirmation view',
+  title: 'Views / Post completion views / Confirmation view',
   component: ConfirmationViewDisplay,
   decorators: [withForm, AnalyticsToolsDecorator, withSubmissionPollInfo, withRouter],
   argTypes: {
diff --git a/src/components/PostCompletionViews/PostCompletionView.stories.jsx b/src/components/PostCompletionViews/PostCompletionView.stories.jsx
index 53cefb592..a8549b792 100644
--- a/src/components/PostCompletionViews/PostCompletionView.stories.jsx
+++ b/src/components/PostCompletionViews/PostCompletionView.stories.jsx
@@ -3,7 +3,7 @@ import Body from 'components/Body';
 import PostCompletionView from './PostCompletionView';
 
 export default {
-  title: 'Private API / Post completion views ',
+  title: 'Views / Post completion views ',
   component: PostCompletionView,
   render: ({body, ...args}) => <PostCompletionView {...args} body={<Body>{body}</Body>} />,
 };
diff --git a/src/components/PostCompletionViews/StartPaymentView.jsx b/src/components/PostCompletionViews/StartPaymentView.jsx
index fe97c6de3..c812bc354 100644
--- a/src/components/PostCompletionViews/StartPaymentView.jsx
+++ b/src/components/PostCompletionViews/StartPaymentView.jsx
@@ -1,9 +1,12 @@
 import PropTypes from 'prop-types';
 import {useContext} from 'react';
 import {FormattedMessage, useIntl} from 'react-intl';
+import {useLocation} from 'react-router-dom';
 
 import Body from 'components/Body';
 import ErrorBoundary from 'components/Errors/ErrorBoundary';
+import useFormContext from 'hooks/useFormContext';
+import {DEBUG} from 'utils';
 
 import PostCompletionView from './PostCompletionView';
 import {StartPayment} from './StartPayment';
@@ -58,19 +61,22 @@ StartPaymentViewDisplay.propTypes = {
   downloadPDFText: PropTypes.node,
 };
 
-const StartPaymentView = ({statusUrl, onFailure, onConfirmed, downloadPDFText}) => {
+const StartPaymentView = ({onFailureNavigateTo}) => {
+  const form = useFormContext();
+  const {statusUrl} = useLocation().state || {};
+  if (DEBUG && !statusUrl) throw new Error('You must pass the status URL via the route state.');
   return (
-    <StatusUrlPoller statusUrl={statusUrl} onFailure={onFailure} onConfirmed={onConfirmed}>
-      <StartPaymentViewDisplay downloadPDFText={downloadPDFText} />
+    <StatusUrlPoller statusUrl={statusUrl} onFailureNavigateTo={onFailureNavigateTo}>
+      <StartPaymentViewDisplay downloadPDFText={form.submissionReportDownloadLinkTitle} />
     </StatusUrlPoller>
   );
 };
 
 StartPaymentView.propTypes = {
-  statusUrl: PropTypes.string,
-  onFailure: PropTypes.func,
-  onConfirmed: PropTypes.func,
-  downloadPDFText: PropTypes.node,
+  /**
+   * Location to navigate to on failure.
+   */
+  onFailureNavigateTo: PropTypes.string,
 };
 
 export default StartPaymentView;
diff --git a/src/components/PostCompletionViews/StartPaymentView.stories.jsx b/src/components/PostCompletionViews/StartPaymentView.stories.jsx
index fe6d18edf..fe3afc461 100644
--- a/src/components/PostCompletionViews/StartPaymentView.stories.jsx
+++ b/src/components/PostCompletionViews/StartPaymentView.stories.jsx
@@ -2,7 +2,7 @@ import {expect, waitFor, within} from '@storybook/test';
 
 import {BASE_URL} from 'api-mocks';
 import {
-  mockSubmissionPaymentStartGet,
+  mockSubmissionPaymentStartPost,
   mockSubmissionProcessingStatusGet,
 } from 'api-mocks/submissions';
 import {withSubmissionPollInfo} from 'story-utils/decorators';
@@ -10,7 +10,7 @@ import {withSubmissionPollInfo} from 'story-utils/decorators';
 import {StartPaymentViewDisplay} from './StartPaymentView';
 
 export default {
-  title: 'Private API / Post completion views / Start payment',
+  title: 'Views / Post completion views / Start payment',
   component: StartPaymentViewDisplay,
   decorators: [withSubmissionPollInfo],
   args: {
@@ -21,7 +21,7 @@ export default {
   },
   parameters: {
     msw: {
-      handlers: [mockSubmissionProcessingStatusGet, mockSubmissionPaymentStartGet],
+      handlers: [mockSubmissionProcessingStatusGet, mockSubmissionPaymentStartPost()],
     },
   },
 };
diff --git a/src/components/PostCompletionViews/StatusUrlPoller.jsx b/src/components/PostCompletionViews/StatusUrlPoller.jsx
index f2b1d5ed7..685cea9e0 100644
--- a/src/components/PostCompletionViews/StatusUrlPoller.jsx
+++ b/src/components/PostCompletionViews/StatusUrlPoller.jsx
@@ -1,7 +1,7 @@
 import PropTypes from 'prop-types';
 import React from 'react';
 import {FormattedMessage, useIntl} from 'react-intl';
-import {useLocation} from 'react-router-dom';
+import {useLocation, useNavigate} from 'react-router-dom';
 
 import Body from 'components/Body';
 import Card from 'components/Card';
@@ -21,9 +21,10 @@ const SubmissionStatusContext = React.createContext({
 });
 SubmissionStatusContext.displayName = 'SubmissionStatusContext';
 
-const StatusUrlPoller = ({statusUrl, onFailure, onConfirmed, children}) => {
+const StatusUrlPoller = ({statusUrl, onFailureNavigateTo, onConfirmed, children}) => {
   const intl = useIntl();
   const location = useLocation();
+  const navigate = useNavigate();
 
   const genericErrorMessage = intl.formatMessage({
     description: 'Generic submission error',
@@ -35,15 +36,16 @@ const StatusUrlPoller = ({statusUrl, onFailure, onConfirmed, children}) => {
     error,
     response: statusResponse,
   } = usePoll(
-    statusUrl || location?.state?.statusUrl,
+    statusUrl,
     1000,
     response => response.status === 'done',
     response => {
       if (response.result === RESULT_FAILED) {
         const errorMessage = response.errorMessage || genericErrorMessage;
-        if (onFailure) onFailure(errorMessage);
+        const newState = {...(location.state || {}), errorMessage};
+        navigate(onFailureNavigateTo, {state: newState});
       } else if (response.result === RESULT_SUCCESS) {
-        if (onConfirmed) onConfirmed();
+        onConfirmed?.();
       }
     }
   );
@@ -71,13 +73,11 @@ const StatusUrlPoller = ({statusUrl, onFailure, onConfirmed, children}) => {
 
   // FIXME: https://github.com/open-formulieren/open-forms/issues/3255
   // errors (bad gateway 502, for example) appear to result in infinite loading
-  // spinners
-  if (error) {
-    console.error(error);
-  }
+  // spinners. Throwing during rendering will at least make it bubble up to the nearest
+  // error boundary.
+  if (error) throw error;
 
   const {
-    result,
     paymentUrl,
     publicReference,
     reportDownloadUrl,
@@ -86,10 +86,6 @@ const StatusUrlPoller = ({statusUrl, onFailure, onConfirmed, children}) => {
     mainWebsiteUrl,
   } = statusResponse;
 
-  if (result === RESULT_FAILED) {
-    throw new Error('Failure should have been handled in the onFailure prop.');
-  }
-
   return (
     <SubmissionStatusContext.Provider
       value={{
@@ -107,8 +103,17 @@ const StatusUrlPoller = ({statusUrl, onFailure, onConfirmed, children}) => {
 };
 
 StatusUrlPoller.propTypes = {
-  statusUrl: PropTypes.string,
-  onFailure: PropTypes.func,
+  /**
+   * Backend status URL to poll for status checks.
+   */
+  statusUrl: PropTypes.string.isRequired,
+  /**
+   * Route to navigate to if the status check reports failure.
+   *
+   * The route state will be extended with `errorMessage` property retrieved from the
+   * backend processing.
+   */
+  onFailureNavigateTo: PropTypes.string.isRequired,
   onConfirmed: PropTypes.func,
   children: PropTypes.node,
 };
diff --git a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx
index fe811466d..c5407301a 100644
--- a/src/components/PostCompletionViews/viewsWithPolling.stories.jsx
+++ b/src/components/PostCompletionViews/viewsWithPolling.stories.jsx
@@ -1,7 +1,7 @@
-import {expect, fn, waitFor, within} from '@storybook/test';
+import {expect, fn, waitForElementToBeRemoved, within} from '@storybook/test';
 import {withRouter} from 'storybook-addon-remix-react-router';
 
-import {BASE_URL} from 'api-mocks';
+import {BASE_URL, buildSubmission} from 'api-mocks';
 import {
   mockSubmissionProcessingStatusGet,
   mockSubmissionProcessingStatusPendingGet,
@@ -11,15 +11,13 @@ import {withSubmissionPollInfo} from 'story-utils/decorators';
 import ConfirmationView from './ConfirmationView';
 
 export default {
-  title: 'Private API / Post completion views / With Polling',
+  title: 'Views / Post completion views / With Polling',
   component: ConfirmationView,
   decorators: [withSubmissionPollInfo, withRouter],
   argTypes: {
     statusUrl: {control: false},
   },
   args: {
-    statusUrl: `${BASE_URL}submissions/4b0e86a8-dc5f-41cc-b812-c89857b9355b/-token-/status`,
-    onFailure: fn(),
     onConfirmed: fn(),
   },
   parameters: {
@@ -27,7 +25,12 @@ export default {
       handlers: [mockSubmissionProcessingStatusGet],
     },
     reactRouter: {
-      location: {state: {}},
+      location: {
+        state: {
+          statusUrl: `${BASE_URL}submissions/4b0e86a8-dc5f-41cc-b812-c89857b9355b/-token-/status`,
+          submission: buildSubmission(),
+        },
+      },
     },
   },
 };
@@ -36,15 +39,10 @@ export const WithoutPayment = {
   play: async ({canvasElement, args}) => {
     const canvas = within(canvasElement);
 
-    await waitFor(
-      async () => {
-        expect(canvas.getByRole('button', {name: 'Terug naar de website'})).toBeVisible();
-      },
-      {
-        timeout: 2000,
-        interval: 100,
-      }
-    );
+    const loader = await canvas.findByRole('status');
+    await waitForElementToBeRemoved(loader, {timeout: 2000, interval: 100});
+
+    expect(canvas.getByRole('button', {name: 'Terug naar de website'})).toBeVisible();
     expect(canvas.getByText(/OF-L337/)).toBeVisible();
     expect(args.onConfirmed).toBeCalledTimes(1);
   },
diff --git a/src/components/ProgressIndicator/ProgressIndicatorItem.jsx b/src/components/ProgressIndicator/ProgressIndicatorItem.jsx
index 9b7a08c56..f149cf7fa 100644
--- a/src/components/ProgressIndicator/ProgressIndicatorItem.jsx
+++ b/src/components/ProgressIndicator/ProgressIndicatorItem.jsx
@@ -1,5 +1,6 @@
 import PropTypes from 'prop-types';
 import {FormattedMessage} from 'react-intl';
+import {useLocation} from 'react-router-dom';
 
 import Link from 'components/Link';
 import {getBEMClassName} from 'utils';
@@ -27,6 +28,7 @@ const getLinkModifiers = isActive => {
  * Once a step is completed, it is displayed with a completion checkmark in front of it.
  */
 const ProgressIndicatorItem = ({label, to, isActive, isCompleted, canNavigateTo, isApplicable}) => {
+  const location = useLocation();
   return (
     <div className={getBEMClassName('progress-indicator-item')}>
       <div className={getBEMClassName('progress-indicator-item__marker')}>
@@ -35,6 +37,7 @@ const ProgressIndicatorItem = ({label, to, isActive, isCompleted, canNavigateTo,
       <div className={getBEMClassName('progress-indicator-item__label')}>
         <Link
           to={to}
+          state={location.state}
           placeholder={!canNavigateTo}
           modifiers={canNavigateTo ? getLinkModifiers(isActive) : []}
           aria-label={label}
diff --git a/src/components/ProgressIndicator/progressIndicator.spec.jsx b/src/components/ProgressIndicator/progressIndicator.spec.jsx
index a61129f16..2a66b5446 100644
--- a/src/components/ProgressIndicator/progressIndicator.spec.jsx
+++ b/src/components/ProgressIndicator/progressIndicator.spec.jsx
@@ -8,15 +8,7 @@ import {ConfigContext, FormContext} from 'Context';
 import {BASE_URL, buildForm, mockAnalyticsToolConfigGet} from 'api-mocks';
 import mswServer from 'api-mocks/msw-server';
 import {buildSubmission, mockSubmissionPost} from 'api-mocks/submissions';
-import App, {routes as nestedRoutes} from 'components/App';
-
-const routes = [
-  {
-    path: '*',
-    element: <App />,
-    children: nestedRoutes,
-  },
-];
+import routes from 'routes';
 
 const renderApp = (form, initialRoute = '/') => {
   const router = createMemoryRouter(routes, {
diff --git a/src/components/RequireSubmission.jsx b/src/components/RequireSubmission.jsx
index cc6eaa25d..8170c26ae 100644
--- a/src/components/RequireSubmission.jsx
+++ b/src/components/RequireSubmission.jsx
@@ -1,31 +1,30 @@
 import PropTypes from 'prop-types';
 import {Navigate} from 'react-router-dom';
 
-import {useSubmissionContext} from 'components/Form';
 import MaintenanceMode from 'components/MaintenanceMode';
+import {useSubmissionContext} from 'components/SubmissionProvider';
 import {ServiceUnavailable} from 'errors';
 import {IsFormDesigner} from 'headers';
 import useFormContext from 'hooks/useFormContext';
 
 /**
- * Higher order component to enforce there is an active submission in the state.
+ * Wrapper component to enforce there is an active submission in the state.
  *
- * If there is no submission, the user is forcibly redirected to the start of the form.
+ * If there is no submission, the user is forcibly redirected to the start of the form,
+ * or an error is thrown if the form is temporarily unavailable. Ensure you wrap the
+ * component in an error boundary that can handle these.
  *
- * Provide either the component or children prop to render the actual content. The
- * `component` prop is deprecated in favour of specifying explicit elements.
+ * The submission is taken from the context set via the `SubmissionProvider` component.
+ * Pass the content to render if there's a submission/session via the `children` prop,
+ * e.g.:
+ *
+ *     <RequireSubmission>
+ *       <MyProtectedView />
+ *     </RequireSubmission>
  */
-const RequireSubmission = ({
-  retrieveSubmissionFromContext = false,
-  submission: submissionFromProps,
-  children,
-  component: Component,
-  ...props
-}) => {
+const RequireSubmission = ({children}) => {
   const {maintenanceMode} = useFormContext();
-  const {submission: submissionFromContext} = useSubmissionContext();
-
-  const submission = retrieveSubmissionFromContext ? submissionFromContext : submissionFromProps;
+  const {submission} = useSubmissionContext();
 
   const userIsFormDesigner = IsFormDesigner.getValue();
   if (!userIsFormDesigner && maintenanceMode) {
@@ -44,25 +43,13 @@ const RequireSubmission = ({
   return (
     <>
       {userIsFormDesigner && maintenanceMode && <MaintenanceMode />}
-      {children ?? <Component submission={submission} {...props} />}
+      {children}
     </>
   );
 };
 
 RequireSubmission.propTypes = {
-  retrieveSubmissionFromContext: PropTypes.bool,
-  /**
-   * Submission (or null-ish) to test if there's an active submission.
-   * @deprecated - grab it from the context via `retrieveSubmissionFromContext` instead.
-   */
-  submission: PropTypes.object,
-  children: PropTypes.node,
-  /**
-   * Component to render with the provided props. If children are provided, those get
-   * priority.
-   * @deprecated
-   */
-  component: PropTypes.elementType,
+  children: PropTypes.node.isRequired,
 };
 
 export default RequireSubmission;
diff --git a/src/components/SubmissionProvider.jsx b/src/components/SubmissionProvider.jsx
new file mode 100644
index 000000000..281aabc04
--- /dev/null
+++ b/src/components/SubmissionProvider.jsx
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React, {useContext} from 'react';
+
+import Types from 'types';
+
+const SubmissionContext = React.createContext({
+  submission: null,
+  onSubmissionObtained: () => {},
+  onDestroySession: () => {},
+  removeSubmissionId: () => {},
+});
+
+const SubmissionProvider = ({
+  submission = null,
+  onSubmissionObtained,
+  onDestroySession,
+  removeSubmissionId,
+  children,
+}) => (
+  <SubmissionContext.Provider
+    value={{submission, onSubmissionObtained, onDestroySession, removeSubmissionId}}
+  >
+    {children}
+  </SubmissionContext.Provider>
+);
+
+SubmissionProvider.propTypes = {
+  /**
+   * The submission currently being filled out / submitted / viewed. It must exist in
+   * the backend session.
+   */
+  submission: Types.Submission,
+  /**
+   * Callback for when a submission was (re-)loaded to store it in the state.
+   */
+  onSubmissionObtained: PropTypes.func.isRequired,
+  /**
+   * Callback for when an abort/logout/stop button is clicked which terminates the
+   * form submission / session.
+   */
+  onDestroySession: PropTypes.func.isRequired,
+  /**
+   * Callback to remove the submission reference (it's ID) from the local storage.
+   */
+  removeSubmissionId: PropTypes.func.isRequired,
+};
+
+export const useSubmissionContext = () => useContext(SubmissionContext);
+
+export default SubmissionProvider;
diff --git a/src/components/Summary/SubmissionSummary.jsx b/src/components/Summary/SubmissionSummary.jsx
index 253a068cd..5eee3a438 100644
--- a/src/components/Summary/SubmissionSummary.jsx
+++ b/src/components/Summary/SubmissionSummary.jsx
@@ -1,50 +1,42 @@
-import PropTypes from 'prop-types';
+import {useState} from 'react';
 import {FormattedMessage, useIntl} from 'react-intl';
-import {useNavigate} from 'react-router-dom';
+import {useLocation, useNavigate} from 'react-router-dom';
 import {useAsync} from 'react-use';
-import {useImmerReducer} from 'use-immer';
 
 import {post} from 'api';
 import {LiteralsProvider} from 'components/Literal';
+import {useSubmissionContext} from 'components/SubmissionProvider';
 import {SUBMISSION_ALLOWED} from 'components/constants';
 import {findPreviousApplicableStep} from 'components/utils';
+import useFormContext from 'hooks/useFormContext';
 import useRefreshSubmission from 'hooks/useRefreshSubmission';
 import useTitle from 'hooks/useTitle';
-import Types from 'types';
 
 import GenericSummary from './GenericSummary';
 import {loadSummaryData} from './utils';
 
-const initialState = {
-  error: '',
+const completeSubmission = async (submission, statementValues) => {
+  const response = await post(`${submission.url}/_complete`, statementValues);
+  const {statusUrl} = response.data;
+  return statusUrl;
 };
 
-const reducer = (draft, action) => {
-  switch (action.type) {
-    case 'ERROR': {
-      draft.error = action.payload;
-      break;
-    }
-    default: {
-      throw new Error(`Unknown action ${action.type}`);
-    }
-  }
-};
-
-const SubmissionSummary = ({
-  form,
-  submission,
-  processingError = '',
-  onConfirm,
-  onClearProcessingErrors,
-  onDestroySession,
-}) => {
-  const [state, dispatch] = useImmerReducer(reducer, initialState);
+const SubmissionSummary = () => {
+  const location = useLocation();
   const navigate = useNavigate();
   const intl = useIntl();
-
+  const form = useFormContext();
+  const {submission, onDestroySession, removeSubmissionId} = useSubmissionContext();
   const refreshedSubmission = useRefreshSubmission(submission);
 
+  const [submitError, setSubmitError] = useState('');
+
+  const pageTitle = intl.formatMessage({
+    description: 'Summary page title',
+    defaultMessage: 'Check and confirm',
+  });
+  useTitle(pageTitle, form.name);
+
   const paymentInfo = refreshedSubmission.payment;
 
   const {
@@ -55,19 +47,31 @@ const SubmissionSummary = ({
     const submissionUrl = new URL(refreshedSubmission.url);
     return await loadSummaryData(submissionUrl);
   }, [refreshedSubmission.url]);
-
-  if (error) {
-    console.error(error);
-  }
+  // throw to nearest error boundary
+  if (error) throw error;
 
   const onSubmit = async statementValues => {
     if (refreshedSubmission.submissionAllowed !== SUBMISSION_ALLOWED.yes) return;
+    let statusUrl;
     try {
-      const {statusUrl} = await completeSubmission(refreshedSubmission, statementValues);
-      onConfirm(statusUrl);
+      statusUrl = await completeSubmission(refreshedSubmission, statementValues);
     } catch (e) {
-      dispatch({type: 'ERROR', payload: e.message});
+      setSubmitError(e.message);
+      return;
     }
+
+    // the completion went through, proceed to redirect to the next page and set up
+    // the necessary state.
+    const needsPayment =
+      refreshedSubmission.payment.isRequired && !refreshedSubmission.payment.hasPaid;
+    const nextUrl = needsPayment ? '/betalen' : '/bevestiging';
+    removeSubmissionId();
+    navigate(nextUrl, {
+      state: {
+        submission: refreshedSubmission,
+        statusUrl,
+      },
+    });
   };
 
   const getPreviousPage = () => {
@@ -79,34 +83,10 @@ const SubmissionSummary = ({
 
   const onPrevPage = event => {
     event.preventDefault();
-    onClearProcessingErrors();
-
     navigate(getPreviousPage());
   };
 
-  const completeSubmission = async (submission, statementValues) => {
-    const response = await post(`${submission.url}/_complete`, statementValues);
-    if (!response.ok) {
-      console.error(response.data);
-      // TODO Specific error for each type of invalid data?
-      throw new Error('InvalidSubmissionData');
-    } else {
-      return response.data;
-    }
-  };
-
-  const pageTitle = intl.formatMessage({
-    description: 'Summary page title',
-    defaultMessage: 'Check and confirm',
-  });
-  useTitle(pageTitle, form.name);
-
-  const getErrors = () => {
-    let errors = [];
-    if (processingError) errors.push(processingError);
-    if (state.error) errors.push(state.error);
-    return errors;
-  };
+  const errorMessages = [location.state?.errorMessage, submitError].filter(Boolean);
 
   return (
     <LiteralsProvider literals={form.literals}>
@@ -124,7 +104,7 @@ const SubmissionSummary = ({
         editStepText={form.literals.changeText.resolved}
         isLoading={loading}
         isAuthenticated={refreshedSubmission.isAuthenticated}
-        errors={getErrors()}
+        errors={errorMessages}
         prevPage={getPreviousPage()}
         onSubmit={onSubmit}
         onPrevPage={onPrevPage}
@@ -134,13 +114,6 @@ const SubmissionSummary = ({
   );
 };
 
-SubmissionSummary.propTypes = {
-  form: Types.Form.isRequired,
-  submission: Types.Submission.isRequired,
-  processingError: PropTypes.string,
-  onConfirm: PropTypes.func.isRequired,
-  onClearProcessingErrors: PropTypes.func.isRequired,
-  onDestroySession: PropTypes.func.isRequired,
-};
+SubmissionSummary.propTypes = {};
 
 export default SubmissionSummary;
diff --git a/src/components/Summary/SubmissionSummary.spec.jsx b/src/components/Summary/SubmissionSummary.spec.jsx
new file mode 100644
index 000000000..f319ec69e
--- /dev/null
+++ b/src/components/Summary/SubmissionSummary.spec.jsx
@@ -0,0 +1,84 @@
+import {render, screen} from '@testing-library/react';
+import messagesNL from 'i18n/compiled/nl.json';
+import {IntlProvider} from 'react-intl';
+import {RouterProvider, createMemoryRouter} from 'react-router-dom';
+
+import {ConfigContext, FormContext} from 'Context';
+import {BASE_URL, buildForm, buildSubmission} from 'api-mocks';
+import mswServer from 'api-mocks/msw-server';
+import {mockSubmissionGet, mockSubmissionSummaryGet} from 'api-mocks/submissions';
+import SubmissionProvider from 'components/SubmissionProvider';
+import {SubmissionSummary} from 'components/Summary';
+import {SUBMISSION_ALLOWED} from 'components/constants';
+
+const Wrapper = ({form, submission}) => {
+  const routes = [
+    {
+      path: '/overzicht',
+      element: (
+        <SubmissionProvider
+          submission={submission}
+          onSubmissionObtained={vi.fn()}
+          onDestroySession={vi.fn()}
+          removeSubmissionId={vi.fn()}
+        >
+          <SubmissionSummary />
+        </SubmissionProvider>
+      ),
+    },
+  ];
+  const router = createMemoryRouter(routes, {
+    initialEntries: ['/overzicht'],
+  });
+  return (
+    <ConfigContext.Provider
+      value={{
+        baseUrl: BASE_URL,
+        clientBaseUrl: 'http://localhost/',
+        basePath: '',
+        baseTitle: '',
+        requiredFieldsWithAsterisk: true,
+      }}
+    >
+      <IntlProvider locale="nl" messages={messagesNL}>
+        <FormContext.Provider value={form}>
+          <RouterProvider router={router} />
+        </FormContext.Provider>
+      </IntlProvider>
+    </ConfigContext.Provider>
+  );
+};
+
+test.each([true, false])(
+  'Summary displays logout button if isAuthenticated is true (loginRequired: %s)',
+  async loginRequired => {
+    const form = buildForm({loginRequired});
+    const submissionIsAuthenticated = buildSubmission({
+      submissionAllowed: SUBMISSION_ALLOWED.yes,
+      isAuthenticated: true,
+    });
+    mswServer.use(mockSubmissionGet(submissionIsAuthenticated), mockSubmissionSummaryGet());
+
+    render(<Wrapper form={form} submission={submissionIsAuthenticated} />);
+
+    const logoutButton = await screen.findByRole('button', {name: 'Uitloggen'});
+    expect(logoutButton).toBeVisible();
+  }
+);
+
+test('Summary when isAuthenticated and loginRequired are false', async () => {
+  const form = buildForm({loginRequired: false});
+  const submissionNotAuthenticated = buildSubmission({
+    submissionAllowed: SUBMISSION_ALLOWED.yes,
+    isAuthenticated: false,
+  });
+  mswServer.use(mockSubmissionGet(submissionNotAuthenticated), mockSubmissionSummaryGet());
+
+  render(<Wrapper form={form} submission={submissionNotAuthenticated} />);
+
+  // we expect an abort button instead of log out
+  const cancelButton = await screen.findByRole('button', {name: 'Annuleren'});
+  expect(cancelButton).toBeVisible();
+  const logoutButton = screen.queryByRole('button', {name: 'Uitloggen'});
+  expect(logoutButton).toBeNull();
+});
diff --git a/src/components/Summary/test.spec.jsx b/src/components/Summary/test.spec.jsx
deleted file mode 100644
index c76034170..000000000
--- a/src/components/Summary/test.spec.jsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import {render, screen} from '@testing-library/react';
-import messagesNL from 'i18n/compiled/nl.json';
-import {IntlProvider} from 'react-intl';
-import {MemoryRouter} from 'react-router-dom';
-import {useAsync} from 'react-use';
-
-import {buildForm} from 'api-mocks';
-import {SubmissionSummary} from 'components/Summary';
-import {SUBMISSION_ALLOWED} from 'components/constants';
-import useRefreshSubmission from 'hooks/useRefreshSubmission';
-
-const SUBMISSION = {
-  id: 'random-id',
-  url: 'https://example.com',
-  form: 'https://example.com',
-  steps: [
-    {
-      formStep:
-        'http://testserver/api/v1/forms/33af5a1c-552e-4e8f-8b19-287cf35b9edd/steps/0c2a1816-a7d7-4193-b431-918956744038',
-    },
-  ],
-  submissionAllowed: SUBMISSION_ALLOWED.yes,
-  payment: {
-    isRequired: false,
-    amount: '',
-    hasPaid: false,
-  },
-  isAuthenticated: false,
-};
-
-vi.mock('react-use');
-vi.mock('hooks/useRefreshSubmission');
-
-const Wrap = ({children}) => (
-  <IntlProvider locale="nl" messages={messagesNL}>
-    <MemoryRouter>{children}</MemoryRouter>
-  </IntlProvider>
-);
-
-it('Summary displays logout button if isAuthenticated is true', () => {
-  const submissionIsAuthenticated = {
-    ...SUBMISSION,
-    isAuthenticated: true,
-  };
-  const onDestroySession = vi.fn();
-  const onConfirm = vi.fn();
-
-  useAsync.mockReturnValue({loading: false, value: []});
-  useRefreshSubmission.mockReturnValue(submissionIsAuthenticated);
-
-  render(
-    <Wrap>
-      <SubmissionSummary
-        form={buildForm()}
-        submission={SUBMISSION}
-        onConfirm={onConfirm}
-        onDestroySession={onDestroySession}
-        onClearProcessingErrors={() => {}}
-      />
-    </Wrap>
-  );
-
-  expect(screen.getByRole('button', {name: 'Uitloggen'})).toBeVisible();
-});
-
-it('Summary does not display logout button if loginRequired is false', () => {
-  const formLoginRequired = buildForm({loginRequired: false});
-  const onDestroySession = vi.fn();
-  const onConfirm = vi.fn();
-
-  useAsync.mockReturnValue({loading: false, value: []});
-  useRefreshSubmission.mockReturnValue({...SUBMISSION, isAuthenticated: false});
-
-  render(
-    <Wrap>
-      <SubmissionSummary
-        form={formLoginRequired}
-        submission={SUBMISSION}
-        onConfirm={onConfirm}
-        onDestroySession={onDestroySession}
-        onClearProcessingErrors={() => {}}
-      />
-    </Wrap>
-  );
-
-  expect(screen.queryByRole('button', {name: 'Uitloggen'})).toBeNull();
-});
-
-it('Summary displays abort button if isAuthenticated is false', () => {
-  const onDestroySession = vi.fn();
-  const onConfirm = vi.fn();
-
-  useAsync.mockReturnValue({loading: false, value: []});
-  useRefreshSubmission.mockReturnValue({...SUBMISSION, isAuthenticated: false});
-
-  render(
-    <Wrap>
-      <SubmissionSummary
-        form={buildForm()}
-        submission={SUBMISSION}
-        onConfirm={onConfirm}
-        onDestroySession={onDestroySession}
-        onClearProcessingErrors={() => {}}
-      />
-    </Wrap>
-  );
-
-  expect(screen.queryByRole('button', {name: 'Annuleren'})).toBeInTheDocument();
-});
diff --git a/src/components/analytics/AnalyticsToolConfigProvider.jsx b/src/components/analytics/AnalyticsToolConfigProvider.jsx
new file mode 100644
index 000000000..17fb41f25
--- /dev/null
+++ b/src/components/analytics/AnalyticsToolConfigProvider.jsx
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React, {useContext} from 'react';
+import {useIntl} from 'react-intl';
+import {useAsync} from 'react-use';
+
+import {ConfigContext} from 'Context';
+import {get} from 'api';
+
+export const AnalyticsToolsConfigContext = React.createContext({
+  govmetricSourceIdFormFinished: '',
+  govmetricSourceIdFormAborted: '',
+  govmetricSecureGuidFormFinished: '',
+  govmetricSecureGuidFormAborted: '',
+  enableGovmetricAnalytics: false,
+});
+
+AnalyticsToolsConfigContext.displayName = 'AnalyticsToolsConfigContext';
+
+const AnalyticsToolsConfigProvider = ({children}) => {
+  const {locale} = useIntl();
+  const {baseUrl} = useContext(ConfigContext);
+
+  const {value} = useAsync(async () => {
+    return await get(`${baseUrl}analytics/analytics-tools-config-info`);
+  }, [baseUrl, locale]);
+
+  return (
+    <AnalyticsToolsConfigContext.Provider value={value}>
+      {children}
+    </AnalyticsToolsConfigContext.Provider>
+  );
+};
+
+AnalyticsToolsConfigProvider.propTypes = {
+  children: PropTypes.node,
+};
+
+export const useAnalyticsToolsConfig = () => useContext(AnalyticsToolsConfigContext);
+
+export default AnalyticsToolsConfigProvider;
diff --git a/src/components/analytics/GovMetricSnippet.jsx b/src/components/analytics/GovMetricSnippet.jsx
index 6c7ea276d..ca4e2eeea 100644
--- a/src/components/analytics/GovMetricSnippet.jsx
+++ b/src/components/analytics/GovMetricSnippet.jsx
@@ -1,17 +1,16 @@
 import govmetricAverageImg from 'img/govmetric/average.png';
 import govmetricGoodImg from 'img/govmetric/good.png';
 import govmetricPoorImg from 'img/govmetric/poor.png';
-import {useContext} from 'react';
 import {FormattedMessage, useIntl} from 'react-intl';
 
-import {AnalyticsToolsConfigContext} from 'Context';
 import useFormContext from 'hooks/useFormContext';
 
+import {useAnalyticsToolsConfig} from './AnalyticsToolConfigProvider';
 import {buildGovMetricUrl, govMetricURLWithRating} from './utils';
 
 const GovMetricSnippet = () => {
   const {enableGovmetricAnalytics, govmetricSourceIdFormFinished, govmetricSecureGuidFormFinished} =
-    useContext(AnalyticsToolsConfigContext);
+    useAnalyticsToolsConfig();
   const form = useFormContext();
   const intl = useIntl();
 
diff --git a/src/components/appointments/CreateAppointment/AppointmentProgress.jsx b/src/components/appointments/CreateAppointment/AppointmentProgress.jsx
index 60bf47709..9fdd9c9fd 100644
--- a/src/components/appointments/CreateAppointment/AppointmentProgress.jsx
+++ b/src/components/appointments/CreateAppointment/AppointmentProgress.jsx
@@ -7,7 +7,7 @@ import {PI_TITLE, STEP_LABELS} from 'components/constants';
 import {checkMatchesPath} from 'components/utils/routers';
 
 import {useCreateAppointmentContext} from './CreateAppointmentState';
-import {APPOINTMENT_STEPS, APPOINTMENT_STEP_PATHS} from './routes';
+import {APPOINTMENT_STEPS, APPOINTMENT_STEP_PATHS} from './steps';
 
 const AppointmentProgress = ({title, currentStep}) => {
   const {submission, submittedSteps} = useCreateAppointmentContext();
diff --git a/src/components/appointments/CreateAppointment/Confirmation.jsx b/src/components/appointments/CreateAppointment/Confirmation.jsx
index a0960b0c6..251a03e5f 100644
--- a/src/components/appointments/CreateAppointment/Confirmation.jsx
+++ b/src/components/appointments/CreateAppointment/Confirmation.jsx
@@ -1,31 +1,15 @@
-import {useNavigate, useSearchParams} from 'react-router-dom';
+import {useSearchParams} from 'react-router-dom';
 
 import {ConfirmationView} from 'components/PostCompletionViews';
-import useFormContext from 'hooks/useFormContext';
 
 import {useCreateAppointmentContext} from './CreateAppointmentState';
 
 const Confirmation = () => {
-  const form = useFormContext();
   const [params] = useSearchParams();
-  const navigate = useNavigate();
-  const {reset, setProcessingError} = useCreateAppointmentContext();
+  const {reset} = useCreateAppointmentContext();
   const statusUrl = params.get('statusUrl');
   if (!statusUrl) throw new Error('Missing statusUrl param');
-
-  const onProcessingFailure = errorMessage => {
-    setProcessingError(errorMessage);
-    navigate('../overzicht');
-  };
-
-  return (
-    <ConfirmationView
-      statusUrl={statusUrl}
-      onFailure={onProcessingFailure}
-      onConfirmed={reset}
-      downloadPDFText={form.submissionReportDownloadLinkTitle}
-    />
-  );
+  return <ConfirmationView onFailureNavigateTo="../overzicht" onConfirmed={reset} />;
 };
 
 Confirmation.propTypes = {};
diff --git a/src/components/appointments/CreateAppointment/CreateAppointment.jsx b/src/components/appointments/CreateAppointment/CreateAppointment.jsx
index 0ee8b11bc..924347870 100644
--- a/src/components/appointments/CreateAppointment/CreateAppointment.jsx
+++ b/src/components/appointments/CreateAppointment/CreateAppointment.jsx
@@ -14,7 +14,7 @@ import useSessionTimeout from 'hooks/useSessionTimeout';
 import {AppointmentConfigContext} from '../Context';
 import AppointmentProgress from './AppointmentProgress';
 import {CreateAppointmentState} from './CreateAppointmentState';
-import {APPOINTMENT_STEP_PATHS} from './routes';
+import {APPOINTMENT_STEP_PATHS} from './steps';
 
 const useIsConfirmation = () => {
   // useMatch requires absolute paths... and react-router are NOT receptive to changing that.
diff --git a/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx b/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx
index 50c4e569a..76b5f793c 100644
--- a/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx
+++ b/src/components/appointments/CreateAppointment/CreateAppointment.spec.jsx
@@ -14,8 +14,8 @@ import {
   mockSubmissionPost,
   mockSubmissionProcessingStatusErrorGet,
 } from 'api-mocks/submissions';
-import App, {routes as nestedRoutes} from 'components/App';
 import {SESSION_STORAGE_KEY as SUBMISSION_SESSION_STORAGE_KEY} from 'hooks/useGetOrCreateSubmission';
+import routes from 'routes';
 
 import {
   mockAppointmentCustomerFieldsGet,
@@ -31,14 +31,6 @@ import {SESSION_STORAGE_KEY as APPOINTMENT_SESSION_STORAGE_KEY} from './CreateAp
 let scrollIntoViewMock = vi.fn();
 window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
 
-const routes = [
-  {
-    path: '*',
-    element: <App />,
-    children: nestedRoutes,
-  },
-];
-
 const renderApp = (initialRoute = '/') => {
   const form = buildForm({
     appointmentOptions: {
@@ -177,6 +169,14 @@ describe('Create appointment status checking', () => {
     // wait for summary page to be rendered again
     await screen.findByText('Check and confirm', undefined, {timeout: 2000});
     expect(screen.getByText('Computer says no.')).toBeVisible();
+
+    // submitting again causes error message to vanish
+    for (const checkbox of screen.getAllByRole('checkbox')) {
+      await user.click(checkbox);
+    }
+    const submitButton2 = screen.getByRole('button', {name: 'Confirm'});
+    await user.click(submitButton2);
+    expect(screen.queryByText('Computer says no.')).toBeNull();
   });
 });
 
diff --git a/src/components/appointments/CreateAppointment/CreateAppointment.stories.jsx b/src/components/appointments/CreateAppointment/CreateAppointment.stories.jsx
index 56839486d..01d4f61de 100644
--- a/src/components/appointments/CreateAppointment/CreateAppointment.stories.jsx
+++ b/src/components/appointments/CreateAppointment/CreateAppointment.stories.jsx
@@ -6,6 +6,7 @@ import {FormContext} from 'Context';
 import {buildForm} from 'api-mocks';
 import {mockSubmissionPost, mockSubmissionProcessingStatusGet} from 'api-mocks/submissions';
 import {loadCalendarLocale} from 'components/forms/DateField/DatePickerCalendar';
+import {createAppointmentRoutes} from 'routes/appointments';
 import {ConfigDecorator, LayoutDecorator} from 'story-utils/decorators';
 
 import {
@@ -16,7 +17,7 @@ import {
   mockAppointmentProductsGet,
   mockAppointmentTimesGet,
 } from '../mocks';
-import CreateAppointment, {routes as childRoutes} from './';
+import CreateAppointment from './';
 
 export default {
   title: 'Private API / Appointments / CreateForm',
@@ -49,7 +50,7 @@ const Wrapper = ({form}) => {
     {
       path: '/appointments/*',
       element: <CreateAppointment />,
-      children: childRoutes,
+      children: createAppointmentRoutes,
     },
   ];
   const router = createMemoryRouter(routes, {
diff --git a/src/components/appointments/CreateAppointment/CreateAppointmentState.jsx b/src/components/appointments/CreateAppointment/CreateAppointmentState.jsx
index 1c9ab1b80..659bc7a7d 100644
--- a/src/components/appointments/CreateAppointment/CreateAppointmentState.jsx
+++ b/src/components/appointments/CreateAppointment/CreateAppointmentState.jsx
@@ -24,8 +24,6 @@ export const buildContextValue = ({
   appointmentErrors = {initialTouched: {}, initialErrors: {}},
   setAppointmentErrors = () => {},
   resetSession = () => {},
-  processingError = '',
-  setProcessingError = () => {},
 }) => {
   const submittedSteps = Object.keys(appointmentData).filter(
     subObject => Object.keys(subObject).length
@@ -55,8 +53,6 @@ export const buildContextValue = ({
     submitStep: values => setAppointmentData({...appointmentData, [currentStep]: values}),
     setErrors: setAppointmentErrors,
     stepErrors: {initialTouched: stepInitialTouched, initialErrors: stepInitialErrors},
-    processingError,
-    setProcessingError,
     clearStepErrors: () => {
       const newInitialErrors = produce(initialErrors, draft => {
         errorKeys.forEach(key => delete draft[key]);
@@ -64,7 +60,6 @@ export const buildContextValue = ({
       setAppointmentErrors({initialTouched, initialErrors: newInitialErrors});
     },
     reset: () => {
-      setProcessingError('');
       setAppointmentData({});
       resetSession();
     },
@@ -77,7 +72,6 @@ export const CreateAppointmentState = ({currentStep, submission, resetSession, c
     initialTouched: {},
     initialErrors: {},
   });
-  const [processingError, setProcessingError] = useState('');
 
   // check if the session is expired
   useSessionTimeout();
@@ -90,8 +84,6 @@ export const CreateAppointmentState = ({currentStep, submission, resetSession, c
     appointmentErrors,
     setAppointmentErrors,
     resetSession,
-    processingError,
-    setProcessingError,
   });
 
   return (
diff --git a/src/components/appointments/CreateAppointment/Summary.jsx b/src/components/appointments/CreateAppointment/Summary.jsx
index af8f924a1..106fc67ff 100644
--- a/src/components/appointments/CreateAppointment/Summary.jsx
+++ b/src/components/appointments/CreateAppointment/Summary.jsx
@@ -1,7 +1,7 @@
 import {Form, Formik} from 'formik';
 import {useContext, useState} from 'react';
 import {FormattedMessage, useIntl} from 'react-intl';
-import {createSearchParams, useNavigate} from 'react-router-dom';
+import {createSearchParams, useLocation, useNavigate} from 'react-router-dom';
 import {useAsync} from 'react-use';
 
 import {ConfigContext} from 'Context';
@@ -60,9 +60,10 @@ const getErrorsNavigateTo = errors => {
 const Summary = () => {
   const intl = useIntl();
   const {baseUrl} = useContext(ConfigContext);
+  const {state: routerState} = useLocation();
   const navigate = useNavigate();
-  const {appointmentData, submission, setErrors, processingError, setProcessingError} =
-    useCreateAppointmentContext();
+  const {appointmentData, submission, setErrors} = useCreateAppointmentContext();
+  const [submitting, setSubmitting] = useState(false);
   const [submitError, setSubmitError] = useState(null);
   useTitle(
     intl.formatMessage({
@@ -162,7 +163,7 @@ const Summary = () => {
    * Submit the appointment data to the backend.
    */
   const onSubmit = async statementValues => {
-    setProcessingError('');
+    setSubmitting(true);
     let appointment;
     try {
       appointment = await createAppointment(baseUrl, submission, appointmentData, statementValues);
@@ -176,15 +177,24 @@ const Summary = () => {
       }
       setSubmitError(e);
       return;
+    } finally {
+      setSubmitting(false);
     }
-    navigate({
-      pathname: '../bevestiging',
-      search: createSearchParams({
-        statusUrl: appointment.statusUrl,
-      }).toString(),
-    });
+    // TODO: store details in sessionStorage instead, to survive hard refreshes
+    navigate(
+      {
+        pathname: '../bevestiging',
+        search: createSearchParams({
+          statusUrl: appointment.statusUrl,
+        }).toString(),
+      },
+      {
+        state: {submission},
+      }
+    );
   };
 
+  const processingError = submitting ? '' : routerState?.errorMessage;
   return (
     <>
       <CardTitle
diff --git a/src/components/appointments/CreateAppointment/index.jsx b/src/components/appointments/CreateAppointment/index.jsx
index 699df6433..111aece52 100644
--- a/src/components/appointments/CreateAppointment/index.jsx
+++ b/src/components/appointments/CreateAppointment/index.jsx
@@ -1,4 +1,3 @@
 import CreateAppointment from './CreateAppointment';
 
 export default CreateAppointment;
-export {routes} from './routes';
diff --git a/src/components/appointments/CreateAppointment/routes.jsx b/src/components/appointments/CreateAppointment/steps.jsx
similarity index 66%
rename from src/components/appointments/CreateAppointment/routes.jsx
rename to src/components/appointments/CreateAppointment/steps.jsx
index c142d5c35..473aadb57 100644
--- a/src/components/appointments/CreateAppointment/routes.jsx
+++ b/src/components/appointments/CreateAppointment/steps.jsx
@@ -1,11 +1,7 @@
 import {defineMessage} from 'react-intl';
-import {Navigate} from 'react-router-dom';
-
-import useQuery from 'hooks/useQuery';
+import {Navigate, useSearchParams} from 'react-router-dom';
 
 import {ChooseProductStep, ContactDetailsStep, LocationAndTimeStep} from '../steps';
-import Confirmation from './Confirmation';
-import Summary from './Summary';
 
 export const APPOINTMENT_STEPS = [
   {
@@ -36,34 +32,16 @@ export const APPOINTMENT_STEPS = [
 
 export const APPOINTMENT_STEP_PATHS = APPOINTMENT_STEPS.map(s => s.path);
 
-const LandingPage = () => {
-  const query = useQuery();
+// TODO: replace with loader that redirects at the route level
+export const LandingPage = () => {
+  const [params] = useSearchParams();
   return (
     <Navigate
       replace
       to={{
         pathname: APPOINTMENT_STEP_PATHS[0],
-        search: `?${query}`,
+        search: `?${params}`,
       }}
     />
   );
 };
-
-/**
- * Route subtree for appointment forms.
- */
-export const routes = [
-  {
-    path: '',
-    element: <LandingPage />,
-  },
-  ...APPOINTMENT_STEPS.map(({path, element}) => ({path, element})),
-  {
-    path: 'overzicht',
-    element: <Summary />,
-  },
-  {
-    path: 'bevestiging',
-    element: <Confirmation />,
-  },
-];
diff --git a/src/components/appointments/ManageAppointment/index.jsx b/src/components/appointments/ManageAppointment/index.jsx
deleted file mode 100644
index 77bd44d6f..000000000
--- a/src/components/appointments/ManageAppointment/index.jsx
+++ /dev/null
@@ -1 +0,0 @@
-export {routes} from './routes';
diff --git a/src/components/appointments/ManageAppointment/routes.jsx b/src/components/appointments/ManageAppointment/routes.jsx
deleted file mode 100644
index a02da59f6..000000000
--- a/src/components/appointments/ManageAppointment/routes.jsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import ErrorBoundary from 'components/Errors/ErrorBoundary';
-
-import {CancelAppointment, CancelAppointmentSuccess} from '../cancel';
-
-export const routes = [
-  {
-    path: '',
-    element: (
-      <ErrorBoundary>
-        <CancelAppointment />
-      </ErrorBoundary>
-    ),
-  },
-  {
-    path: 'succes',
-    element: <CancelAppointmentSuccess />,
-  },
-];
diff --git a/src/components/appointments/cancel/CancelAppointment.integration.spec.jsx b/src/components/appointments/cancel/CancelAppointment.integration.spec.jsx
index 34e06ad2c..8194d66de 100644
--- a/src/components/appointments/cancel/CancelAppointment.integration.spec.jsx
+++ b/src/components/appointments/cancel/CancelAppointment.integration.spec.jsx
@@ -5,7 +5,7 @@ import {RouterProvider, createMemoryRouter} from 'react-router-dom';
 
 import {ConfigContext, FormContext} from 'Context';
 import {BASE_URL, buildForm} from 'api-mocks';
-import {routes} from 'components/App';
+import routes from 'routes';
 
 const Wrapper = () => {
   const form = buildForm({
diff --git a/src/components/appointments/cancel/CancelAppointment.jsx b/src/components/appointments/cancel/CancelAppointment.jsx
index 504141b90..33487eb1b 100644
--- a/src/components/appointments/cancel/CancelAppointment.jsx
+++ b/src/components/appointments/cancel/CancelAppointment.jsx
@@ -1,7 +1,7 @@
 import {Formik} from 'formik';
 import {useContext, useState} from 'react';
 import {FormattedDate, FormattedMessage} from 'react-intl';
-import {useNavigate} from 'react-router-dom';
+import {useNavigate, useSearchParams} from 'react-router-dom';
 
 import {ConfigContext} from 'Context';
 import {post} from 'api';
@@ -12,18 +12,17 @@ import ErrorMessage from 'components/Errors/ErrorMessage';
 import {Toolbar, ToolbarList} from 'components/Toolbar';
 import {EmailField} from 'components/forms';
 import {ValidationError} from 'errors';
-import useQuery from 'hooks/useQuery';
 
 const CancelAppointment = () => {
   const {baseUrl} = useContext(ConfigContext);
   const navigate = useNavigate();
-  const queryParams = useQuery();
+  const [params] = useSearchParams();
 
   const [failed, setFailed] = useState(false);
 
   // validate the necessary information to know which submission we are dealing with
-  const timeParam = queryParams.get('time');
-  const submissionId = queryParams.get('submission_uuid');
+  const timeParam = params.get('time');
+  const submissionId = params.get('submission_uuid');
 
   // input validation - show error message if people are messing with URLs rather
   // than crashing hard.
diff --git a/src/components/appointments/index.jsx b/src/components/appointments/index.jsx
index 6eee0217f..6981aaa6d 100644
--- a/src/components/appointments/index.jsx
+++ b/src/components/appointments/index.jsx
@@ -1,2 +1 @@
-export {default as CreateAppointment, routes as appointmentRoutes} from './CreateAppointment';
-export {routes as manageAppointmentRoutes} from './ManageAppointment';
+export {default as CreateAppointment} from './CreateAppointment';
diff --git a/src/components/appointments/steps/ChooseProductStep.jsx b/src/components/appointments/steps/ChooseProductStep.jsx
index 6aa81632d..a33f30d68 100644
--- a/src/components/appointments/steps/ChooseProductStep.jsx
+++ b/src/components/appointments/steps/ChooseProductStep.jsx
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import {useContext} from 'react';
 import {flushSync} from 'react-dom';
 import {FormattedMessage, useIntl} from 'react-intl';
-import {useNavigate} from 'react-router-dom';
+import {useNavigate, useSearchParams} from 'react-router-dom';
 import {z} from 'zod';
 import {toFormikValidationSchema} from 'zod-formik-adapter';
 
@@ -12,7 +12,6 @@ import {OFButton} from 'components/Button';
 import {CardTitle} from 'components/Card';
 import {EditGrid, EditGridButtonGroup, EditGridItem} from 'components/EditGrid';
 import FAIcon from 'components/FAIcon';
-import useQuery from 'hooks/useQuery';
 import useTitle from 'hooks/useTitle';
 
 import {AppointmentConfigContext} from '../Context';
@@ -172,8 +171,8 @@ const ChooseProductStep = ({navigateTo = null}) => {
       defaultMessage: 'Product',
     })
   );
-  const query = useQuery();
-  const initialProductId = query.get('product');
+  const [params] = useSearchParams();
+  const initialProductId = params.get('product');
 
   const initialValues = produce(INITIAL_VALUES, draft => {
     if (initialProductId) {
diff --git a/src/components/auth/AuthenticationErrors/index.jsx b/src/components/auth/AuthenticationErrors/index.jsx
index 3e9e90e08..9cfdba0b2 100644
--- a/src/components/auth/AuthenticationErrors/index.jsx
+++ b/src/components/auth/AuthenticationErrors/index.jsx
@@ -1,8 +1,8 @@
 import PropTypes from 'prop-types';
 import {useIntl} from 'react-intl';
+import {useSearchParams} from 'react-router-dom';
 
 import ErrorMessage from 'components/Errors/ErrorMessage';
-import useQuery from 'hooks/useQuery';
 
 const MAPPING_PARAMS_SERVICE = {
   '_digid-message': 'DigiD',
@@ -13,11 +13,11 @@ const MAPPING_PARAMS_SERVICE = {
 const CANCEL_LOGIN_PARAM = 'login-cancelled';
 
 const useDetectAuthErrorMessages = () => {
-  const query = useQuery();
+  const [params] = useSearchParams();
 
   let parameters = {};
 
-  for (const [key, value] of query.entries()) {
+  for (const [key, value] of params.entries()) {
     if (key in MAPPING_PARAMS_SERVICE) {
       parameters[key] = value;
       return parameters;
diff --git a/src/components/auth/AuthenticationOutage.jsx b/src/components/auth/AuthenticationOutage.jsx
index 574e58cb2..4e2794e43 100644
--- a/src/components/auth/AuthenticationOutage.jsx
+++ b/src/components/auth/AuthenticationOutage.jsx
@@ -1,14 +1,14 @@
 import PropTypes from 'prop-types';
 import {FormattedMessage} from 'react-intl';
+import {useSearchParams} from 'react-router-dom';
 
 import ErrorMessage from 'components/Errors/ErrorMessage';
-import useQuery from 'hooks/useQuery';
 
 const AUTHENTICATION_OUTAGE_QUERY_PARAM = 'of-auth-problem';
 
 export const useDetectAuthenticationOutage = () => {
-  const query = useQuery();
-  return query.get(AUTHENTICATION_OUTAGE_QUERY_PARAM);
+  const [params] = useSearchParams();
+  return params.get(AUTHENTICATION_OUTAGE_QUERY_PARAM);
 };
 
 const AuthenticationOutage = ({loginOption}) => (
diff --git a/src/hooks/useQuery.js b/src/hooks/useQuery.js
deleted file mode 100644
index 75e5de02c..000000000
--- a/src/hooks/useQuery.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import {useSearchParams} from 'react-router-dom';
-
-const useQuery = () => {
-  const [searchParams] = useSearchParams();
-  return searchParams;
-};
-
-export default useQuery;
diff --git a/src/hooks/useRecycleSubmission.js b/src/hooks/useRecycleSubmission.js
index 9cf5f4cd9..7a1dca3db 100644
--- a/src/hooks/useRecycleSubmission.js
+++ b/src/hooks/useRecycleSubmission.js
@@ -1,22 +1,21 @@
 import {useContext} from 'react';
-import {useLocation} from 'react-router-dom';
+import {useLocation, useSearchParams} from 'react-router-dom';
 import {useAsync, useLocalStorage} from 'react-use';
 
 import {ConfigContext} from 'Context';
 import {apiCall} from 'api';
-import useQuery from 'hooks/useQuery';
 
 const useRecycleSubmission = (form, currentSubmission, onSubmissionLoaded, onError = () => {}) => {
   const location = useLocation();
   const config = useContext(ConfigContext);
-  const queryParams = useQuery();
+  const [params] = useSearchParams();
   // XXX: use sessionStorage instead of localStorage for this, so that it's scoped to
   // a single tab/window?
   let [submissionId, setSubmissionId, removeSubmissionId] = useLocalStorage(form.uuid, '');
 
   // If no submissionID is in the localStorage see if one can be retrieved from the query param
   if (!submissionId) {
-    submissionId = queryParams.get('submission_uuid');
+    submissionId = params.get('submission_uuid');
   }
 
   const url = submissionId ? `${config.baseUrl}submissions/${submissionId}` : null;
diff --git a/src/hooks/useScrollIntoView.js b/src/hooks/useScrollIntoView.js
index 5c83b4fc1..81b8d0941 100644
--- a/src/hooks/useScrollIntoView.js
+++ b/src/hooks/useScrollIntoView.js
@@ -4,7 +4,11 @@ const useScrollIntoView = (options = {behavior: 'smooth'}) => {
   const ref = useRef();
   useEffect(() => {
     if (!ref.current) return;
-    ref.current.scrollIntoView(options);
+    // scrollIntoView is not available in jest-dom, and this can cause to crashing/infinitely
+    // loading (integration) tests because ErrorMessage uses this hook, which is used
+    // in the usual ErrorBoundary component... So, be very conservative here with the
+    // scrollIntoView behaviour/expectations!
+    ref.current.scrollIntoView?.(options);
   }, [ref, options]);
   return ref;
 };
diff --git a/src/hooks/useScrollIntoView.spec.jsx b/src/hooks/useScrollIntoView.spec.jsx
new file mode 100644
index 000000000..de0810d98
--- /dev/null
+++ b/src/hooks/useScrollIntoView.spec.jsx
@@ -0,0 +1,16 @@
+import {render, screen} from '@testing-library/react';
+
+import useScrollIntoView from './useScrollIntoView';
+
+const TestComponent = () => {
+  const ref = useScrollIntoView();
+  return <div ref={ref}>Scroll me!</div>;
+};
+
+test('useScrollIntoView in jest-dom', () => {
+  expect(window.HTMLElement.prototype.scrollIntoView).toBeUndefined();
+
+  render(<TestComponent />);
+
+  expect(screen.getByText('Scroll me!')).toBeVisible();
+});
diff --git a/src/hooks/useStartSubmission.js b/src/hooks/useStartSubmission.js
index 5ebfaea5c..640da79a2 100644
--- a/src/hooks/useStartSubmission.js
+++ b/src/hooks/useStartSubmission.js
@@ -1,10 +1,10 @@
-import {START_FORM_QUERY_PARAM} from 'components/constants';
+import {useSearchParams} from 'react-router-dom';
 
-import useQuery from './useQuery';
+import {START_FORM_QUERY_PARAM} from 'components/constants';
 
 const useStartSubmission = () => {
-  const query = useQuery();
-  return !!query.get(START_FORM_QUERY_PARAM);
+  const [params] = useSearchParams();
+  return !!params.get(START_FORM_QUERY_PARAM);
 };
 
 export default useStartSubmission;
diff --git a/src/routes/app.jsx b/src/routes/app.jsx
new file mode 100644
index 000000000..5e49ed178
--- /dev/null
+++ b/src/routes/app.jsx
@@ -0,0 +1,62 @@
+import App from 'components/App';
+import {Cosign} from 'components/CoSign';
+import ErrorBoundary from 'components/Errors/ErrorBoundary';
+import Form from 'components/Form';
+import SessionExpired from 'components/Sessions/SessionExpired';
+import {CreateAppointment} from 'components/appointments';
+
+import {createAppointmentRoutes, manageAppointmentRoutes} from './appointments';
+import cosignRoutes from './cosign';
+import formRoutes from './form';
+
+/**
+ * Main app entrypoint routes.
+ *
+ * These routes are the top-level routes, dividing the SDK into distinct features/
+ * chunks.
+ *
+ * @todo - soon-ish we can use dynamic loading to split up the bundle for lazy loading
+ * and reduce the initial load time.
+ */
+const routes = [
+  {
+    path: '*',
+    element: <App />,
+    children: [
+      {
+        path: 'afspraak-annuleren/*',
+        children: manageAppointmentRoutes,
+      },
+      {
+        path: 'afspraak-maken/*',
+        element: <CreateAppointment />,
+        children: createAppointmentRoutes,
+      },
+      {
+        path: 'cosign/*',
+        element: <Cosign />,
+        children: cosignRoutes,
+      },
+      {
+        path: 'sessie-verlopen',
+        element: (
+          <ErrorBoundary useCard>
+            <SessionExpired />
+          </ErrorBoundary>
+        ),
+      },
+      // All the rest goes to the formio-based form flow
+      {
+        path: '*',
+        element: (
+          <ErrorBoundary useCard>
+            <Form />
+          </ErrorBoundary>
+        ),
+        children: formRoutes,
+      },
+    ],
+  },
+];
+
+export default routes;
diff --git a/src/routes/appointments.jsx b/src/routes/appointments.jsx
new file mode 100644
index 000000000..38db46de3
--- /dev/null
+++ b/src/routes/appointments.jsx
@@ -0,0 +1,41 @@
+import ErrorBoundary from 'components/Errors/ErrorBoundary';
+import Confirmation from 'components/appointments/CreateAppointment/Confirmation';
+import Summary from 'components/appointments/CreateAppointment/Summary';
+import {APPOINTMENT_STEPS, LandingPage} from 'components/appointments/CreateAppointment/steps';
+import {CancelAppointment, CancelAppointmentSuccess} from 'components/appointments/cancel';
+
+/**
+ * Route subtree for appointment forms.
+ */
+const createAppointmentRoutes = [
+  {
+    path: '',
+    element: <LandingPage />,
+  },
+  ...APPOINTMENT_STEPS.map(({path, element}) => ({path, element})),
+  {
+    path: 'overzicht',
+    element: <Summary />,
+  },
+  {
+    path: 'bevestiging',
+    element: <Confirmation />,
+  },
+];
+
+const manageAppointmentRoutes = [
+  {
+    path: '',
+    element: (
+      <ErrorBoundary>
+        <CancelAppointment />
+      </ErrorBoundary>
+    ),
+  },
+  {
+    path: 'succes',
+    element: <CancelAppointmentSuccess />,
+  },
+];
+
+export {createAppointmentRoutes, manageAppointmentRoutes};
diff --git a/src/components/CoSign/routes.jsx b/src/routes/cosign.jsx
similarity index 56%
rename from src/components/CoSign/routes.jsx
rename to src/routes/cosign.jsx
index 31d8267df..6f9b6caac 100644
--- a/src/components/CoSign/routes.jsx
+++ b/src/routes/cosign.jsx
@@ -1,6 +1,6 @@
-import CosignCheck from './CosignCheck';
-import CosignDone from './CosignDone';
-import CosignStart from './CosignStart';
+import CosignCheck from 'components/CoSign/CosignCheck';
+import CosignDone from 'components/CoSign/CosignDone';
+import CosignStart from 'components/CoSign/CosignStart';
 
 const routes = [
   {
diff --git a/src/components/formRoutes.jsx b/src/routes/form.jsx
similarity index 50%
rename from src/components/formRoutes.jsx
rename to src/routes/form.jsx
index 579b02d8f..c367b6e57 100644
--- a/src/components/formRoutes.jsx
+++ b/src/routes/form.jsx
@@ -3,10 +3,12 @@ import FormLandingPage from 'components/FormLandingPage';
 import FormStart from 'components/FormStart';
 import FormStep from 'components/FormStep';
 import IntroductionPage from 'components/IntroductionPage';
+import {ConfirmationView, StartPaymentView} from 'components/PostCompletionViews';
 import RequireSubmission from 'components/RequireSubmission';
 import {SessionTrackerModal} from 'components/Sessions';
+import {SubmissionSummary} from 'components/Summary';
 
-const formRoutes = [
+const routes = [
   {
     path: '',
     element: <FormLandingPage />,
@@ -28,13 +30,43 @@ const formRoutes = [
     element: (
       <ErrorBoundary useCard>
         <SessionTrackerModal>
-          <RequireSubmission retrieveSubmissionFromContext>
+          <RequireSubmission>
             <FormStep />
           </RequireSubmission>
         </SessionTrackerModal>
       </ErrorBoundary>
     ),
   },
+  {
+    path: 'overzicht',
+    element: (
+      <ErrorBoundary useCard>
+        <SessionTrackerModal>
+          <RequireSubmission>
+            <SubmissionSummary />
+          </RequireSubmission>
+        </SessionTrackerModal>
+      </ErrorBoundary>
+    ),
+  },
+  {
+    path: 'betalen',
+    element: (
+      <ErrorBoundary useCard>
+        <RequireSubmission>
+          <StartPaymentView onFailureNavigateTo="/overzicht" />
+        </RequireSubmission>
+      </ErrorBoundary>
+    ),
+  },
+  {
+    path: 'bevestiging',
+    element: (
+      <ErrorBoundary useCard>
+        <ConfirmationView onFailureNavigateTo="/overzicht" />
+      </ErrorBoundary>
+    ),
+  },
 ];
 
-export default formRoutes;
+export default routes;
diff --git a/src/routes/index.jsx b/src/routes/index.jsx
new file mode 100644
index 000000000..eae5ea402
--- /dev/null
+++ b/src/routes/index.jsx
@@ -0,0 +1 @@
+export {default} from './app';
diff --git a/src/sdk.jsx b/src/sdk.jsx
index 7433a3bde..f786731bf 100644
--- a/src/sdk.jsx
+++ b/src/sdk.jsx
@@ -11,11 +11,11 @@ import {NonceProvider} from 'react-select';
 
 import {ConfigContext, FormContext} from 'Context';
 import {get} from 'api';
-import App, {routes as nestedRoutes} from 'components/App';
 import {getRedirectParams} from 'components/routingActions';
 import {AddFetchAuth} from 'formio/plugins';
 import {CSPNonce} from 'headers';
 import {I18NErrorBoundary, I18NManager} from 'i18n';
+import routes from 'routes';
 import initialiseSentry from 'sentry';
 import {DEBUG, getVersion} from 'utils';
 
@@ -50,14 +50,6 @@ fixLeafletIconUrls();
 
 const VERSION = getVersion();
 
-const routes = [
-  {
-    path: '*',
-    element: <App />,
-    children: nestedRoutes,
-  },
-];
-
 class OpenForm {
   constructor(targetNode, opts) {
     const {
diff --git a/src/story-utils/decorators.jsx b/src/story-utils/decorators.jsx
index 3cc2ff516..26cdb8890 100644
--- a/src/story-utils/decorators.jsx
+++ b/src/story-utils/decorators.jsx
@@ -2,11 +2,12 @@ import {Document} from '@utrecht/component-library-react';
 import {Formik} from 'formik';
 import merge from 'lodash/merge';
 
-import {AnalyticsToolsConfigContext, ConfigContext, FormContext} from 'Context';
+import {ConfigContext, FormContext} from 'Context';
 import {BASE_URL, buildForm} from 'api-mocks';
 import Card from 'components/Card';
 import {LiteralsProvider} from 'components/Literal';
 import {SubmissionStatusContext} from 'components/PostCompletionViews';
+import {AnalyticsToolsConfigContext} from 'components/analytics/AnalyticsToolConfigProvider';
 import {ModalContext} from 'components/modals/Modal';
 
 export const ConfigDecorator = (Story, {parameters}) => {
@@ -26,8 +27,10 @@ export const ConfigDecorator = (Story, {parameters}) => {
 
 export const AnalyticsToolsDecorator = (Story, {parameters}) => {
   const defaults = {
-    govmetricSourceId: '',
-    govmetricSecureGuid: '',
+    govmetricSourceIdFormFinished: '',
+    govmetricSourceIdFormAborted: '',
+    govmetricSecureGuidFormFinished: '',
+    govmetricSecureGuidFormAborted: '',
     enableGovmetricAnalytics: false,
   };
 
diff --git a/vite.config.mts b/vite.config.mts
index 59ef9e935..bda0ece7b 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -145,6 +145,7 @@ export default defineConfig(({mode}) => ({
         'src/**/*.d.ts',
         'src/**/*.stories.{js,jsx,ts,tsx}',
         'src/api-mocks/*',
+        'src/**/mocks.{js,jsx}',
         'src/story-utils/*',
         ...coverageConfigDefaults.exclude,
       ],