Repository: solid/community-server
Pull request #1088 (2021-12-09 10:55:13)

Update IDP API architecture
The main goal of this PR is to separate the HTML as much as possible from the IDP API, and also make sure the API architecture becomes more easily extensible. There are quite a few relevant considerations there, some of them caused by OIDC behaviour, so this is going to be a long description. I tried to cover most of them here but let me know if something is missing.

The HTML in this PR should be seen mostly as a proof of concept of what can be done, but the quality there can (and should) still be improved by a lot.

All this is also preparation for #972

## Architecture overview
This is an overview of both the current architecture and the new architecture. I will only cover the points where this PR makes changes. The current architecture is only there so a comparison can be made between the two.

### Current
IdentityProviderHttpHandler
  * has list of InteractionRoutes
  * finds matching route by iterating over routes and calling `supportsPath` function
  * gets result by calling matching route
  * can receive "complete", "error", or "response" object
  * sends "complete" results to the InteractionCompleter
  * sends "error" results to the ErrorHandler to receive JSON results and handles them as "response" objects
  * for "response" objects: add controls and send them to converter to potentially convert to HTML

InteractionRoute
  * stores route, prompt, handler, GET template, POST response template, control field
  * `getControls` returns control field
  * `supportsPath` matches prompt if needed, or matches route
  * `handleOperation` returns template as response for GET requests. Calls handler for POST requests. Catches errors so "error" result can be returned in those cases.

InteractionHandler
  * input: Operation + Interaction
  * output: { type: "response"/"complete"/"error", details: any }
  * these handle the login/registration/etc. operation

`IdentityProviderHttpHandler` and `InteractionRoute` were made with the idea that these were temporary classes that had some ugly code to tie everything together until a more extensive change could be made (which is what this PR is). The main issues there is that they do a lot, making it hard to change things. A lot of work is also done to render pretty HTML which is a responsibility that can be offloaded to the actual HTML files.

### New
IdentityProviderHttpHandler
  * calls InteractionHandler
  * outputs resulting Representation as ResponseDescription

InteractionHandler
  * input: Operation + Interaction
  * output: Representation

Actual InteractionHandler:
  * WaterfallHandler
    * HtmlViewHandler
    * ControlHandler
      * WaterfallHandler
        * InteractionRoute
          * LoginHandler
        * InteractionRoute
          * PromptHandler
        * etc.

InteractionRoute
  * interface with 1 function: `getPath`
  * used by other classes that need to reference the path of an IDP component. E.g. the ForgotPasswordHandler takes the ResetPasswordRoute so it knows which URL to send in the mail.

HtmlViewHandler
  * takes as input a map of templates tied to InteractionRoutes
  * outputs the rendered template if there is a match for the path and HTML is more preferred than JSON

ControlHandler
  * takes as input a map of control names (e.g. "login") tied to InteractionRoutes
  * adds the "controls" field to the output of the wrapped handler

Originally I wanted to completely remove all HTML references from the IDP components and only have a JSON API, but that would complicate certain behaviour. OIDC needs an HTML component (since user interaction is required) and the fact that the HTML pages have the same URL as the JSON API controls make it easier to reference other pages. Having the same URL is also relevant for the Interaction cookie, more on that below.

## HTML pages
All the HTML pages have been rewritten to use JS code for submitting the forms using a fetch call. In case of errors JS is also used to edit the HTML page to add it visually. This differs from before where the server had to send back an entirely newly rendered page containing that error. This also makes it so the form data always stays filled in without needing extra code for that.

In case of successful API calls, the page gets redirected to a relative `response/` (e.g., `/idp/register/response/`) with the response values being added as query parameters so a correct response can be rendered there. Currently the `/response/` part is hardcoded in the HTML. Another option is to edit the HTML in place so we don't need another page. This is currently what is done on the setup page since it is no longer possible to access the setup page after success.

The JS for all this is also embedded in all the pages right now. We probably want a separate JS file with utility functions for all this.

## Redirects
Since the HTML pages now do fetch calls to the API I had to change how redirects work in the IDP. Take the login page as an example. In case of an error when submitting data, this error needs to be displayed on the page, in case of success, the user should be redirected back to the client initiating the OIDC interaction. This creates issues when doing a fetch call in the JS of the page. On error, the fetch response will be a JSON object containing the error description. On success, the fetch will be redirected to the client URL, causing the fetch to return the HTML at that client URL location! Or a CORS error in case that URL has a different host. We want neither of those. 

Instead of getting the HTML of that redirect result, we want to have the actual redirect URL so we can redirect the page there. Because of this all API calls that would do a redirect, now instead return a JSON object containing a `location` field. This is the URL to which the browser should be redirected. So then the JS code can always do a fetch. In case of an error it updates the HTML page and in case of success it uses that location field to trigger a redirect.

## Prompts and cookies
During the OIDC interaction, at some point the user gets redirected to a specific page where their input is required. In our implementation (both current and new) this URL is fixed to `/idp/`. In the current implementation you would get the HTML back that belongs to the login page (or confirmation page). In the new version `/idp/` returns an empty HTML page. This page does a GET call to `/idp/prompt/` which returns `/idp/login/` (or `/idp/confirm`) after which the page gets redirected there.

One important reason that the original redirect has to be to `/idp/` is that the OIDC library sets a cookie for that URL. This is the cookie that is used to generate the Interaction object that contains all the relevant information about this login session. This is also what we use on the registration response page to either add a link to the login page or not, depending on if we are in such a session.

The OIDC library allows us to dynamically generate this URL though. So instead of doing the calls above, we could call the PromptHandler directly through the OIDC library callback and immediately redirect to `/idp/login/`. The problem here is that the cookie is then also set for that URL, meaning the registration page can no longer see if we are in a login session or not. 

We could always put the login link on that page, but then we are going to run into the problem we had previously where many people reported the login page not working. Writing this out makes me realize that we could change the login page depending on if we are in an active session or not though.

It is not relevant right now, but perhaps in the future it might be relevant that the scope of this cookie is not the entire API?

## Minor extra remarks
Integration tests now partially use JSON. We might have to switch fully to JSON at some point and have separate tests for the HTML pages.

The `DynamicJsonToTemplateConverter` can be completely removed since it was only used for setup and IDP.

Some things can be made even more generic. For example the HtmlViewHandler could take type as input instead of being fixed on HTML. But I don't think we really want other view handlers. HTML is just a special case for OIDC, if we want other representations they should use a converter on the JSON output.

The ResetPasswordHandler now uses an URL query parameter (which it did originally and I changed later) again, since it is now easier to handle due to the JS we use in the HTML.

Not sure if this is going to break the conformance tests. I actually think not since we still accept form data but this is untested.

Doing a GET on an IDP API returns a JSON object describing the expected parameters since I didn't really know what else to return there.

I didn't do it yet, but this would also be a good PR to change `/idp/` to `/.accounts/` as mentioned here https://github.com/solid/community-server/pull/1054#pullrequestreview-820694805

In practice nothing really changed for the end user :D

RubenVerborgh: > All the HTML pages have been rewritten to use JS code for submitting the forms using a fetch call. In case of errors JS is also used to edit the HTML page to add it visually. This differs from before where the server had to send back an entirely newly rendered page containing that error. This also makes it so the form data always stays filled in without needing extra code for that.

So we now depend on browser-side JS code for this to work, i.e., no progressive enhancement?
Could you detail why this route was chosen?

> In case of successful API calls, the page gets redirected to a relative `response/` (e.g., `/idp/register/response/`) with the response values being added as query parameters so a correct response can be rendered there. Currently the `/response/` part is hardcoded in the HTML. Another option is to edit the HTML in place so we don't need another page. This is currently what is done on the setup page since it is no longer possible to access the setup page after success.

We might indeed want to avoid that (depending on the answer above).

> The JS for all this is also embedded in all the pages right now. We probably want a separate JS file with utility functions for all this.

Most definitely, also linting etc., option to test, reuse.

> I didn't do it yet, but this would also be a good PR to change `/idp/` to `/.accounts/` as mentioned here [#1054 (review)](https://github.com/solid/community-server/pull/1054#pullrequestreview-820694805)

Oh yes!
joachimvh: > So we now depend on browser-side JS code for this to work, i.e., no progressive enhancement? Could you detail why this route was chosen?

I actually had to spend some time to reconstruct my own argument here since it's been a while since :smile:. This is also a subjective part so you might reach a different conclusion.

One big issue with the HTML part of the IDP is that there are 3 possible request/response combinations:
 - GET always returns the view page
 - POST returns the response page in case of success
 - POST returns the view page in case of an error

In the old implementation this was solved by checking the method and catching errors in the entrypoint IDP handler. Based on that the correct template reference was put in the response metadata object after which the converter was called. In case HTML was preferred the DynamicJsonToTemplateConverter would take that metadata to generate the correct HTML based on the JSON response body and the template in the metadata.

Some problems with that solution is that this mixes the HTML logic with much of the other logic which requires a bunch of exceptions. The fact that we have to catch errors there is annoying for example since that means we can't make use of the standard behaviour of letting errors fall through to the ErrorHandler. Having to add the correct template file path to the response metadata also doesn't feel great.

If the HTML page itself is responsible for doing the call we shift the responsibility of how to view the error to, well, the view. It makes the IDP API a bit more API-like in the sense that it can now just throw an error in case of issues.

This is then the behaviour in the suggested solution:
 - GET always returns the view page
 - POST always returns a JSON response (error or actual response)
The difference is that there are 2 different pages to GET in cases where we would now have 1. The other solution would be to only have 1 page and have that page edit itself to show the response on success. But that was more complex frontend HTML/JS which is why it currently is like it is, because the HTML source still needs updating anyway.
RubenVerborgh: Okay, fair enough. Then we basically have an HTML app operating the JSON API. That works.
joachimvh: > I still find the `location` hack a bit icky. 

Always open to suggestions 😄 . But I couldn't find a better solution due to the reasons described above.

> I think we'll need to clean up the client-side JS to make it more maintainable. It's fine as it is now, but any further changes could jeopardize quality.

I agree, this part of the PR is still sort of in draft in my opinion and will need to be cleaned up before merging.
joachimvh: @RubenVerborgh mostly requesting re-review for the HTML changes, but here is the full changelist since the previous version:
  - Remove GET/POST requirement everywhere except BaseInteractionHandler.
  - Redirect errors get thrown again.
    - There is a new handler that intercepts these errors and returns a JSON response with the location field instead. This way only 1 class needs to be changed if we want to change this.
  - Fully updated HTML with utility scripts.
  - OIDC library dynamically redirects by calling prompt handler
    - No weird index html page needed anymore.
    - HtmlViewHandler now also gives `authenticating` parameter to pages.
      This was actually a bug in the previous version as otherwise the registration page could not link to the login page correctly.  Due to the impact the prompt handler change has on the interaction cookie, the registration result page now always links to the login page, but the login page will prevent logging in if there is no interaction.

Patch:
diff --git a/LICENSE.md b/LICENSE.md
index b433cd27a..dfb243b82 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright © 2019–2021 Inrupt Inc. and imec
+Copyright © 2019–2022 Inrupt Inc. and imec
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 9a75fc02d..e6a208d7f 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -29,11 +29,15 @@ The following changes are relevant for v2 custom configs that replaced certain f
 - Requests targeting the OIDC library now use a separate handler.
   - `/http/handler/default.json`
   - `/identity/handler/default.json`
+- The architecture of IDP interaction handlers has completely changed to improve modularity
+  - `/identity/handler/interaction/*`
+  - `/identity/registration/*`
 
 ### Interface changes
 These changes are relevant if you wrote custom modules for the server that depend on existing interfaces.
 - `TypedRepresentationConverter` function signatures changed 
-  and base functionality moved to `BaseTypedRepresentationConverter`
+  and base functionality moved to `BaseTypedRepresentationConverter`.
+- Many changes to several components related to the IDP. This includes the HTML templates.
 
 ## v2.0.0
 ### New features
diff --git a/config/app/setup/handlers/setup.json b/config/app/setup/handlers/setup.json
index fd0bbd140..ea279ffd3 100644
--- a/config/app/setup/handlers/setup.json
+++ b/config/app/setup/handlers/setup.json
@@ -14,14 +14,31 @@
       "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
       "args_operationHandler": {
         "@type": "SetupHttpHandler",
-        "args_initializer": { "@id": "urn:solid-server:default:RootInitializer" },
-        "args_registrationManager": { "@id": "urn:solid-server:default:SetupRegistrationManager" },
+        "args_handler": {
+          "@type": "SetupHandler",
+          "args_initializer": { "@id": "urn:solid-server:default:RootInitializer" },
+          "args_registrationManager": { "@id": "urn:solid-server:default:SetupRegistrationManager" }
+        },
         "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
         "args_storageKey": "setupCompleted-2.0",
         "args_storage": { "@id": "urn:solid-server:default:SetupStorage" },
-        "args_viewTemplate": "@css:templates/setup/index.html.ejs",
-        "args_responseTemplate": "@css:templates/setup/response.html.ejs",
-        "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }
+        "args_templateEngine": {
+          "comment": "Renders the specific page and embeds it into the main HTML body.",
+          "@type": "ChainedTemplateEngine",
+          "renderedName": "htmlBody",
+          "engines": [
+            {
+              "comment": "Renders the main setup template.",
+              "@type": "EjsTemplateEngine",
+              "template": "@css:templates/setup/index.html.ejs"
+            },
+            {
+              "comment": "Will embed the result of the first engine into the main HTML template.",
+              "@type": "EjsTemplateEngine",
+              "template": "@css:templates/main.html.ejs"
+            }
+          ]
+        }
       }
     },
     {
diff --git a/config/http/static/default.json b/config/http/static/default.json
index 7d3bb2c79..9dfe69f57 100644
--- a/config/http/static/default.json
+++ b/config/http/static/default.json
@@ -12,17 +12,22 @@
           "StaticAssetHandler:_assets_value": "@css:templates/images/favicon.ico"
         },
         {
-          "StaticAssetHandler:_assets_key": "/.well_known/css/styles/",
+          "StaticAssetHandler:_assets_key": "/.well-known/css/styles/",
           "StaticAssetHandler:_assets_value": "@css:templates/styles/"
         },
         {
-          "StaticAssetHandler:_assets_key": "/.well_known/css/fonts/",
+          "StaticAssetHandler:_assets_key": "/.well-known/css/fonts/",
           "StaticAssetHandler:_assets_value": "@css:templates/fonts/"
         },
         {
-          "StaticAssetHandler:_assets_key": "/.well_known/css/images/",
+          "StaticAssetHandler:_assets_key": "/.well-known/css/images/",
           "StaticAssetHandler:_assets_value": "@css:templates/images/"
+        },
+        {
+          "StaticAssetHandler:_assets_key": "/.well-known/css/scripts/",
+          "StaticAssetHandler:_assets_value": "@css:templates/scripts/"
         }
+
       ]
     }
   ]
diff --git a/config/identity/handler/default.json b/config/identity/handler/default.json
index 50fe0f71b..fd57f21cd 100644
--- a/config/identity/handler/default.json
+++ b/config/identity/handler/default.json
@@ -39,11 +39,9 @@
       "comment": "Handles IDP handler behaviour.",
       "@id": "urn:solid-server:default:IdentityProviderHttpHandler",
       "@type": "IdentityProviderHttpHandler",
-      "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
-      "args_idpPath": "/idp",
       "args_providerFactory": { "@id": "urn:solid-server:default:IdentityProviderFactory" },
       "args_converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
-      "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" }
+      "args_handler": { "@id": "urn:solid-server:default:InteractionHandler" }
     }
   ]
 }
diff --git a/config/identity/handler/interaction/routes.json b/config/identity/handler/interaction/routes.json
index 83be9a153..d1b92d5a6 100644
--- a/config/identity/handler/interaction/routes.json
+++ b/config/identity/handler/interaction/routes.json
@@ -1,19 +1,55 @@
 {
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
   "import": [
+    "files-scs:config/identity/handler/interaction/routes/existing-login.json",
     "files-scs:config/identity/handler/interaction/routes/forgot-password.json",
+    "files-scs:config/identity/handler/interaction/routes/index.json",
     "files-scs:config/identity/handler/interaction/routes/login.json",
+    "files-scs:config/identity/handler/interaction/routes/prompt.json",
     "files-scs:config/identity/handler/interaction/routes/reset-password.json",
-    "files-scs:config/identity/handler/interaction/routes/session.json"
+    "files-scs:config/identity/handler/interaction/views/controls.json",
+    "files-scs:config/identity/handler/interaction/views/html.json"
   ],
   "@graph": [
     {
-      "@id": "urn:solid-server:default:IdentityProviderHttpHandler",
-      "IdentityProviderHttpHandler:_args_interactionRoutes": [
-        { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" },
+      "@id": "urn:solid-server:default:InteractionHandler",
+      "@type": "WaterfallHandler",
+      "handlers": [
+        {
+          "comment": "Returns the relevant HTML pages for the interactions when needed",
+          "@id": "urn:solid-server:auth:password:HtmlViewHandler"
+        },
+        {
+          "comment": "Adds controls and API version to JSON responses.",
+          "@id": "urn:solid-server:auth:password:ControlHandler",
+          "ControlHandler:_source" : { "@id": "urn:solid-server:auth:password:LocationInteractionHandler" }
+        }
+      ]
+    },
+    {
+      "comment": "Converts redirect errors to location JSON responses.",
+      "@id": "urn:solid-server:auth:password:LocationInteractionHandler",
+      "@type": "LocationInteractionHandler",
+      "LocationInteractionHandler:_source" : { "@id": "urn:solid-server:auth:password:RouteInteractionHandler" }
+    },
+    {
+      "comment": "Handles every interaction based on their route.",
+      "@id": "urn:solid-server:auth:password:RouteInteractionHandler",
+      "@type": "WaterfallHandler",
+      "handlers": [
+        {
+          "comment": [
+            "This handler is required to prevent Components.js issues with arrays.",
+            "This might be fixed in the next Components.js release after which this can be removed."
+          ],
+          "@type": "UnsupportedAsyncHandler"
+        },
+        { "@id": "urn:solid-server:auth:password:IndexRoute" },
+        { "@id": "urn:solid-server:auth:password:PromptRoute" },
         { "@id": "urn:solid-server:auth:password:LoginRoute" },
-        { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" },
-        { "@id": "urn:solid-server:auth:password:SessionRoute" }
+        { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" },
+        { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" },
+        { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }
       ]
     }
   ]
diff --git a/config/identity/handler/interaction/routes/existing-login.json b/config/identity/handler/interaction/routes/existing-login.json
new file mode 100644
index 000000000..17ad7da19
--- /dev/null
+++ b/config/identity/handler/interaction/routes/existing-login.json
@@ -0,0 +1,16 @@
+{
+  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
+  "@graph": [
+    {
+      "comment": "Handles the interaction that occurs when a logged in user wants to authenticate with a new app.",
+      "@id": "urn:solid-server:auth:password:ExistingLoginRoute",
+      "@type": "RelativeInteractionRoute",
+      "base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
+      "relativePath": "/consent/",
+      "source": {
+        "@type": "ExistingLoginHandler",
+        "interactionCompleter": { "@type": "BaseInteractionCompleter" }
+      }
+    }
+  ]
+}
diff --git a/config/identity/handler/interaction/routes/forgot-password.json b/config/identity/handler/interaction/routes/forgot-password.json
index 3fe3ca6c5..b3e32da5b 100644
--- a/config/identity/handler/interaction/routes/forgot-password.json
+++ b/config/identity/handler/interaction/routes/forgot-password.json
@@ -2,32 +2,20 @@
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
   "@graph": [
     {
-      "comment": "Handles all functionality on the forgot password page",
+      "comment": "Handles the forgot password interaction",
       "@id": "urn:solid-server:auth:password:ForgotPasswordRoute",
-      "@type": "BasicInteractionRoute",
-      "route": "^/forgotpassword/$",
-      "viewTemplates": {
-        "BasicInteractionRoute:_viewTemplates_key": "text/html",
-        "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/forgot-password.html.ejs"
-      },
-      "responseTemplates": {
-        "BasicInteractionRoute:_responseTemplates_key": "text/html",
-        "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/forgot-password-response.html.ejs"
-      },
-      "controls": {
-        "BasicInteractionRoute:_controls_key": "forgotPassword",
-        "BasicInteractionRoute:_controls_value": "/forgotpassword/"
-      },
-      "handler": {
+      "@type": "RelativeInteractionRoute",
+      "base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
+      "relativePath": "/forgotpassword/",
+      "source": {
         "@type": "ForgotPasswordHandler",
         "args_accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
-        "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
-        "args_idpPath": "/idp",
         "args_templateEngine": {
           "@type": "EjsTemplateEngine",
           "template": "@css:templates/identity/email-password/reset-password-email.html.ejs"
         },
-        "args_emailSender": { "@id": "urn:solid-server:default:EmailSender" }
+        "args_emailSender": { "@id": "urn:solid-server:default:EmailSender" },
+        "args_resetRoute": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }
       }
     }
   ]
diff --git a/config/identity/handler/interaction/routes/index.json b/config/identity/handler/interaction/routes/index.json
new file mode 100644
index 000000000..419edd709
--- /dev/null
+++ b/config/identity/handler/interaction/routes/index.json
@@ -0,0 +1,16 @@
+{
+  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
+  "@graph": [
+    {
+      "comment": "Root API entry. Returns an empty body so we can add controls pointing to other interaction routes.",
+      "@id": "urn:solid-server:auth:password:IndexRoute",
+      "@type": "RelativeInteractionRoute",
+      "base": { "@id": "urn:solid-server:default:variable:baseUrl" },
+      "relativePath": "/idp/",
+      "source": {
+        "@type": "FixedInteractionHandler",
+        "response": {}
+      }
+    }
+  ]
+}
diff --git a/config/identity/handler/interaction/routes/login.json b/config/identity/handler/interaction/routes/login.json
index 66f48dffd..294af8e49 100644
--- a/config/identity/handler/interaction/routes/login.json
+++ b/config/identity/handler/interaction/routes/login.json
@@ -2,20 +2,12 @@
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
   "@graph": [
     {
-      "comment": "Handles all functionality on the Login Page",
+      "comment": "Handles the login interaction",
       "@id": "urn:solid-server:auth:password:LoginRoute",
-      "@type": "BasicInteractionRoute",
-      "route": "^/login/$",
-      "prompt": "login",
-      "viewTemplates": {
-        "BasicInteractionRoute:_viewTemplates_key": "text/html",
-        "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/login.html.ejs"
-      },
-      "controls": {
-        "BasicInteractionRoute:_controls_key": "login",
-        "BasicInteractionRoute:_controls_value": "/login/"
-      },
-      "handler": {
+      "@type": "RelativeInteractionRoute",
+      "base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
+      "relativePath": "/login/",
+      "source": {
         "@type": "LoginHandler",
         "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" },
         "interactionCompleter": { "@type": "BaseInteractionCompleter" }
diff --git a/config/identity/handler/interaction/routes/prompt.json b/config/identity/handler/interaction/routes/prompt.json
new file mode 100644
index 000000000..0dba93633
--- /dev/null
+++ b/config/identity/handler/interaction/routes/prompt.json
@@ -0,0 +1,26 @@
+{
+  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
+  "@graph": [
+    {
+      "comment": "Handles OIDC redirects containing a prompt, such as login or consent.",
+      "@id": "urn:solid-server:auth:password:PromptRoute",
+      "@type": "RelativeInteractionRoute",
+      "base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
+      "relativePath": "/prompt/",
+      "source": {
+        "@type": "PromptHandler",
+        "@id": "urn:solid-server:auth:password:PromptHandler",
+        "promptRoutes": [
+          {
+            "PromptHandler:_promptRoutes_key": "login",
+            "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:LoginRoute" }
+          },
+          {
+            "PromptHandler:_promptRoutes_key": "consent",
+            "PromptHandler:_promptRoutes_value": { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" }
+          }
+        ]
+      }
+    }
+  ]
+}
diff --git a/config/identity/handler/interaction/routes/reset-password.json b/config/identity/handler/interaction/routes/reset-password.json
index c4cdd7644..5c1bc3b92 100644
--- a/config/identity/handler/interaction/routes/reset-password.json
+++ b/config/identity/handler/interaction/routes/reset-password.json
@@ -1,21 +1,13 @@
 {
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
-  "comment": "Exports 2 handlers: one for viewing the page and one for doing the reset.",
   "@graph": [
     {
-      "comment": "Handles the reset password page submission",
+      "comment": "Handles the reset password interaction",
       "@id": "urn:solid-server:auth:password:ResetPasswordRoute",
-      "@type": "BasicInteractionRoute",
-      "route": "^/resetpassword/[^/]*$",
-      "viewTemplates": {
-        "BasicInteractionRoute:_viewTemplates_key": "text/html",
-        "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/reset-password.html.ejs"
-      },
-      "responseTemplates": {
-        "BasicInteractionRoute:_responseTemplates_key": "text/html",
-        "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/reset-password-response.html.ejs"
-      },
-      "handler": {
+      "@type": "RelativeInteractionRoute",
+      "base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
+      "relativePath": "/resetpassword/",
+      "source": {
         "@type": "ResetPasswordHandler",
         "accountStore": { "@id": "urn:solid-server:auth:password:AccountStore" }
       }
diff --git a/config/identity/handler/interaction/routes/session.json b/config/identity/handler/interaction/routes/session.json
deleted file mode 100644
index 90910688c..000000000
--- a/config/identity/handler/interaction/routes/session.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
-  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
-  "@graph": [
-    {
-      "comment": "Handles confirm requests",
-      "@id": "urn:solid-server:auth:password:SessionRoute",
-      "@type": "BasicInteractionRoute",
-      "route": "^/confirm/$",
-      "prompt": "consent",
-      "viewTemplates": {
-        "BasicInteractionRoute:_viewTemplates_key": "text/html",
-        "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/confirm.html.ejs"
-      },
-      "handler": {
-        "@type": "SessionHttpHandler",
-        "interactionCompleter": { "@type": "BaseInteractionCompleter" }
-      }
-    }
-  ]
-}
diff --git a/config/identity/handler/interaction/views/controls.json b/config/identity/handler/interaction/views/controls.json
new file mode 100644
index 000000000..cef0c83cb
--- /dev/null
+++ b/config/identity/handler/interaction/views/controls.json
@@ -0,0 +1,27 @@
+{
+  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
+  "@graph": [
+    {
+      "@id": "urn:solid-server:auth:password:ControlHandler",
+      "@type": "ControlHandler",
+      "controls": [
+        {
+          "ControlHandler:_controls_key": "index",
+          "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:IndexRoute" }
+        },
+        {
+          "ControlHandler:_controls_key": "prompt",
+          "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:PromptRoute" }
+        },
+        {
+          "ControlHandler:_controls_key": "login",
+          "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:LoginRoute" }
+        },
+        {
+          "ControlHandler:_controls_key": "forgotPassword",
+          "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }
+        }
+      ]
+    }
+  ]
+}
diff --git a/config/identity/handler/interaction/views/html.json b/config/identity/handler/interaction/views/html.json
new file mode 100644
index 000000000..ba6e568c9
--- /dev/null
+++ b/config/identity/handler/interaction/views/html.json
@@ -0,0 +1,44 @@
+{
+  "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
+  "@graph": [
+    {
+      "@id": "urn:solid-server:auth:password:HtmlViewHandler",
+      "@type": "HtmlViewHandler",
+      "index": { "@id": "urn:solid-server:auth:password:IndexRoute" },
+      "templateEngine": {
+        "comment": "Renders the specific page and embeds it into the main HTML body.",
+        "@type": "ChainedTemplateEngine",
+        "renderedName": "htmlBody",
+        "engines": [
+          {
+            "comment": "Will be called with specific templates to generate HTML snippets.",
+            "@type": "EjsTemplateEngine"
+          },
+          {
+            "comment": "Will embed the result of the first engine into the main HTML template.",
+            "@type": "EjsTemplateEngine",
+            "template": "@css:templates/main.html.ejs"
+          }
+        ]
+      },
+      "templates": [
+        {
+          "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/login.html.ejs",
+          "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:LoginRoute" }
+        },
+        {
+          "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/consent.html.ejs",
+          "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ExistingLoginRoute" }
+        },
+        {
+          "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/forgot-password.html.ejs",
+          "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ForgotPasswordRoute" }
+        },
+        {
+          "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/reset-password.html.ejs",
+          "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" }
+        }
+      ]
+    }
+  ]
+}
diff --git a/config/identity/handler/provider-factory/identity.json b/config/identity/handler/provider-factory/identity.json
index cde397c61..7efe13956 100644
--- a/config/identity/handler/provider-factory/identity.json
+++ b/config/identity/handler/provider-factory/identity.json
@@ -11,7 +11,7 @@
       "args_adapterFactory": { "@id": "urn:solid-server:default:IdpAdapterFactory" },
       "args_baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
       "args_oidcPath": "/.oidc",
-      "args_idpPath": "/idp",
+      "args_interactionHandler": { "@id": "urn:solid-server:auth:password:PromptHandler" },
       "args_storage": { "@id": "urn:solid-server:default:IdpKeyStorage" },
       "args_errorHandler": { "@id": "urn:solid-server:default:ErrorHandler" },
       "args_responseWriter": { "@id": "urn:solid-server:default:ResponseWriter" },
diff --git a/config/identity/registration/enabled.json b/config/identity/registration/enabled.json
index 7ff5663b9..db6579f17 100644
--- a/config/identity/registration/enabled.json
+++ b/config/identity/registration/enabled.json
@@ -5,11 +5,43 @@
   ],
   "@graph": [
     {
-      "comment": "Enable registration by adding a registration handler to the list of interaction routes.",
-      "@id": "urn:solid-server:default:IdentityProviderHttpHandler",
-      "IdentityProviderHttpHandler:_args_interactionRoutes": [
+      "@id": "urn:solid-server:auth:password:RouteInteractionHandler",
+      "WaterfallHandler:_handlers": [
+        {
+          "comment": [
+            "This handler is required to prevent Components.js issues with arrays.",
+            "This might be fixed in the next Components.js release after which this can be removed."
+          ],
+          "@type": "UnsupportedAsyncHandler"
+        },
         { "@id": "urn:solid-server:auth:password:RegistrationRoute" }
       ]
+    },
+    {
+      "@id": "urn:solid-server:auth:password:ControlHandler",
+      "ControlHandler:_controls": [
+        {
+          "ControlHandler:_controls_key": "register",
+          "ControlHandler:_controls_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" }
+        }
+      ]
+    },
+    {
+      "@id": "urn:solid-server:auth:password:HtmlViewHandler",
+      "HtmlViewHandler:_templates": [
+        {
+          "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/register.html.ejs",
+          "HtmlViewHandler:_templates_value": { "@id": "urn:solid-server:auth:password:RegistrationRoute" }
+        },
+        {
+          "HtmlViewHandler:_templates_key": "@css:templates/identity/email-password/reset-password-response.html.ejs",
+          "HtmlViewHandler:_templates_value": {
+            "@type": "RelativeInteractionRoute",
+            "base": { "@id": "urn:solid-server:auth:password:ResetPasswordRoute" },
+            "relativePath": "/response/"
+          }
+        }
+      ]
     }
   ]
 }
diff --git a/config/identity/registration/route/registration.json b/config/identity/registration/route/registration.json
index c0aff91af..9e2e684c1 100644
--- a/config/identity/registration/route/registration.json
+++ b/config/identity/registration/route/registration.json
@@ -2,23 +2,12 @@
   "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^2.0.0/components/context.jsonld",
   "@graph": [
     {
-      "comment": "Handles all functionality on the register page",
+      "comment": "Handles the register interaction",
       "@id": "urn:solid-server:auth:password:RegistrationRoute",
-      "@type": "BasicInteractionRoute",
-      "route": "^/register/$",
-      "viewTemplates": {
-        "BasicInteractionRoute:_viewTemplates_key": "text/html",
-        "BasicInteractionRoute:_viewTemplates_value": "@css:templates/identity/email-password/register.html.ejs"
-      },
-      "responseTemplates": {
-        "BasicInteractionRoute:_responseTemplates_key": "text/html",
-        "BasicInteractionRoute:_responseTemplates_value": "@css:templates/identity/email-password/register-response.html.ejs"
-      },
-      "controls": {
-        "BasicInteractionRoute:_controls_key": "register",
-        "BasicInteractionRoute:_controls_value": "/register/"
-      },
-      "handler": {
+      "@type": "RelativeInteractionRoute",
+      "base": { "@id": "urn:solid-server:auth:password:IndexRoute" },
+      "relativePath": "/register/",
+      "source": {
         "@type": "RegistrationHandler",
         "registrationManager": {
           "@type": "RegistrationManager",
diff --git a/src/identity/IdentityProviderHttpHandler.ts b/src/identity/IdentityProviderHttpHandler.ts
index 174d5e68f..aa9f146ef 100644
--- a/src/identity/IdentityProviderHttpHandler.ts
+++ b/src/identity/IdentityProviderHttpHandler.ts
@@ -1,211 +1,81 @@
-import type { Operation } from '../http/Operation';
-import type { ErrorHandler } from '../http/output/error/ErrorHandler';
-import { ResponseDescription } from '../http/output/response/ResponseDescription';
-import { BasicRepresentation } from '../http/representation/BasicRepresentation';
+import { OkResponseDescription } from '../http/output/response/OkResponseDescription';
+import type { ResponseDescription } from '../http/output/response/ResponseDescription';
 import { getLoggerFor } from '../logging/LogUtil';
 import type { OperationHttpHandlerInput } from '../server/OperationHttpHandler';
 import { OperationHttpHandler } from '../server/OperationHttpHandler';
 import type { RepresentationConverter } from '../storage/conversion/RepresentationConverter';
 import { APPLICATION_JSON } from '../util/ContentTypes';
-import { NotFoundHttpError } from '../util/errors/NotFoundHttpError';
-import { joinUrl, trimTrailingSlashes } from '../util/PathUtil';
-import { addTemplateMetadata, cloneRepresentation } from '../util/ResourceUtil';
-import { readJsonStream } from '../util/StreamUtil';
 import type { ProviderFactory } from './configuration/ProviderFactory';
-import type { Interaction } from './interaction/InteractionHandler';
-import type { InteractionRoute, TemplatedInteractionResult } from './interaction/routing/InteractionRoute';
-
-const API_VERSION = '0.2';
+import type {
+  InteractionHandler,
+  Interaction,
+} from './interaction/InteractionHandler';
 
 export interface IdentityProviderHttpHandlerArgs {
-  /**
-   * Base URL of the server.
-   */
-  baseUrl: string;
-  /**
-   * Relative path of the IDP entry point.
-   */
-  idpPath: string;
   /**
    * Used to generate the OIDC provider.
    */
   providerFactory: ProviderFactory;
   /**
-   * All routes handling the custom IDP behaviour.
-   */
-  interactionRoutes: InteractionRoute[];
-  /**
-   * Used for content negotiation.
+   * Used for converting the input data.
    */
   converter: RepresentationConverter;
   /**
-   * Used for converting output errors.
+   * Handles the requests.
    */
-  errorHandler: ErrorHandler;
+  handler: InteractionHandler;
 }
 
 /**
- * Handles all requests relevant for the entire IDP interaction,
- * by sending them to either a matching {@link InteractionRoute},
- * or the generated Provider from the {@link ProviderFactory} if there is no match.
+ * Generates the active Interaction object if there is an ongoing OIDC interaction
+ * and sends it to the {@link InteractionHandler}.
  *
- * The InteractionRoutes handle all requests where we need custom behaviour,
- * such as everything related to generating and validating an account.
- * The Provider handles all the default request such as the initial handshake.
+ * Input data will first be converted to JSON.
  *
- * This handler handles all requests since it assumes all those requests are relevant for the IDP interaction.
- * A {@link RouterHandler} should be used to filter out other requests.
+ * Only GET and POST methods are accepted.
  */
 export class IdentityProviderHttpHandler extends OperationHttpHandler {
   protected readonly logger = getLoggerFor(this);
 
-  private readonly baseUrl: string;
   private readonly providerFactory: ProviderFactory;
-  private readonly interactionRoutes: InteractionRoute[];
   private readonly converter: RepresentationConverter;
-  private readonly errorHandler: ErrorHandler;
-
-  private readonly controls: Record<string, string>;
+  private readonly handler: InteractionHandler;
 
   public constructor(args: IdentityProviderHttpHandlerArgs) {
     super();
-    // Trimming trailing slashes so the relative URL starts with a slash after slicing this off
-    this.baseUrl = trimTrailingSlashes(joinUrl(args.baseUrl, args.idpPath));
     this.providerFactory = args.providerFactory;
-    this.interactionRoutes = args.interactionRoutes;
     this.converter = args.converter;
-    this.errorHandler = args.errorHandler;
-
-    this.controls = Object.assign(
-      {},
-      ...this.interactionRoutes.map((route): Record<string, string> => this.getRouteControls(route)),
-    );
+    this.handler = args.handler;
   }
 
-  /**
-   * Finds the matching route and resolves the operation.
-   */
   public async handle({ operation, request, response }: OperationHttpHandlerInput): Promise<ResponseDescription> {
     // This being defined means we're in an OIDC session
     let oidcInteraction: Interaction | undefined;
     try {
       const provider = await this.providerFactory.getProvider();
       oidcInteraction = await provider.interactionDetails(request, response);
+      this.logger.debug('Found an active OIDC interaction.');
     } catch {
-      // Just a regular request
+      this.logger.debug('No active OIDC interaction found.');
     }
 
-    const route = await this.findRoute(operation, oidcInteraction);
-    if (!route) {
-      throw new NotFoundHttpError();
-    }
-
-    // Cloning input data so it can be sent back in case of errors
-    let clone = operation.body;
-
-    // IDP handlers expect JSON data
-    if (!operation.body.isEmpty) {
+    // Convert input data to JSON
+    // Allows us to still support form data
+    const { contentType } = operation.body.metadata;
+    if (contentType && contentType !== APPLICATION_JSON) {
+      this.logger.debug(`Converting input ${contentType} to ${APPLICATION_JSON}`);
       const args = {
         representation: operation.body,
         preferences: { type: { [APPLICATION_JSON]: 1 }},
         identifier: operation.target,
       };
-      operation.body = await this.converter.handleSafe(args);
-      clone = await cloneRepresentation(operation.body);
-    }
-
-    const result = await route.handleOperation(operation, oidcInteraction);
-
-    // Reset the body so it can be reused when needed for output
-    operation.body = clone;
-
-    return this.handleInteractionResult(operation, result, oidcInteraction);
-  }
-
-  /**
-   * Finds a route that supports the given request.
-   */
-  private async findRoute(operation: Operation, oidcInteraction?: Interaction): Promise<InteractionRoute | undefined> {
-    if (!operation.target.path.startsWith(this.baseUrl)) {
-      // This is an invalid request
-      return;
-    }
-    const pathName = operation.target.path.slice(this.baseUrl.length);
-
-    for (const route of this.interactionRoutes) {
-      if (route.supportsPath(pathName, oidcInteraction?.prompt.name)) {
-        return route;
-      }
-    }
-  }
-
-  /**
-   * Creates a ResponseDescription based on the InteractionHandlerResult.
-   * This will either be a redirect if type is "complete" or a data stream if the type is "response".
-   */
-  private async handleInteractionResult(operation: Operation, result: TemplatedInteractionResult,
-    oidcInteraction?: Interaction): Promise<ResponseDescription> {
-    let responseDescription: ResponseDescription | undefined;
-
-    if (result.type === 'error') {
-      // We want to show the errors on the original page in case of html interactions, so we can't just throw them here
-      const preferences = { type: { [APPLICATION_JSON]: 1 }};
-      const response = await this.errorHandler.handleSafe({ error: result.error, preferences });
-      const details = await readJsonStream(response.data!);
-
-      // Add the input data to the JSON response;
-      if (!operation.body.isEmpty) {
-        details.prefilled = await readJsonStream(operation.body.data);
-
-        // Don't send passwords back
-        delete details.prefilled.password;
-        delete details.prefilled.confirmPassword;
-      }
-
-      responseDescription =
-        await this.handleResponseResult(details, operation, result.templateFiles, oidcInteraction, response.statusCode);
-    } else {
-      // Convert the response object to a data stream
-      responseDescription =
-        await this.handleResponseResult(result.details ?? {}, operation, result.templateFiles, oidcInteraction);
-    }
-
-    return responseDescription;
-  }
-
-  /**
-   * Converts an InteractionResponseResult to a ResponseDescription by first converting to a Representation
-   * and applying necessary conversions.
-   */
-  private async handleResponseResult(details: Record<string, any>, operation: Operation,
-    templateFiles: Record<string, string>, oidcInteraction?: Interaction, statusCode = 200):
-    Promise<ResponseDescription> {
-    const json = {
-      ...details,
-      apiVersion: API_VERSION,
-      authenticating: Boolean(oidcInteraction),
-      controls: this.controls,
-    };
-    const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON);
-
-    // Template metadata is required for conversion
-    for (const [ type, templateFile ] of Object.entries(templateFiles)) {
-      addTemplateMetadata(representation.metadata, templateFile, type);
+      operation = {
+        ...operation,
+        body: await this.converter.handleSafe(args),
+      };
     }
 
-    // Potentially convert the Representation based on the preferences
-    const args = { representation, preferences: operation.preferences, identifier: operation.target };
-    const converted = await this.converter.handleSafe(args);
-
-    return new ResponseDescription(statusCode, converted.metadata, converted.data);
-  }
-
-  /**
-   * Converts the controls object of a route to one with full URLs.
-   */
-  private getRouteControls(route: InteractionRoute): Record<string, string> {
-    const entries = Object.entries(route.getControls())
-      .map(([ name, path ]): [ string, string ] => [ name, joinUrl(this.baseUrl, path) ]);
-    return Object.fromEntries(entries);
+    const representation = await this.handler.handleSafe({ operation, oidcInteraction });
+    return new OkResponseDescription(representation.metadata, representation.data);
   }
 }
diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts
index 4717c8dae..fc3125422 100644
--- a/src/identity/configuration/IdentityProviderFactory.ts
+++ b/src/identity/configuration/IdentityProviderFactory.ts
@@ -12,10 +12,15 @@ import type { AnyObject,
   ErrorOut,
   Adapter } from 'oidc-provider';
 import { Provider } from 'oidc-provider';
+import type { Operation } from '../../http/Operation';
 import type { ErrorHandler } from '../../http/output/error/ErrorHandler';
 import type { ResponseWriter } from '../../http/output/ResponseWriter';
+import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
 import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
-import { ensureTrailingSlash, joinUrl } from '../../util/PathUtil';
+import { InternalServerError } from '../../util/errors/InternalServerError';
+import { RedirectHttpError } from '../../util/errors/RedirectHttpError';
+import { joinUrl } from '../../util/PathUtil';
+import type { InteractionHandler } from '../interaction/InteractionHandler';
 import type { AdapterFactory } from '../storage/AdapterFactory';
 import type { ProviderFactory } from './ProviderFactory';
 
@@ -33,10 +38,9 @@ export interface IdentityProviderFactoryArgs {
    */
   oidcPath: string;
   /**
-   * The entry point for the custom IDP handlers of the server.
-   * Should start with a slash.
+   * The handler responsible for redirecting interaction requests to the correct URL.
    */
-  idpPath: string;
+  interactionHandler: InteractionHandler;
   /**
    * Storage used to store cookie and JWT keys so they can be re-used in case of multithreading.
    */
@@ -59,14 +63,14 @@ const COOKIES_KEY = 'cookie-secret';
  * The provider will be cached and returned on subsequent calls.
  * Cookie and JWT keys will be stored in an internal storage so they can be re-used over multiple threads.
  * Necessary claims for Solid OIDC interactions will be added.
- * Routes will be updated based on the `baseUrl` and `idpPath`.
+ * Routes will be updated based on the `baseUrl` and `oidcPath`.
  */
 export class IdentityProviderFactory implements ProviderFactory {
   private readonly config: Configuration;
   private readonly adapterFactory!: AdapterFactory;
   private readonly baseUrl!: string;
   private readonly oidcPath!: string;
-  private readonly idpPath!: string;
+  private readonly interactionHandler!: InteractionHandler;
   private readonly storage!: KeyValueStorage<string, unknown>;
   private readonly errorHandler!: ErrorHandler;
   private readonly responseWriter!: ResponseWriter;
@@ -78,9 +82,6 @@ export class IdentityProviderFactory implements ProviderFactory {
    * @param args - Remaining parameters required for the factory.
    */
   public constructor(config: Configuration, args: IdentityProviderFactoryArgs) {
-    if (!args.idpPath.startsWith('/')) {
-      throw new Error('idpPath needs to start with a /');
-    }
     this.config = config;
     Object.assign(this, args);
   }
@@ -230,7 +231,26 @@ export class IdentityProviderFactory implements ProviderFactory {
     // (missing user session, requested ACR not fulfilled, prompt requested, ...)
     // it will resolve the interactions.url helper function and redirect the User-Agent to that url.
     config.interactions = {
-      url: (): string => ensureTrailingSlash(this.idpPath),
+      url: async(ctx, oidcInteraction): Promise<string> => {
+        const operation: Operation = {
+          method: ctx.method,
+          target: { path: ctx.request.href },
+          preferences: {},
+          body: new BasicRepresentation(),
+        };
+
+        // Instead of sending a 3xx redirect to the client (via a RedirectHttpError),
+        // we need to pass the location URL to the OIDC library
+        try {
+          await this.interactionHandler.handleSafe({ operation, oidcInteraction });
+        } catch (error: unknown) {
+          if (RedirectHttpError.isInstance(error)) {
+            return error.location;
+          }
+          throw error;
+        }
+        throw new InternalServerError('Could not correctly redirect for the given interaction.');
+      },
     };
 
     config.routes = {
@@ -254,7 +274,7 @@ export class IdentityProviderFactory implements ProviderFactory {
    */
   private configureErrors(config: Configuration): void {
     config.renderError = async(ctx: KoaContextWithOIDC, out: ErrorOut, error: Error): Promise<void> => {
-      // This allows us to stream directly to to the response object, see https://github.com/koajs/koa/issues/944
+      // This allows us to stream directly to the response object, see https://github.com/koajs/koa/issues/944
       ctx.respond = false;
       const result = await this.errorHandler.handleSafe({ error, preferences: { type: { 'text/plain': 1 }}});
       await this.responseWriter.handleSafe({ response: ctx.res, result });
diff --git a/src/identity/interaction/BaseInteractionHandler.ts b/src/identity/interaction/BaseInteractionHandler.ts
new file mode 100644
index 000000000..e81afe7a2
--- /dev/null
+++ b/src/identity/interaction/BaseInteractionHandler.ts
@@ -0,0 +1,51 @@
+import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
+import type { Representation } from '../../http/representation/Representation';
+import { APPLICATION_JSON } from '../../util/ContentTypes';
+import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError';
+import type { InteractionHandlerInput } from './InteractionHandler';
+import { InteractionHandler } from './InteractionHandler';
+
+/**
+ * Abstract implementation for handlers that always return a fixed JSON view on a GET.
+ * POST requests are passed to an abstract function.
+ * Other methods will be rejected.
+ */
+export abstract class BaseInteractionHandler extends InteractionHandler {
+  private readonly view: string;
+
+  protected constructor(view: Record<string, unknown>) {
+    super();
+    this.view = JSON.stringify(view);
+  }
+
+  public async canHandle(input: InteractionHandlerInput): Promise<void> {
+    await super.canHandle(input);
+    const { method } = input.operation;
+    if (method !== 'GET' && method !== 'POST') {
+      throw new MethodNotAllowedHttpError('Only GET/POST requests are supported.');
+    }
+  }
+
+  public async handle(input: InteractionHandlerInput): Promise<Representation> {
+    switch (input.operation.method) {
+      case 'GET': return this.handleGet(input);
+      case 'POST': return this.handlePost(input);
+      default: throw new MethodNotAllowedHttpError();
+    }
+  }
+
+  /**
+   * Returns a fixed JSON view.
+   * @param input - Input parameters, only the operation target is used.
+   */
+  protected async handleGet(input: InteractionHandlerInput): Promise<Representation> {
+    return new BasicRepresentation(this.view, input.operation.target, APPLICATION_JSON);
+  }
+
+  /**
+   * Function that will be called for POST requests.
+   * Input data remains unchanged.
+   * @param input - Input operation and OidcInteraction if it exists.
+   */
+  protected abstract handlePost(input: InteractionHandlerInput): Promise<Representation>;
+}
diff --git a/src/identity/interaction/CompletingInteractionHandler.ts b/src/identity/interaction/CompletingInteractionHandler.ts
index 5b30f2698..3a460011e 100644
--- a/src/identity/interaction/CompletingInteractionHandler.ts
+++ b/src/identity/interaction/CompletingInteractionHandler.ts
@@ -1,11 +1,11 @@
 import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
 import { FoundHttpError } from '../../util/errors/FoundHttpError';
+import { BaseInteractionHandler } from './BaseInteractionHandler';
 import type { InteractionHandlerInput } from './InteractionHandler';
-import { InteractionHandler } from './InteractionHandler';
 import type { InteractionCompleterInput, InteractionCompleter } from './util/InteractionCompleter';
 
 /**
- * Abstract class for {@link InteractionHandler}s that need to call an {@link InteractionCompleter}.
+ * Abstract extension of {@link BaseInteractionHandler} for handlers that need to call an {@link InteractionCompleter}.
  * This is required by handlers that handle IDP behaviour
  * and need to complete an OIDC interaction by redirecting back to the client,
  * such as when logging in.
@@ -13,17 +13,17 @@ import type { InteractionCompleterInput, InteractionCompleter } from './util/Int
  * Calls the InteractionCompleter with the results returned by the helper function
  * and throw a corresponding {@link FoundHttpError}.
  */
-export abstract class CompletingInteractionHandler extends InteractionHandler {
+export abstract class CompletingInteractionHandler extends BaseInteractionHandler {
   protected readonly interactionCompleter: InteractionCompleter;
 
-  protected constructor(interactionCompleter: InteractionCompleter) {
-    super();
+  protected constructor(view: Record<string, unknown>, interactionCompleter: InteractionCompleter) {
+    super(view);
     this.interactionCompleter = interactionCompleter;
   }
 
   public async canHandle(input: InteractionHandlerInput): Promise<void> {
     await super.canHandle(input);
-    if (!input.oidcInteraction) {
+    if (input.operation.method === 'POST' && !input.oidcInteraction) {
       throw new BadRequestHttpError(
         'This action can only be performed as part of an OIDC authentication flow.',
         { errorCode: 'E0002' },
@@ -31,7 +31,7 @@ export abstract class CompletingInteractionHandler extends InteractionHandler {
     }
   }
 
-  public async handle(input: InteractionHandlerInput): Promise<never> {
+  public async handlePost(input: InteractionHandlerInput): Promise<never> {
     // Interaction is defined due to canHandle call
     const parameters = await this.getCompletionParameters(input as Required<InteractionHandlerInput>);
     const location = await this.interactionCompleter.handleSafe(parameters);
@@ -40,6 +40,7 @@ export abstract class CompletingInteractionHandler extends InteractionHandler {
 
   /**
    * Generates the parameters necessary to call an InteractionCompleter.
+   * The input parameters are the same that the `handlePost` function was called with.
    * @param input - The original input parameters to the `handle` function.
    */
   protected abstract getCompletionParameters(input: Required<InteractionHandlerInput>):
diff --git a/src/identity/interaction/ControlHandler.ts b/src/identity/interaction/ControlHandler.ts
new file mode 100644
index 000000000..34eb78500
--- /dev/null
+++ b/src/identity/interaction/ControlHandler.ts
@@ -0,0 +1,43 @@
+import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
+import type { Representation } from '../../http/representation/Representation';
+import { APPLICATION_JSON } from '../../util/ContentTypes';
+import { InternalServerError } from '../../util/errors/InternalServerError';
+import { readJsonStream } from '../../util/StreamUtil';
+import type { InteractionHandlerInput } from './InteractionHandler';
+import { InteractionHandler } from './InteractionHandler';
+import type { InteractionRoute } from './routing/InteractionRoute';
+
+const INTERNAL_API_VERSION = '0.3';
+
+/**
+ * Adds `controls` and `apiVersion` fields to the output of its source handler,
+ * such that clients can predictably find their way to other resources.
+ * Control paths are determined by the input routes.
+ */
+export class ControlHandler extends InteractionHandler {
+  private readonly source: InteractionHandler;
+  private readonly controls: Record<string, string>;
+
+  public constructor(source: InteractionHandler, controls: Record<string, InteractionRoute>) {
+    super();
+    this.source = source;
+    this.controls = Object.fromEntries(
+      Object.entries(controls).map(([ control, route ]): [ string, string ] => [ control, route.getPath() ]),
+    );
+  }
+
+  public async canHandle(input: InteractionHandlerInput): Promise<void> {
+    await this.source.canHandle(input);
+  }
+
+  public async handle(input: InteractionHandlerInput): Promise<Representation> {
+    const result = await this.source.handle(input);
+    if (result.metadata.contentType !== APPLICATION_JSON) {
+      throw new InternalServerError('Source handler should return application/json.');
+    }
+    const json = await readJsonStream(result.data);
+    json.controls = this.controls;
+    json.apiVersion = INTERNAL_API_VERSION;
+    return new BasicRepresentation(JSON.stringify(json), result.metadata);
+  }
+}
diff --git a/src/identity/interaction/SessionHttpHandler.ts b/src/identity/interaction/ExistingLoginHandler.ts
similarity index 83%
rename from src/identity/interaction/SessionHttpHandler.ts
rename to src/identity/interaction/ExistingLoginHandler.ts
index 5e26f88fd..94755405f 100644
--- a/src/identity/interaction/SessionHttpHandler.ts
+++ b/src/identity/interaction/ExistingLoginHandler.ts
@@ -5,12 +5,12 @@ import type { InteractionHandlerInput } from './InteractionHandler';
 import type { InteractionCompleter, InteractionCompleterInput } from './util/InteractionCompleter';
 
 /**
- * Simple InteractionHttpHandler that sends the session accountId to the InteractionCompleter as webId.
+ * Simple CompletingInteractionRoute that returns the session accountId as webId.
  * This is relevant when a client already logged in this session and tries logging in again.
  */
-export class SessionHttpHandler extends CompletingInteractionHandler {
+export class ExistingLoginHandler extends CompletingInteractionHandler {
   public constructor(interactionCompleter: InteractionCompleter) {
-    super(interactionCompleter);
+    super({}, interactionCompleter);
   }
 
   protected async getCompletionParameters({ operation, oidcInteraction }: Required<InteractionHandlerInput>):
diff --git a/src/identity/interaction/FixedInteractionHandler.ts b/src/identity/interaction/FixedInteractionHandler.ts
new file mode 100644
index 000000000..c3deecf3c
--- /dev/null
+++ b/src/identity/interaction/FixedInteractionHandler.ts
@@ -0,0 +1,26 @@
+/* eslint-disable tsdoc/syntax */
+// tsdoc/syntax cannot handle `@range`
+import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
+import type { Representation } from '../../http/representation/Representation';
+import { APPLICATION_JSON } from '../../util/ContentTypes';
+import type { InteractionHandlerInput } from './InteractionHandler';
+import { InteractionHandler } from './InteractionHandler';
+
+/**
+ * An {@link InteractionHandler} that always returns the same JSON response on all requests.
+ */
+export class FixedInteractionHandler extends InteractionHandler {
+  private readonly response: string;
+
+  /**
+   * @param response - @range {json}
+   */
+  public constructor(response: unknown) {
+    super();
+    this.response = JSON.stringify(response);
+  }
+
+  public async handle({ operation }: InteractionHandlerInput): Promise<Representation> {
+    return new BasicRepresentation(this.response, operation.target, APPLICATION_JSON);
+  }
+}
diff --git a/src/identity/interaction/HtmlViewHandler.ts b/src/identity/interaction/HtmlViewHandler.ts
new file mode 100644
index 000000000..7861f9fcf
--- /dev/null
+++ b/src/identity/interaction/HtmlViewHandler.ts
@@ -0,0 +1,61 @@
+import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
+import type { Representation } from '../../http/representation/Representation';
+import { cleanPreferences, getTypeWeight } from '../../storage/conversion/ConversionUtil';
+import { APPLICATION_JSON, TEXT_HTML } from '../../util/ContentTypes';
+import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError';
+import { NotFoundHttpError } from '../../util/errors/NotFoundHttpError';
+import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
+import type { TemplateEngine } from '../../util/templates/TemplateEngine';
+import type { InteractionHandlerInput } from './InteractionHandler';
+import { InteractionHandler } from './InteractionHandler';
+import type { InteractionRoute } from './routing/InteractionRoute';
+
+/**
+ * Stores the HTML templates associated with specific InteractionRoutes.
+ * Template keys should be file paths to the templates,
+ * values should be the corresponding routes.
+ *
+ * Will only handle GET operations for which there is a matching template if HTML is more preferred than JSON.
+ * Reason for doing it like this instead of a standard content negotiation flow
+ * is because we only want to return the HTML pages on GET requests. *
+ *
+ * Templates will receive the parameter `idpIndex` in their context pointing to the root index URL of the IDP API
+ * and an `authenticating` parameter indicating if this is an active OIDC interaction.
+ */
+export class HtmlViewHandler extends InteractionHandler {
+  private readonly idpIndex: string;
+  private readonly templateEngine: TemplateEngine;
+  private readonly templates: Record<string, string>;
+
+  public constructor(index: InteractionRoute, templateEngine: TemplateEngine,
+    templates: Record<string, InteractionRoute>) {
+    super();
+    this.idpIndex = index.getPath();
+    this.templateEngine = templateEngine;
+    this.templates = Object.fromEntries(
+      Object.entries(templates).map(([ template, route ]): [ string, string ] => [ route.getPath(), template ]),
+    );
+  }
+
+  public async canHandle({ operation }: InteractionHandlerInput): Promise<void> {
+    if (operation.method !== 'GET') {
+      throw new MethodNotAllowedHttpError();
+    }
+    if (!this.templates[operation.target.path]) {
+      throw new NotFoundHttpError();
+    }
+    const preferences = cleanPreferences(operation.preferences.type);
+    const htmlWeight = getTypeWeight(TEXT_HTML, preferences);
+    const jsonWeight = getTypeWeight(APPLICATION_JSON, preferences);
+    if (jsonWeight >= htmlWeight) {
+      throw new NotImplementedHttpError('HTML views are only returned when they are preferred.');
+    }
+  }
+
+  public async handle({ operation, oidcInteraction }: InteractionHandlerInput): Promise<Representation> {
+    const template = this.templates[operation.target.path];
+    const contents = { idpIndex: this.idpIndex, authenticating: Boolean(oidcInteraction) };
+    const result = await this.templateEngine.render(contents, { templateFile: template });
+    return new BasicRepresentation(result, operation.target, TEXT_HTML);
+  }
+}
diff --git a/src/identity/interaction/InteractionHandler.ts b/src/identity/interaction/InteractionHandler.ts
index 5bab30adf..3bef6f451 100644
--- a/src/identity/interaction/InteractionHandler.ts
+++ b/src/identity/interaction/InteractionHandler.ts
@@ -1,5 +1,6 @@
 import type { KoaContextWithOIDC } from 'oidc-provider';
 import type { Operation } from '../../http/Operation';
+import type { Representation } from '../../http/representation/Representation';
 import { APPLICATION_JSON } from '../../util/ContentTypes';
 import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
 import { AsyncHandler } from '../../util/handlers/AsyncHandler';
@@ -9,7 +10,7 @@ export type Interaction = NonNullable<KoaContextWithOIDC['oidc']['entities']['In
 
 export interface InteractionHandlerInput {
   /**
-   * The operation to execute
+   * The operation to execute.
    */
   operation: Operation;
   /**
@@ -19,25 +20,14 @@ export interface InteractionHandlerInput {
   oidcInteraction?: Interaction;
 }
 
-export type InteractionHandlerResult = InteractionResponseResult | InteractionErrorResult;
-
-export interface InteractionResponseResult<T = NodeJS.Dict<any>> {
-  type: 'response';
-  details?: T;
-}
-
-export interface InteractionErrorResult {
-  type: 'error';
-  error: Error;
-}
-
 /**
  * Handler used for IDP interactions.
  * Only supports JSON data.
  */
-export abstract class InteractionHandler extends AsyncHandler<InteractionHandlerInput, InteractionHandlerResult> {
+export abstract class InteractionHandler extends AsyncHandler<InteractionHandlerInput, Representation> {
   public async canHandle({ operation }: InteractionHandlerInput): Promise<void> {
-    if (operation.body?.metadata.contentType !== APPLICATION_JSON) {
+    const { contentType } = operation.body.metadata;
+    if (contentType && contentType !== APPLICATION_JSON) {
       throw new NotImplementedHttpError('Only application/json data is supported.');
     }
   }
diff --git a/src/identity/interaction/LocationInteractionHandler.ts b/src/identity/interaction/LocationInteractionHandler.ts
new file mode 100644
index 000000000..253e1f357
--- /dev/null
+++ b/src/identity/interaction/LocationInteractionHandler.ts
@@ -0,0 +1,40 @@
+import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
+import type { Representation } from '../../http/representation/Representation';
+import { APPLICATION_JSON } from '../../util/ContentTypes';
+import { RedirectHttpError } from '../../util/errors/RedirectHttpError';
+import type { InteractionHandlerInput } from './InteractionHandler';
+import { InteractionHandler } from './InteractionHandler';
+
+/**
+ * Catches redirect errors from the source and returns a JSON body containing a `location` field instead.
+ * This allows the API to be used more easily from the browser.
+ *
+ * The issue is that if the API actually did a redirect,
+ * this would make it unusable when using it on HTML pages that need to render errors in case the fetch fails,
+ * but want to redirect the page in case it succeeds.
+ * See full overview at https://github.com/solid/community-server/pull/1088.
+ */
+export class LocationInteractionHandler extends InteractionHandler {
+  private readonly source: InteractionHandler;
+
+  public constructor(source: InteractionHandler) {
+    super();
+    this.source = source;
+  }
+
+  public async canHandle(input: InteractionHandlerInput): Promise<void> {
+    await this.source.canHandle(input);
+  }
+
+  public async handle(input: InteractionHandlerInput): Promise<Representation> {
+    try {
+      return await this.source.handle(input);
+    } catch (error: unknown) {
+      if (RedirectHttpError.isInstance(error)) {
+        const body = JSON.stringify({ location: error.location });
+        return new BasicRepresentation(body, input.operation.target, APPLICATION_JSON);
+      }
+      throw error;
+    }
+  }
+}
diff --git a/src/identity/interaction/PromptHandler.ts b/src/identity/interaction/PromptHandler.ts
new file mode 100644
index 000000000..1eb838204
--- /dev/null
+++ b/src/identity/interaction/PromptHandler.ts
@@ -0,0 +1,28 @@
+import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
+import { FoundHttpError } from '../../util/errors/FoundHttpError';
+import { InteractionHandler } from './InteractionHandler';
+import type { InteractionHandlerInput } from './InteractionHandler';
+import type { InteractionRoute } from './routing/InteractionRoute';
+
+/**
+ * Redirects requests based on the OIDC Interaction prompt.
+ * Errors in case no match was found.
+ */
+export class PromptHandler extends InteractionHandler {
+  private readonly promptRoutes: Record<string, InteractionRoute>;
+
+  public constructor(promptRoutes: Record<string, InteractionRoute>) {
+    super();
+    this.promptRoutes = promptRoutes;
+  }
+
+  public async handle({ oidcInteraction }: InteractionHandlerInput): Promise<never> {
+    // We also want to redirect on GET so no method check is needed
+    const prompt = oidcInteraction?.prompt.name;
+    if (prompt && this.promptRoutes[prompt]) {
+      const location = this.promptRoutes[prompt].getPath();
+      throw new FoundHttpError(location);
+    }
+    throw new BadRequestHttpError(`Unsupported prompt: ${prompt}`);
+  }
+}
diff --git a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts
index 7e64f6c50..a4ad43ebc 100644
--- a/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts
+++ b/src/identity/interaction/email-password/handler/ForgotPasswordHandler.ts
@@ -1,49 +1,55 @@
 import assert from 'assert';
+import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation';
+import type { Representation } from '../../../../http/representation/Representation';
 import { getLoggerFor } from '../../../../logging/LogUtil';
-import { ensureTrailingSlash, joinUrl } from '../../../../util/PathUtil';
+import { APPLICATION_JSON } from '../../../../util/ContentTypes';
 import { readJsonStream } from '../../../../util/StreamUtil';
 import type { TemplateEngine } from '../../../../util/templates/TemplateEngine';
-import { InteractionHandler } from '../../InteractionHandler';
-import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler';
+import { BaseInteractionHandler } from '../../BaseInteractionHandler';
+import type { InteractionHandlerInput } from '../../InteractionHandler';
+import type { InteractionRoute } from '../../routing/InteractionRoute';
 import type { EmailSender } from '../../util/EmailSender';
 import type { AccountStore } from '../storage/AccountStore';
 
+const forgotPasswordView = {
+  required: {
+    email: 'string',
+  },
+} as const;
+
 export interface ForgotPasswordHandlerArgs {
   accountStore: AccountStore;
-  baseUrl: string;
-  idpPath: string;
   templateEngine: TemplateEngine<{ resetLink: string }>;
   emailSender: EmailSender;
+  resetRoute: InteractionRoute;
 }
 
 /**
  * Handles the submission of the ForgotPassword form
  */
-export class ForgotPasswordHandler extends InteractionHandler {
+export class ForgotPasswordHandler extends BaseInteractionHandler {
   protected readonly logger = getLoggerFor(this);
 
   private readonly accountStore: AccountStore;
-  private readonly baseUrl: string;
-  private readonly idpPath: string;
   private readonly templateEngine: TemplateEngine<{ resetLink: string }>;
   private readonly emailSender: EmailSender;
+  private readonly resetRoute: InteractionRoute;
 
   public constructor(args: ForgotPasswordHandlerArgs) {
-    super();
+    super(forgotPasswordView);
     this.accountStore = args.accountStore;
-    this.baseUrl = ensureTrailingSlash(args.baseUrl);
-    this.idpPath = args.idpPath;
     this.templateEngine = args.templateEngine;
     this.emailSender = args.emailSender;
+    this.resetRoute = args.resetRoute;
   }
 
-  public async handle({ operation }: InteractionHandlerInput): Promise<InteractionResponseResult<{ email: string }>> {
+  public async handlePost({ operation }: InteractionHandlerInput): Promise<Representation> {
     // Validate incoming data
     const { email } = await readJsonStream(operation.body.data);
     assert(typeof email === 'string' && email.length > 0, 'Email required');
 
     await this.resetPassword(email);
-    return { type: 'response', details: { email }};
+    return new BasicRepresentation(JSON.stringify({ email }), operation.target, APPLICATION_JSON);
   }
 
   /**
@@ -68,7 +74,7 @@ export class ForgotPasswordHandler extends InteractionHandler {
    */
   private async sendResetMail(recordId: string, email: string): Promise<void> {
     this.logger.info(`Sending password reset to ${email}`);
-    const resetLink = joinUrl(this.baseUrl, this.idpPath, `resetpassword/${recordId}`);
+    const resetLink = `${this.resetRoute.getPath()}?rid=${encodeURIComponent(recordId)}`;
     const renderedEmail = await this.templateEngine.render({ resetLink });
     await this.emailSender.handleSafe({
       recipient: email,
diff --git a/src/identity/interaction/email-password/handler/LoginHandler.ts b/src/identity/interaction/email-password/handler/LoginHandler.ts
index 2ef26889f..0f8e9f04c 100644
--- a/src/identity/interaction/email-password/handler/LoginHandler.ts
+++ b/src/identity/interaction/email-password/handler/LoginHandler.ts
@@ -6,9 +6,22 @@ import { readJsonStream } from '../../../../util/StreamUtil';
 import { CompletingInteractionHandler } from '../../CompletingInteractionHandler';
 import type { InteractionHandlerInput } from '../../InteractionHandler';
 import type { InteractionCompleterInput, InteractionCompleter } from '../../util/InteractionCompleter';
-
 import type { AccountStore } from '../storage/AccountStore';
 
+const loginView = {
+  required: {
+    email: 'string',
+    password: 'string',
+    remember: 'boolean',
+  },
+} as const;
+
+interface LoginInput {
+  email: string;
+  password: string;
+  remember: boolean;
+}
+
 /**
  * Handles the submission of the Login Form and logs the user in.
  * Will throw a RedirectHttpError on success.
@@ -19,12 +32,13 @@ export class LoginHandler extends CompletingInteractionHandler {
   private readonly accountStore: AccountStore;
 
   public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) {
-    super(interactionCompleter);
+    super(loginView, interactionCompleter);
     this.accountStore = accountStore;
   }
 
-  protected async getCompletionParameters({ operation, oidcInteraction }: Required<InteractionHandlerInput>):
+  protected async getCompletionParameters(input: Required<InteractionHandlerInput>):
   Promise<InteractionCompleterInput> {
+    const { operation, oidcInteraction } = input;
     const { email, password, remember } = await this.parseInput(operation);
     // Try to log in, will error if email/password combination is invalid
     const webId = await this.accountStore.authenticate(email, password);
@@ -39,15 +53,12 @@ export class LoginHandler extends CompletingInteractionHandler {
   }
 
   /**
-   * Parses and validates the input form data.
+   * Validates the input data. Also makes sure remember is a boolean.
    * Will throw an error in case something is wrong.
-   * All relevant data that was correct up to that point will be prefilled.
    */
-  private async parseInput(operation: Operation): Promise<{ email: string; password: string; remember: boolean }> {
-    const prefilled: Record<string, string> = {};
+  private async parseInput(operation: Operation): Promise<LoginInput> {
     const { email, password, remember } = await readJsonStream(operation.body.data);
     assert(typeof email === 'string' && email.length > 0, 'Email required');
-    prefilled.email = email;
     assert(typeof password === 'string' && password.length > 0, 'Password required');
     return { email, password, remember: Boolean(remember) };
   }
diff --git a/src/identity/interaction/email-password/handler/RegistrationHandler.ts b/src/identity/interaction/email-password/handler/RegistrationHandler.ts
index 16db7c395..a8ae66b5c 100644
--- a/src/identity/interaction/email-password/handler/RegistrationHandler.ts
+++ b/src/identity/interaction/email-password/handler/RegistrationHandler.ts
@@ -1,27 +1,46 @@
+import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation';
+import type { Representation } from '../../../../http/representation/Representation';
 import { getLoggerFor } from '../../../../logging/LogUtil';
+import { APPLICATION_JSON } from '../../../../util/ContentTypes';
 import { readJsonStream } from '../../../../util/StreamUtil';
-import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler';
-import { InteractionHandler } from '../../InteractionHandler';
-import type { RegistrationManager, RegistrationResponse } from '../util/RegistrationManager';
+import { BaseInteractionHandler } from '../../BaseInteractionHandler';
+import type { InteractionHandlerInput } from '../../InteractionHandler';
+import type { RegistrationManager } from '../util/RegistrationManager';
+
+const registrationView = {
+  required: {
+    email: 'string',
+    password: 'string',
+    confirmPassword: 'string',
+    createWebId: 'boolean',
+    register: 'boolean',
+    createPod: 'boolean',
+    rootPod: 'boolean',
+  },
+  optional: {
+    webId: 'string',
+    podName: 'string',
+    template: 'string',
+  },
+} as const;
 
 /**
  * Supports registration based on the `RegistrationManager` behaviour.
  */
-export class RegistrationHandler extends InteractionHandler {
+export class RegistrationHandler extends BaseInteractionHandler {
   protected readonly logger = getLoggerFor(this);
 
   private readonly registrationManager: RegistrationManager;
 
   public constructor(registrationManager: RegistrationManager) {
-    super();
+    super(registrationView);
     this.registrationManager = registrationManager;
   }
 
-  public async handle({ operation }: InteractionHandlerInput):
-  Promise<InteractionResponseResult<RegistrationResponse>> {
+  public async handlePost({ operation }: InteractionHandlerInput): Promise<Representation> {
     const data = await readJsonStream(operation.body.data);
     const validated = this.registrationManager.validateInput(data, false);
     const details = await this.registrationManager.register(validated, false);
-    return { type: 'response', details };
+    return new BasicRepresentation(JSON.stringify(details), operation.target, APPLICATION_JSON);
   }
 }
diff --git a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts
index b2150ca20..5f9fe520e 100644
--- a/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts
+++ b/src/identity/interaction/email-password/handler/ResetPasswordHandler.ts
@@ -1,30 +1,39 @@
 import assert from 'assert';
+import { BasicRepresentation } from '../../../../http/representation/BasicRepresentation';
+import type { Representation } from '../../../../http/representation/Representation';
 import { getLoggerFor } from '../../../../logging/LogUtil';
+import { APPLICATION_JSON } from '../../../../util/ContentTypes';
 import { readJsonStream } from '../../../../util/StreamUtil';
-import type { InteractionResponseResult, InteractionHandlerInput } from '../../InteractionHandler';
-import { InteractionHandler } from '../../InteractionHandler';
+import { BaseInteractionHandler } from '../../BaseInteractionHandler';
+import type { InteractionHandlerInput } from '../../InteractionHandler';
 import { assertPassword } from '../EmailPasswordUtil';
 import type { AccountStore } from '../storage/AccountStore';
 
+const resetPasswordView = {
+  required: {
+    password: 'string',
+    confirmPassword: 'string',
+    recordId: 'string',
+  },
+} as const;
+
 /**
- * Handles the submission of the ResetPassword form:
- * this is the form that is linked in the reset password email.
+ * Resets a password if a valid `recordId` is provided,
+ * which should have been generated by a different handler.
  */
-export class ResetPasswordHandler extends InteractionHandler {
+export class ResetPasswordHandler extends BaseInteractionHandler {
   protected readonly logger = getLoggerFor(this);
 
   private readonly accountStore: AccountStore;
 
   public constructor(accountStore: AccountStore) {
-    super();
+    super(resetPasswordView);
     this.accountStore = accountStore;
   }
 
-  public async handle({ operation }: InteractionHandlerInput): Promise<InteractionResponseResult> {
-    // Extract record ID from request URL
-    const recordId = /\/([^/]+)$/u.exec(operation.target.path)?.[1];
+  public async handlePost({ operation }: InteractionHandlerInput): Promise<Representation> {
     // Validate input data
-    const { password, confirmPassword } = await readJsonStream(operation.body.data);
+    const { password, confirmPassword, recordId } = await readJsonStream(operation.body.data);
     assert(
       typeof recordId === 'string' && recordId.length > 0,
       'Invalid request. Open the link from your email again',
@@ -32,7 +41,7 @@ export class ResetPasswordHandler extends InteractionHandler {
     assertPassword(password, confirmPassword);
 
     await this.resetPassword(recordId, password);
-    return { type: 'response' };
+    return new BasicRepresentation(JSON.stringify({}), operation.target, APPLICATION_JSON);
   }
 
   /**
diff --git a/src/identity/interaction/routing/BasicInteractionRoute.ts b/src/identity/interaction/routing/BasicInteractionRoute.ts
index 580d3dd12..f726e687e 100644
--- a/src/identity/interaction/routing/BasicInteractionRoute.ts
+++ b/src/identity/interaction/routing/BasicInteractionRoute.ts
@@ -1,101 +1,43 @@
-import type { Operation } from '../../../http/Operation';
-import { BadRequestHttpError } from '../../../util/errors/BadRequestHttpError';
-import { createErrorMessage, isError } from '../../../util/errors/ErrorUtil';
-import { InternalServerError } from '../../../util/errors/InternalServerError';
-import { RedirectHttpError } from '../../../util/errors/RedirectHttpError';
-import { trimTrailingSlashes } from '../../../util/PathUtil';
-import type {
-  InteractionHandler,
-  Interaction,
-} from '../InteractionHandler';
-import type { InteractionRoute, TemplatedInteractionResult } from './InteractionRoute';
+import type { Representation } from '../../../http/representation/Representation';
+import { NotFoundHttpError } from '../../../util/errors/NotFoundHttpError';
+import { UnsupportedAsyncHandler } from '../../../util/handlers/UnsupportedAsyncHandler';
+import { InteractionHandler } from '../InteractionHandler';
+import type { InteractionHandlerInput } from '../InteractionHandler';
+import type { InteractionRoute } from './InteractionRoute';
 
 /**
- * Default implementation of the InteractionRoute.
- * See function comments for specifics.
+ * Default implementation of an InteractionHandler with an InteractionRoute.
+ *
+ * Rejects operations that target a different path,
+ * otherwise the input parameters get passed to the source handler.
+ *
+ * In case no source handler is provided it defaults to an {@link UnsupportedAsyncHandler}.
+ * This can be useful if you want an object with just the route.
  */
-export class BasicInteractionRoute implements InteractionRoute {
-  public readonly route: RegExp;
-  public readonly handler: InteractionHandler;
-  public readonly viewTemplates: Record<string, string>;
-  public readonly prompt?: string;
-  public readonly responseTemplates: Record<string, string>;
-  public readonly controls: Record<string, string>;
+export class BasicInteractionRoute extends InteractionHandler implements InteractionRoute {
+  private readonly path: string;
+  private readonly source: InteractionHandler;
 
-  /**
-   * @param route - Regex to match this route.
-   * @param viewTemplates - Templates to render on GET requests.
-   *                        Keys are content-types, values paths to a template.
-   * @param handler - Handler to call on POST requests.
-   * @param prompt - In case of requests to the IDP entry point, the session prompt will be compared to this.
-   * @param responseTemplates - Templates to render as a response to POST requests when required.
-   *                            Keys are content-types, values paths to a template.
-   * @param controls - Controls to add to the response JSON.
-   *                   The keys will be copied and the values will be converted to full URLs.
-   */
-  public constructor(route: string,
-    viewTemplates: Record<string, string>,
-    handler: InteractionHandler,
-    prompt?: string,
-    responseTemplates: Record<string, string> = {},
-    controls: Record<string, string> = {}) {
-    this.route = new RegExp(route, 'u');
-    this.viewTemplates = viewTemplates;
-    this.handler = handler;
-    this.prompt = prompt;
-    this.responseTemplates = responseTemplates;
-    this.controls = controls;
+  public constructor(path: string, source?: InteractionHandler) {
+    super();
+    this.path = path;
+    this.source = source ?? new UnsupportedAsyncHandler('This route has no associated handler.');
   }
 
-  /**
-   * Returns the stored controls.
-   */
-  public getControls(): Record<string, string> {
-    return this.controls;
+  public getPath(): string {
+    return this.path;
   }
 
-  /**
-   * Checks support by comparing the prompt if the path targets the base URL,
-   * and otherwise comparing with the stored route regular expression.
-   */
-  public supportsPath(path: string, prompt?: string): boolean {
-    // In case the request targets the IDP entry point the prompt determines where to go
-    if (trimTrailingSlashes(path).length === 0 && prompt) {
-      return this.prompt === prompt;
+  public async canHandle(input: InteractionHandlerInput): Promise<void> {
+    const { target } = input.operation;
+    const path = this.getPath();
+    if (target.path !== path) {
+      throw new NotFoundHttpError();
     }
-    return this.route.test(path);
+    await this.source.canHandle(input);
   }
 
-  /**
-   * GET requests return a default response result.
-   * POST requests return the InteractionHandler result.
-   * InteractionHandler errors will be converted into response results.
-   *
-   * All results will be appended with the matching template paths.
-   *
-   * Will error for other methods
-   */
-  public async handleOperation(operation: Operation, oidcInteraction?: Interaction):
-  Promise<TemplatedInteractionResult> {
-    switch (operation.method) {
-      case 'GET':
-        return { type: 'response', templateFiles: this.viewTemplates };
-      case 'POST':
-        try {
-          const result = await this.handler.handleSafe({ operation, oidcInteraction });
-          return { ...result, templateFiles: this.responseTemplates };
-        } catch (err: unknown) {
-          // Redirect errors need to be propagated and not rendered on the response pages.
-          // Otherwise, the user would be redirected to a new page only containing that error.
-          if (RedirectHttpError.isInstance(err)) {
-            throw err;
-          }
-          const error = isError(err) ? err : new InternalServerError(createErrorMessage(err));
-          // Potentially render the error in the view
-          return { type: 'error', error, templateFiles: this.viewTemplates };
-        }
-      default:
-        throw new BadRequestHttpError(`Unsupported request: ${operation.method} ${operation.target.path}`);
-    }
+  public async handle(input: InteractionHandlerInput): Promise<Representation> {
+    return this.source.handle(input);
   }
 }
diff --git a/src/identity/interaction/routing/InteractionRoute.ts b/src/identity/interaction/routing/InteractionRoute.ts
index 5287ce9b6..3b0caccff 100644
--- a/src/identity/interaction/routing/InteractionRoute.ts
+++ b/src/identity/interaction/routing/InteractionRoute.ts
@@ -1,33 +1,9 @@
-import type { Operation } from '../../../http/Operation';
-import type { Interaction, InteractionHandlerResult } from '../InteractionHandler';
-
-export type TemplatedInteractionResult<T extends InteractionHandlerResult = InteractionHandlerResult> = T & {
-  templateFiles: Record<string, string>;
-};
-
 /**
- * Handles the routing behaviour for IDP handlers.
+ * An object with a specific path.
  */
 export interface InteractionRoute {
   /**
-   * Returns the control fields that should be added to response objects.
-   * Keys are control names, values are relative URL paths.
+   * @returns The absolute path of this route.
    */
-  getControls: () => Record<string, string>;
-
-  /**
-   * If this route supports the given path.
-   * @param path - Relative URL path.
-   * @param prompt - Session prompt if there is one.
-   */
-  supportsPath: (path: string, prompt?: string) => boolean;
-
-  /**
-   * Handles the given operation.
-   * @param operation - Operation to handle.
-   * @param oidcInteraction - Interaction if there is one.
-   *
-   * @returns InteractionHandlerResult appended with relevant template files.
-   */
-  handleOperation: (operation: Operation, oidcInteraction?: Interaction) => Promise<TemplatedInteractionResult>;
+  getPath: () => string;
 }
diff --git a/src/identity/interaction/routing/RelativeInteractionRoute.ts b/src/identity/interaction/routing/RelativeInteractionRoute.ts
new file mode 100644
index 000000000..6443b5ec3
--- /dev/null
+++ b/src/identity/interaction/routing/RelativeInteractionRoute.ts
@@ -0,0 +1,18 @@
+import { joinUrl } from '../../../util/PathUtil';
+import type { InteractionHandler } from '../InteractionHandler';
+import { BasicInteractionRoute } from './BasicInteractionRoute';
+import type { InteractionRoute } from './InteractionRoute';
+
+/**
+ * A route that is relative to another route.
+ * The relative path will be joined to the input base,
+ * which can either be an absolute URL or an InteractionRoute of which the path will be used.
+ * The source handler will be called for all operation requests
+ */
+export class RelativeInteractionRoute extends BasicInteractionRoute {
+  public constructor(base: InteractionRoute | string, relativePath: string, source?: InteractionHandler) {
+    const url = typeof base === 'string' ? base : base.getPath();
+    const path = joinUrl(url, relativePath);
+    super(path, source);
+  }
+}
diff --git a/src/index.ts b/src/index.ts
index f3ed16de2..2d68312c0 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -147,6 +147,7 @@ export * from './identity/interaction/email-password/EmailPasswordUtil';
 // Identity/Interaction/Routing
 export * from './identity/interaction/routing/BasicInteractionRoute';
 export * from './identity/interaction/routing/InteractionRoute';
+export * from './identity/interaction/routing/RelativeInteractionRoute';
 
 // Identity/Interaction/Util
 export * from './identity/interaction/util/BaseEmailSender';
@@ -155,9 +156,15 @@ export * from './identity/interaction/util/EmailSender';
 export * from './identity/interaction/util/InteractionCompleter';
 
 // Identity/Interaction
+export * from './identity/interaction/BaseInteractionHandler';
 export * from './identity/interaction/CompletingInteractionHandler';
+export * from './identity/interaction/ExistingLoginHandler';
+export * from './identity/interaction/ControlHandler';
+export * from './identity/interaction/FixedInteractionHandler';
+export * from './identity/interaction/HtmlViewHandler';
 export * from './identity/interaction/InteractionHandler';
-export * from './identity/interaction/SessionHttpHandler';
+export * from './identity/interaction/LocationInteractionHandler';
+export * from './identity/interaction/PromptHandler';
 
 // Identity/Ownership
 export * from './identity/ownership/NoCheckOwnershipValidator';
@@ -178,6 +185,7 @@ export * from './init/final/Finalizable';
 export * from './init/final/ParallelFinalizer';
 
 // Init/Setup
+export * from './init/setup/SetupHandler';
 export * from './init/setup/SetupHttpHandler';
 
 // Init/Cli
diff --git a/src/init/setup/SetupHandler.ts b/src/init/setup/SetupHandler.ts
new file mode 100644
index 000000000..9d7f798f4
--- /dev/null
+++ b/src/init/setup/SetupHandler.ts
@@ -0,0 +1,83 @@
+import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
+import type { Representation } from '../../http/representation/Representation';
+import { BaseInteractionHandler } from '../../identity/interaction/BaseInteractionHandler';
+import type { RegistrationManager } from '../../identity/interaction/email-password/util/RegistrationManager';
+import type { InteractionHandlerInput } from '../../identity/interaction/InteractionHandler';
+import { getLoggerFor } from '../../logging/LogUtil';
+import { APPLICATION_JSON } from '../../util/ContentTypes';
+import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
+import { readJsonStream } from '../../util/StreamUtil';
+import type { Initializer } from '../Initializer';
+
+export interface SetupHandlerArgs {
+  /**
+   * Used for registering a pod during setup.
+   */
+  registrationManager?: RegistrationManager;
+  /**
+   * Initializer to call in case no registration procedure needs to happen.
+   * This Initializer should make sure the necessary resources are there so the server can work correctly.
+   */
+  initializer?: Initializer;
+}
+
+/**
+ * On POST requests, runs an initializer and/or performs a registration step, both optional.
+ */
+export class SetupHandler extends BaseInteractionHandler {
+  protected readonly logger = getLoggerFor(this);
+
+  private readonly registrationManager?: RegistrationManager;
+  private readonly initializer?: Initializer;
+
+  public constructor(args: SetupHandlerArgs) {
+    super({});
+    this.registrationManager = args.registrationManager;
+    this.initializer = args.initializer;
+  }
+
+  protected async handlePost({ operation }: InteractionHandlerInput): Promise<Representation> {
+    const json = operation.body.isEmpty ? {} : await readJsonStream(operation.body.data);
+
+    const output: Record<string, any> = { initialize: false, registration: false };
+    if (json.registration) {
+      Object.assign(output, await this.register(json));
+      output.registration = true;
+    } else if (json.initialize) {
+      // We only want to initialize if no registration happened
+      await this.initialize();
+      output.initialize = true;
+    }
+
+    this.logger.debug(`Output: ${JSON.stringify(output)}`);
+
+    return new BasicRepresentation(JSON.stringify(output), APPLICATION_JSON);
+  }
+
+  /**
+   * Call the initializer.
+   * Errors if no initializer was defined.
+   */
+  private async initialize(): Promise<void> {
+    if (!this.initializer) {
+      throw new NotImplementedHttpError('This server is not configured with a setup initializer.');
+    }
+    await this.initializer.handleSafe();
+  }
+
+  /**
+   * Register a user based on the given input.
+   * Errors if no registration manager is defined.
+   */
+  private async register(json: NodeJS.Dict<any>): Promise<Record<string, any>> {
+    if (!this.registrationManager) {
+      throw new NotImplementedHttpError('This server is not configured to support registration during setup.');
+    }
+    // Validate the input JSON
+    const validated = this.registrationManager.validateInput(json, true);
+    this.logger.debug(`Validated input: ${JSON.stringify(validated)}`);
+
+    // Register and/or create a pod as requested. Potentially does nothing if all booleans are false.
+    return this.registrationManager.register(validated, true);
+  }
+}
diff --git a/src/init/setup/SetupHttpHandler.ts b/src/init/setup/SetupHttpHandler.ts
index ffb3a1c79..ebc7730d9 100644
--- a/src/init/setup/SetupHttpHandler.ts
+++ b/src/init/setup/SetupHttpHandler.ts
@@ -1,55 +1,26 @@
 import type { Operation } from '../../http/Operation';
-import type { ErrorHandler } from '../../http/output/error/ErrorHandler';
-import { ResponseDescription } from '../../http/output/response/ResponseDescription';
+import { OkResponseDescription } from '../../http/output/response/OkResponseDescription';
+import type { ResponseDescription } from '../../http/output/response/ResponseDescription';
 import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
-import type { RegistrationParams,
-  RegistrationManager } from '../../identity/interaction/email-password/util/RegistrationManager';
+import type { InteractionHandler } from '../../identity/interaction/InteractionHandler';
 import { getLoggerFor } from '../../logging/LogUtil';
 import type { OperationHttpHandlerInput } from '../../server/OperationHttpHandler';
 import { OperationHttpHandler } from '../../server/OperationHttpHandler';
 import type { RepresentationConverter } from '../../storage/conversion/RepresentationConverter';
 import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
 import { APPLICATION_JSON, TEXT_HTML } from '../../util/ContentTypes';
-import { createErrorMessage } from '../../util/errors/ErrorUtil';
-import { HttpError } from '../../util/errors/HttpError';
-import { InternalServerError } from '../../util/errors/InternalServerError';
 import { MethodNotAllowedHttpError } from '../../util/errors/MethodNotAllowedHttpError';
-import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
-import { addTemplateMetadata } from '../../util/ResourceUtil';
-import { readJsonStream } from '../../util/StreamUtil';
-import type { Initializer } from '../Initializer';
-
-/**
- * Input parameters expected in calls to the handler.
- * Will be sent to the RegistrationManager for validation and registration.
- * The reason this is a flat object and does not have a specific field for all the registration parameters
- * is so we can also support form data.
- */
-export interface SetupInput extends Record<string, any>{
-  /**
-   * Indicates if the initializer should be executed. Ignored if `registration` is true.
-   */
-  initialize?: boolean;
-  /**
-   * Indicates if the registration procedure should be done for IDP registration and/or pod provisioning.
-   */
-  registration?: boolean;
-}
+import type { TemplateEngine } from '../../util/templates/TemplateEngine';
 
 export interface SetupHttpHandlerArgs {
   /**
-   * Used for registering a pod during setup.
+   * Used for converting the input data.
    */
-  registrationManager?: RegistrationManager;
-  /**
-   * Initializer to call in case no registration procedure needs to happen.
-   * This Initializer should make sure the necessary resources are there so the server can work correctly.
-   */
-  initializer?: Initializer;
+  converter: RepresentationConverter;
   /**
-   * Used for content negotiation.
+   * Handles the requests.
    */
-  converter: RepresentationConverter;
+  handler: InteractionHandler;
   /**
    * Key that is used to store the boolean in the storage indicating setup is finished.
    */
@@ -59,17 +30,9 @@ export interface SetupHttpHandlerArgs {
    */
   storage: KeyValueStorage<string, boolean>;
   /**
-   * Template to use for GET requests.
-   */
-  viewTemplate: string;
-  /**
-   * Template to show when setup was completed successfully.
-   */
-  responseTemplate: string;
-  /**
-   * Used for converting output errors.
+   * Renders the main view.
    */
-  errorHandler: ErrorHandler;
+  templateEngine: TemplateEngine;
 }
 
 /**
@@ -78,128 +41,68 @@ export interface SetupHttpHandlerArgs {
  * this to prevent accidentally running unsafe servers.
  *
  * GET requests will return the view template which should contain the setup information for the user.
- * POST requests will run an initializer and/or perform a registration step, both optional.
+ * POST requests will be sent to the InteractionHandler.
  * After successfully completing a POST request this handler will disable itself and become unreachable.
  * All other methods will be rejected.
  */
 export class SetupHttpHandler extends OperationHttpHandler {
   protected readonly logger = getLoggerFor(this);
 
-  private readonly registrationManager?: RegistrationManager;
-  private readonly initializer?: Initializer;
+  private readonly handler: InteractionHandler;
   private readonly converter: RepresentationConverter;
   private readonly storageKey: string;
   private readonly storage: KeyValueStorage<string, boolean>;
-  private readonly viewTemplate: string;
-  private readonly responseTemplate: string;
-  private readonly errorHandler: ErrorHandler;
-
-  private finished: boolean;
+  private readonly templateEngine: TemplateEngine;
 
   public constructor(args: SetupHttpHandlerArgs) {
     super();
-    this.finished = false;
 
-    this.registrationManager = args.registrationManager;
-    this.initializer = args.initializer;
+    this.handler = args.handler;
     this.converter = args.converter;
     this.storageKey = args.storageKey;
     this.storage = args.storage;
-    this.viewTemplate = args.viewTemplate;
-    this.responseTemplate = args.responseTemplate;
-    this.errorHandler = args.errorHandler;
+    this.templateEngine = args.templateEngine;
   }
 
   public async handle({ operation }: OperationHttpHandlerInput): Promise<ResponseDescription> {
-    let json: Record<string, any>;
-    let template: string;
-    let success = false;
-    let statusCode = 200;
-    try {
-      ({ json, template } = await this.getJsonResult(operation));
-      success = true;
-    } catch (err: unknown) {
-      // We want to show the errors on the original page in case of HTML interactions, so we can't just throw them here
-      const error = HttpError.isInstance(err) ? err : new InternalServerError(createErrorMessage(err));
-      ({ statusCode } = error);
-      this.logger.warn(error.message);
-      const response = await this.errorHandler.handleSafe({ error, preferences: { type: { [APPLICATION_JSON]: 1 }}});
-      json = await readJsonStream(response.data!);
-      template = this.viewTemplate;
-    }
-
-    // Convert the response JSON to the required format
-    const representation = new BasicRepresentation(JSON.stringify(json), operation.target, APPLICATION_JSON);
-    addTemplateMetadata(representation.metadata, template, TEXT_HTML);
-    const result = await this.converter.handleSafe(
-      { representation, identifier: operation.target, preferences: operation.preferences },
-    );
-
-    // Make sure this setup handler is never used again after a successful POST request
-    if (success && operation.method === 'POST') {
-      this.finished = true;
-      await this.storage.set(this.storageKey, true);
+    switch (operation.method) {
+      case 'GET': return this.handleGet(operation);
+      case 'POST': return this.handlePost(operation);
+      default: throw new MethodNotAllowedHttpError();
     }
-
-    return new ResponseDescription(statusCode, result.metadata, result.data);
   }
 
   /**
-   * Creates a JSON object representing the result of executing the given operation,
-   * together with the template it should be applied to.
+   * Returns the HTML representation of the setup page.
    */
-  private async getJsonResult(operation: Operation): Promise<{ json: Record<string, any>; template: string }> {
-    if (operation.method === 'GET') {
-      // Return the initial setup page
-      return { json: {}, template: this.viewTemplate };
-    }
-    if (operation.method !== 'POST') {
-      throw new MethodNotAllowedHttpError();
-    }
+  private async handleGet(operation: Operation): Promise<ResponseDescription> {
+    const result = await this.templateEngine.render({});
+    const representation = new BasicRepresentation(result, operation.target, TEXT_HTML);
+    return new OkResponseDescription(representation.metadata, representation.data);
+  }
 
-    // Registration manager expects JSON data
-    let json: SetupInput = {};
-    if (!operation.body.isEmpty) {
+  /**
+   * Converts the input data to JSON and calls the setup handler.
+   * On success `true` will be written to the storage key.
+   */
+  private async handlePost(operation: Operation): Promise<ResponseDescription> {
+    // Convert input data to JSON
+    // Allows us to still support form data
+    if (operation.body.metadata.contentType) {
       const args = {
         representation: operation.body,
         preferences: { type: { [APPLICATION_JSON]: 1 }},
         identifier: operation.target,
       };
-      const converted = await this.converter.handleSafe(args);
-      json = await readJsonStream(converted.data);
-      this.logger.debug(`Input JSON: ${JSON.stringify(json)}`);
-    }
-
-    // We want to initialize after the input has been validated, but before (potentially) writing a pod
-    // since that might overwrite the initializer result
-    if (json.initialize && !json.registration) {
-      if (!this.initializer) {
-        throw new NotImplementedHttpError('This server is not configured with a setup initializer.');
-      }
-      await this.initializer.handleSafe();
-    }
-
-    let output: Record<string, any> = {};
-    // We only call the RegistrationManager when getting registration input.
-    // This way it is also possible to set up a server without requiring registration parameters.
-    let validated: RegistrationParams | undefined;
-    if (json.registration) {
-      if (!this.registrationManager) {
-        throw new NotImplementedHttpError('This server is not configured to support registration during setup.');
-      }
-      // Validate the input JSON
-      validated = this.registrationManager.validateInput(json, true);
-      this.logger.debug(`Validated input: ${JSON.stringify(validated)}`);
-
-      // Register and/or create a pod as requested. Potentially does nothing if all booleans are false.
-      output = await this.registrationManager.register(validated, true);
+      operation = {
+        ...operation,
+        body: await this.converter.handleSafe(args),
+      };
     }
 
-    // Add extra setup metadata
-    output.initialize = Boolean(json.initialize);
-    output.registration = Boolean(json.registration);
-    this.logger.debug(`Output: ${JSON.stringify(output)}`);
+    const representation = await this.handler.handleSafe({ operation });
+    await this.storage.set(this.storageKey, true);
 
-    return { json: output, template: this.responseTemplate };
+    return new OkResponseDescription(representation.metadata, representation.data);
   }
 }
diff --git a/templates/identity/email-password/confirm.html.ejs b/templates/identity/email-password/consent.html.ejs
similarity index 63%
rename from templates/identity/email-password/confirm.html.ejs
rename to templates/identity/email-password/consent.html.ejs
index 01797c1c7..cd3edcbce 100644
--- a/templates/identity/email-password/confirm.html.ejs
+++ b/templates/identity/email-password/consent.html.ejs
@@ -1,9 +1,7 @@
 <h1>Authorize</h1>
 <p>You are authorizing an application to access your Pod.</p>
-<form method="post">
-  <% if (locals.message) { %>
-    <p class="error"><%= message %></p>
-  <% } %>
+<form method="post" id="mainForm">
+  <p class="error" id="error"></p>
 
   <fieldset>
     <ol>
@@ -15,3 +13,7 @@
 
   <p class="actions"><button autofocus type="submit" name="submit">Continue</button></p>
 </form>
+
+<script>
+  addPostListener('mainForm', 'error', '', () => { throw new Error('Expected a location field in the response.') });
+</script>
diff --git a/templates/identity/email-password/forgot-password-response.html.ejs b/templates/identity/email-password/forgot-password-response.html.ejs
deleted file mode 100644
index dddc23bf6..000000000
--- a/templates/identity/email-password/forgot-password-response.html.ejs
+++ /dev/null
@@ -1,13 +0,0 @@
-<h1>Email sent</h1>
-<form method="post">
-  <p>If your account exists, an email has been sent with a link to reset your password.</p>
-  <p>If you do not receive your email in a couple of minutes, check your spam folder or click the link below to send another email.</p>
-
-  <input type="hidden" name="email" value="<%= email %>" />
-
-  <p class="actions"><a href="<%= controls.login %>">Back to Log In</a></p>
-
-  <p class="actions">
-    <button type="submit" name="submit" class="link">Send Another Email</button>
-  </p>
-</form>
diff --git a/templates/identity/email-password/forgot-password.html.ejs b/templates/identity/email-password/forgot-password.html.ejs
index 5ed4412cd..84789e516 100644
--- a/templates/identity/email-password/forgot-password.html.ejs
+++ b/templates/identity/email-password/forgot-password.html.ejs
@@ -1,19 +1,45 @@
-<h1>Forgot password</h1>
-<form method="post">
-  <% if (locals.message) { %>
-    <p class="error"><%= message %></p>
-  <% } %>
+<div id="input-partial">
+  <h1>Forgot password</h1>
+  <form method="post" id="mainForm">
+    <p class="error" id="error"></p>
 
-  <fieldset>
-    <ol>
-      <li>
-        <label for="email">Email</label>
-        <input id="email" type="email" name="email" autofocus>
-      </li>
-    </ol>
-  </fieldset>
+    <fieldset>
+      <ol>
+        <li>
+          <label for="input-email">Email</label>
+          <input id="input-email" type="email" name="email" autofocus>
+        </li>
+      </ol>
+    </fieldset>
 
-  <p class="actions"><button type="submit" name="submit">Send recovery email</button></p>
+    <p class="actions"><button type="submit" name="submit">Send recovery email</button></p>
 
-  <p class="actions"><a href="<%= controls.login %>" class="link">Log in</a></p>
-</form>
+    <p class="actions"><a id="input-login-link" href="" class="link">Log in</a></p>
+  </form>
+</div>
+<div id="response-partial">
+  <h1>Email sent</h1>
+  <p>If your account exists, an email has been sent with a link to reset your password.</p>
+  <p>If you do not receive your email in a couple of minutes, check your spam folder or try sending another email.</p>
+
+  <ul class="actions">
+    <li><a id="response-login-link" href="" class="link">Back to Log In</a></li>
+    <li><a id="response-forgot-link"  href="" class="link">Back to Forgot Password</a></li>
+  </ul>
+</div>
+
+<script>
+  addControlLinks('<%= idpIndex %>', {
+    'input-login-link': 'login',
+    'response-login-link': 'login',
+    'response-forgot-link': 'forgotPassword'
+  });
+
+  setVisibility('response-partial', false);
+  function updateResponse() {
+    // Swap visibility
+    setVisibility('input-partial', false);
+    setVisibility('response-partial', true);
+  }
+  addPostListener('mainForm', 'error', '', updateResponse);
+</script>
diff --git a/templates/identity/email-password/login.html.ejs b/templates/identity/email-password/login.html.ejs
index 1d28d70a4..e527daa42 100644
--- a/templates/identity/email-password/login.html.ejs
+++ b/templates/identity/email-password/login.html.ejs
@@ -1,32 +1,56 @@
-<h1>Log in</h1>
-<form method="post">
-  <% prefilled = locals.prefilled || {}; %>
+<div id="authenticating">
+  <h1>Log in</h1>
+  <form method="post" id="mainForm">
+    <p class="error" id="error"></p>
 
-  <% if (locals.message) { %>
-    <p class="error"><%= message %></p>
-  <% } %>
+    <fieldset>
+      <legend>Your account</legend>
+      <ol>
+        <li>
+          <label for="email">Email</label>
+          <input id="email" type="email" name="email" autofocus>
+        </li>
+        <li>
+          <label for="password">Password</label>
+          <input id="password" type="password" name="password">
+        </li>
+        <li class="checkbox">
+          <label><input type="checkbox" name="remember" value="yes" checked>Stay logged in</label>
+        </li>
+      </ol>
+    </fieldset>
 
-  <fieldset>
-    <legend>Your account</legend>
-    <ol>
-      <li>
-        <label for="email">Email</label>
-        <input id="email" type="email" name="email" autofocus value="<%= prefilled.email || '' %>">
-      </li>
-      <li>
-        <label for="password">Password</label>
-        <input id="password" type="password" name="password">
-      </li>
-      <li class="checkbox">
-        <label><input type="checkbox" name="remember" value="yes" checked>Stay logged in</label>
-      </li>
-    </ol>
-  </fieldset>
+    <p class="actions"><button type="submit" name="submit">Log in</button></p>
 
-  <p class="actions"><button type="submit" name="submit">Log in</button></p>
+    <ul class="actions">
+      <li><a id="register-link" href="" class="link">Sign up</a></li>
+      <li><a id="forgot-link"  href="" class="link">Forgot password</a></li>
+    </ul>
+  </form>
+</div>
+<div id="not-authenticating">
+  <h1>Please log in through an app</h1>
+  <p><strong>To log in and access documents, you need to use a Solid app.</strong></p>
+  <p>This server provides secure storage, but it is not a client app.</p>
+  <p>
+    Choose one of the
+    <a href="https://solidproject.org/apps" class="link">Solid apps</a>
+    to log in and browse Pods.
+  </p>
+  <p>
+    If you're developing an app yourself,
+    use a library such as
+    <a href="https://github.com/inrupt/solid-client-authn-js" class="link"><code>solid-client-authn-js</code></a>
+    to initiate an OIDC authentication flow.
+  </p>
+</div>
 
-  <ul class="actions">
-    <li><a href="<%= controls.register %>" class="link">Sign up</a></li>
-    <li><a href="<%= controls.forgotPassword %>" class="link">Forgot password</a></li>
-  </ul>
-</form>
+
+<script>
+  setVisibility('authenticating', <%= Boolean(authenticating) %>);
+  setVisibility('not-authenticating', <%= !Boolean(authenticating) %>);
+
+  addPostListener('mainForm', 'error', '', () => { throw new Error('Expected a location field in the response.') });
+
+  addControlLinks('<%= idpIndex %>', { 'register-link': 'register', 'forgot-link': 'forgotPassword'});
+</script>
diff --git a/templates/identity/email-password/register-partial.html.ejs b/templates/identity/email-password/register-partial.html.ejs
index dfe67bea5..815bb3f08 100644
--- a/templates/identity/email-password/register-partial.html.ejs
+++ b/templates/identity/email-password/register-partial.html.ejs
@@ -165,32 +165,8 @@
     }
   }
 
-  // Checks whether the given element is visible
-  function isVisible(element) {
-    return !(elements[element] ?? element).classList.contains('hidden');
-  }
-
-  // Sets the visibility of the given element
-  function setVisibility(element, visible) {
-    // Show or hide the element
-    element = elements[element] ?? element;
-    element.classList[visible ? 'remove' : 'add']('hidden');
-
-    // Disable children of hidden elements,
-    // such that the browser does not expect input for them
-    for (const child of getDescendants(element)) {
-      if ('disabled' in child)
-        child.disabled = !visible;
-    }
-  }
-
-  // Obtains all children, grandchildren, etc. of the given element
-  function getDescendants(element) {
-    return [...element.querySelectorAll("*")];
-  }
-
   // Prepare the form when the DOM is ready
-  window.addEventListener('DOMContentLoaded', (event) => {
+  addEventListener('DOMContentLoaded', (event) => {
     synchronizeInputFields();
     elements.mainForm.classList.add('loaded');
   });
diff --git a/templates/identity/email-password/register-response-partial.html.ejs b/templates/identity/email-password/register-response-partial.html.ejs
index 0c141edc9..4a94e76bc 100644
--- a/templates/identity/email-password/register-response-partial.html.ejs
+++ b/templates/identity/email-password/register-response-partial.html.ejs
@@ -1,38 +1,54 @@
-<% if (createPod) { %>
+<div id="response-createPod">
   <h2>Your new Pod</h2>
   <p>
-    Your new Pod is located at <a href="<%= podBaseUrl %>" class="link"><%= podBaseUrl %></a>.
+    Your new Pod is located at <a id="response-podBaseUrl" href="" class="link"></a>.
     <br>
     You can store your documents and data there.
   </p>
-<% } %>
+</div>
 
-<% if (createWebId) { %>
+<div id="response-createWebId">
   <h2>Your new WebID</h2>
   <p>
-    Your new WebID is <a href="<%= webId %>" class="link"><%= webId %></a>.
+    Your new WebID is <a id="response-createdWebId" href="" class="link"></a>.
     <br>
     You can use this identifier to interact with Solid pods and apps.
   </p>
-<% } %>
+</div>
 
-<% if (register) { %>
+<div id="response-register">
   <h2>Your new account</h2>
   <p>
-    Via your email address <em><%= email %></em>,
-    <% if (authenticating) { %>
-      you can now <a href="<%= controls.login %>">log in</a>
-    <% } else { %>
-      this server lets you log in to Solid apps
-    <% } %>
-    with your WebID <a href="<%= webId %>" class="link"><%= webId %></a>
+    Via your email address <em id="response-email"></em>,
+    this server lets you log in to Solid apps
+    with your WebID <a id="response-registeredWebId" href="" class="link"></a>
   </p>
-  <% if (!createWebId) { %>
+  <div id="response-registerWebId">
     <p>
       You will need to add the triple
-      <code><%= `<${webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${oidcIssuer}>.`%></code>
-      to your existing WebID document <em><%= webId %></em>
+      <code id="response-oidcIssuerTriple"></code>
+      to your existing WebID document <em id="response-existingWebId"></em>
       to indicate that you trust this server as a login provider.
     </p>
-  <% } %>
-<% } %>
+  </div>
+  <p>
+    You can now <a id="response-login-link" href="">log in</a>.
+  </p>
+</div>
+
+<script>
+  function updateResponseFields(json) {
+    updateElement('response-podBaseUrl', json.podBaseUrl, { innerText: true, href: true });
+    updateElement('response-createdWebId', json.webId, { innerText: true, href: true });
+    updateElement('response-registeredWebId', json.webId, { innerText: true, href: true });
+    const triple = `<${json.webId}> <http://www.w3.org/ns/solid/terms#oidcIssuer> <${json.oidcIssuer}>.`;
+    updateElement('response-oidcIssuerTriple', triple, { innerText: true });
+    updateElement('response-existingWebId', json.webId, { innerText: true });
+    updateElement('response-email', json.email, { innerText: true });
+    setVisibility('response-createPod', json.createPod);
+    setVisibility('response-createWebId', json.createWebId);
+    setVisibility('response-registerWebId', !json.createWebId);
+    setVisibility('response-register', json.register);
+    updateElement('response-login-link', json.controls.login, { href: true });
+  }
+</script>
diff --git a/templates/identity/email-password/register-response.html.ejs b/templates/identity/email-password/register-response.html.ejs
deleted file mode 100644
index bb0689c8d..000000000
--- a/templates/identity/email-password/register-response.html.ejs
+++ /dev/null
@@ -1,7 +0,0 @@
-<h1>You've been signed up</h1>
-<p>
-  <strong>Welcome to Solid.</strong>
-  We wish you an exciting experience!
-</p>
-
-<%- include('./register-response-partial.html.ejs') %>
diff --git a/templates/identity/email-password/register.html.ejs b/templates/identity/email-password/register.html.ejs
index f1d209bdc..40944d84b 100644
--- a/templates/identity/email-password/register.html.ejs
+++ b/templates/identity/email-password/register.html.ejs
@@ -1,11 +1,31 @@
-<h1>Sign up</h1>
-<form method="post" id="mainForm">
+<div id="input-partial">
+  <h1>Sign up</h1>
+  <form method="post" id="mainForm">
+    <p class="error" id="error"></p>
 
-  <% if (locals.message) { %>
-    <p class="error">Error: <%= message %></p>
-  <% } %>
+    <%- include('./register-partial.html.ejs', { allowRoot: false }) %>
 
-  <%- include('./register-partial.html.ejs', { allowRoot: false }) %>
+    <p class="actions"><button type="submit" name="submit">Sign up</button></p>
+  </form>
+</div>
+<div id="response-partial">
+  <h1>You've been signed up</h1>
+  <p>
+    <strong>Welcome to Solid.</strong>
+    We wish you an exciting experience!
+  </p>
 
-  <p class="actions"><button type="submit" name="submit">Sign up</button></p>
-</form>
+  <%- include('./register-response-partial.html.ejs') %>
+</div>
+
+<script>
+  setVisibility('response-partial', false);
+  function updateResponse(json) {
+    // Swap visibility
+    setVisibility('input-partial', false);
+    setVisibility('response-partial', true);
+
+    updateResponseFields(json);
+  }
+  addPostListener('mainForm', 'error', '', updateResponse);
+</script>
diff --git a/templates/identity/email-password/reset-password-response.html.ejs b/templates/identity/email-password/reset-password-response.html.ejs
deleted file mode 100644
index 4c7169c58..000000000
--- a/templates/identity/email-password/reset-password-response.html.ejs
+++ /dev/null
@@ -1,2 +0,0 @@
-<h1>Password reset</h1>
-<p>Your password was successfully reset.</p>
diff --git a/templates/identity/email-password/reset-password.html.ejs b/templates/identity/email-password/reset-password.html.ejs
index a5c605eb8..9f8837250 100644
--- a/templates/identity/email-password/reset-password.html.ejs
+++ b/templates/identity/email-password/reset-password.html.ejs
@@ -1,21 +1,40 @@
-<h1>Reset password</h1>
-<form method="post">
-  <% if (locals.message) { %>
-    <p class="error"><%= message %></p>
-  <% } %>
+<div id="input-partial">
+  <h1>Reset password</h1>
+  <form method="post" id="mainForm">
+    <p class="error" id="error"></p>
 
-  <fieldset>
-    <ol>
-      <li>
-        <label for="password">New password</label>
-        <input id="password" type="password" name="password" placeholder="">
-      </li>
-      <li>
-        <label for="confirmPassword">Confirm new password</label>
-        <input id="confirmPassword" type="password" name="confirmPassword" placeholder="">
-      </li>
-    </ol>
-  </fieldset>
+    <fieldset>
+      <ol>
+        <li>
+          <label for="password">New password</label>
+          <input id="password" type="password" name="password" placeholder="">
+        </li>
+        <li>
+          <label for="confirmPassword">Confirm new password</label>
+          <input id="confirmPassword" type="password" name="confirmPassword" placeholder="">
+        </li>
+      </ol>
+      <input type="hidden" id="recordId" name="recordId" value="">
+    </fieldset>
 
-  <p class="actions"><button type="submit" name="submit">Reset password</button></p>
-</form>
+    <p class="actions"><button type="submit" name="submit">Reset password</button></p>
+  </form>
+</div>
+<div id="response-partial">
+  <h1>Password reset</h1>
+  <p>Your password was successfully reset.</p>
+</div>
+
+<script>
+  const hidden = document.getElementById('recordId');
+  const recordId = new URLSearchParams(location.search).get('rid');
+  hidden.value = recordId;
+
+  setVisibility('response-partial', false);
+  function updateResponse() {
+    // Swap visibility
+    setVisibility('input-partial', false);
+    setVisibility('response-partial', true);
+  }
+  addPostListener('mainForm', 'error', '', updateResponse);
+</script>
diff --git a/templates/main.html.ejs b/templates/main.html.ejs
index 0c3dfc1b2..26bd02e3f 100644
--- a/templates/main.html.ejs
+++ b/templates/main.html.ejs
@@ -4,11 +4,12 @@
   <meta charset="utf-8"/>
   <meta name="viewport" content="width=device-width, initial-scale=1"/>
   <title><%= extractTitle(htmlBody) %></title>
-  <link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
+  <link rel="stylesheet" href="/.well-known/css/styles/main.css" type="text/css">
+  <script type="text/javascript" src="/.well-known/css/scripts/util.js"></script>
 </head>
 <body>
   <header>
-    <a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
+    <a href="/"><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
     <h1>Community Solid Server</h1>
   </header>
   <main>
@@ -16,7 +17,7 @@
   </main>
   <footer>
     <p>
-      ©2019–2021 <a href="https://inrupt.com/">Inrupt Inc.</a>
+      ©2019–2022 <a href="https://inrupt.com/">Inrupt Inc.</a>
       and <a href="https://www.imec-int.com/">imec</a>
     </p>
   </footer>
diff --git a/templates/root/prefilled/index.html b/templates/root/prefilled/index.html
index db867df82..1ac5f7cb7 100644
--- a/templates/root/prefilled/index.html
+++ b/templates/root/prefilled/index.html
@@ -4,11 +4,11 @@
   <meta charset="utf-8"/>
   <meta name="viewport" content="width=device-width, initial-scale=1"/>
   <title>Community Solid Server</title>
-  <link rel="stylesheet" href="/.well_known/css/styles/main.css" type="text/css">
+  <link rel="stylesheet" href="/.well-known/css/styles/main.css" type="text/css">
 </head>
 <body>
   <header>
-    <a href="/"><img src="/.well_known/css/images/solid.svg" alt="[Solid logo]" /></a>
+    <a href="/"><img src="/.well-known/css/images/solid.svg" alt="[Solid logo]" /></a>
     <h1>Community Solid Server</h1>
   </header>
   <main>
@@ -58,7 +58,7 @@
   </main>
   <footer>
     <p>
-      ©2019–2021 <a href="https://inrupt.com/">Inrupt Inc.</a>
+      ©2019–2022 <a href="https://inrupt.com/">Inrupt Inc.</a>
       and <a href="https://www.imec-int.com/">imec</a>
     </p>
   </footer>
diff --git a/templates/scripts/util.js b/templates/scripts/util.js
new file mode 100644
index 000000000..f0c8099be
--- /dev/null
+++ b/templates/scripts/util.js
@@ -0,0 +1,142 @@
+/**
+ * Acquires all data from the given form and POSTs it as JSON to the target URL.
+ * In case of failure this function will throw an error.
+ * In case of success a parsed JSON body of the response will be returned,
+ * unless the body contains a `location` field,
+ * in that case the page will be redirected to that location.
+ *
+ * @param formId - ID of the form.
+ * @param target - Target URL to POST to. Defaults to the current URL.
+ * @returns {Promise<unknown>} - The response JSON.
+ */
+async function postJsonForm(formId, target = '') {
+  const form = document.getElementById(formId);
+  const formData = new FormData(form);
+  const res = await fetch(target, {
+    method: 'POST',
+    credentials: 'include',
+    headers: { 'accept': 'application/json', 'content-type': 'application/json' },
+    body: JSON.stringify(Object.fromEntries(formData)),
+  });
+  if (res.status >= 400) {
+    const error = await res.json();
+    throw new Error(`${error.statusCode} - ${error.name}: ${error.message}`)
+  } else if (res.status === 200 || res.status === 201) {
+    const body = await res.json();
+    if (body.location) {
+      location.href = body.location;
+    } else {
+      return body;
+    }
+  }
+}
+
+/**
+ * Redirects the page to the given target with the key/value pairs of the JSON body as query parameters.
+ * Controls will be deleted from the JSON to prevent very large URLs.
+ * `false` values will be deleted to prevent incorrect serializations to "false".
+ * @param json - JSON to convert.
+ * @param target - URL to redirect to.
+ */
+function redirectJsonResponse(json, target) {
+  // These would cause the URL to get very large, can be acquired later if needed
+  delete json.controls;
+
+  // Remove false parameters since these would be converted to "false" strings
+  for (const [key, val] of Object.entries(json)) {
+    if (typeof val === 'boolean' && !val) {
+      delete json[key];
+    }
+  }
+
+  const searchParams = new URLSearchParams(Object.entries(json));
+  location.href = `${target}?${searchParams.toString()}`;
+}
+
+/**
+ * Adds a listener to the given form to catch the form submission and do an API call instead.
+ * In case of an error, the inner text of the given error block will be updated with the message.
+ * In case of success the callback function will be called.
+ *
+ * @param formId - ID of the form.
+ * @param errorId - ID of the error block.
+ * @param apiTarget - Target URL to send the POST request to. Defaults to the current URL.
+ * @param callback - Callback function that will be called with the response JSON.
+ */
+async function addPostListener(formId, errorId, apiTarget, callback) {
+  const form = document.getElementById(formId);
+  const errorBlock = document.getElementById(errorId);
+
+  form.addEventListener('submit', async(event) => {
+    event.preventDefault();
+
+    try {
+      const json = await postJsonForm(formId, apiTarget);
+      callback(json);
+    } catch (error) {
+      errorBlock.innerText = error.message;
+    }
+  });
+}
+
+/**
+ * Updates links on a page based on the controls received from the API.
+ * @param url - API URL that will return the controls
+ * @param controlMap - Key/value map with keys being element IDs and values being the control field names.
+ */
+async function addControlLinks(url, controlMap) {
+  const json = await fetchJson(url);
+  for (let [ id, control ] of Object.entries(controlMap)) {
+    updateElement(id, json.controls[control], { href: true });
+  }
+}
+
+/**
+ * Shows or hides the given element.
+ * @param id - ID of the element.
+ * @param visible - If it should be visible.
+ */
+function setVisibility(id, visible) {
+  const element = document.getElementById(id);
+  element.classList[visible ? 'remove' : 'add']('hidden');
+  // Disable children of hidden elements,
+  // such that the browser does not expect input for them
+  for (const child of getDescendants(element)) {
+    if ('disabled' in child)
+      child.disabled = !visible;
+  }
+}
+
+/**
+ * Obtains all children, grandchildren, etc. of the given element.
+ * @param element - Element to get all descendants from.
+ */
+function getDescendants(element) {
+  return [...element.querySelectorAll("*")];
+}
+
+/**
+ * Updates the inner text and href field of an element.
+ * @param id - ID of the element.
+ * @param text - Text to put in the field(s).
+ * @param options - Indicates which fields should be updated.
+ *                  Keys should be `innerText` and/or `href`, values should be booleans.
+ */
+function updateElement(id, text, options) {
+  const element = document.getElementById(id);
+  if (options.innerText) {
+    element.innerText = text;
+  }
+  if (options.href) {
+    element.href = text;
+  }
+}
+
+/**
+ * Fetches JSON from the url and converts it to an object.
+ * @param url - URL to fetch JSON from.
+ */
+async function fetchJson(url) {
+  const res = await fetch(url, { headers: { accept: 'application/json' } });
+  return res.json();
+}
diff --git a/templates/setup/index.html.ejs b/templates/setup/index.html.ejs
index acdb998b8..b5b9923fb 100644
--- a/templates/setup/index.html.ejs
+++ b/templates/setup/index.html.ejs
@@ -1,72 +1,45 @@
-<h1>Set up your Solid server</h1>
-<p>
-  Your Solid server needs a <strong>one-time setup</strong>
-  so it acts exactly the way you want.
-</p>
+<div id="input-partial">
+  <%- include('./input-partial.html.ejs') %>
+</div>
+<div id="response-partial">
+  <h1 id="public">Server setup complete</h1>
+  <p>
+    Congratulations!
+    Your Solid server is now ready to use.
+    <br>
+    You can now visit its <a href="./">homepage</a>.
+  </p>
 
-<form method="post" id="mainForm">
-  <% const safePrefilled = locals.prefilled || {}; %>
+  <div id="response-initialize">
+    <h2>Root Pod</h2>
+    <p>
+      <strong>Warning: the root Pod is publicly accessible.</strong>
+      <br>
+      Prevent public write and control access to the root
+      by modifying its <a href=".acl">ACL document</a>.
+    </p>
+  </div>
 
-  <% if (locals.message) { %>
-    <p class="error"><%= message %></p>
-  <% } %>
-  <fieldset>
-    <legend>Accounts on this server</legend>
-    <ol>
-      <li class="checkbox">
-        <label>
-          <input type="checkbox" checked disabled>
-          Enable account registration.
-        </label>
-        <p>
-          You can disable account registration
-          by <a href="https://github.com/solid/community-server/blob/main/config/identity/README.md">changing the configuration</a>.
-        </p>
-      </li>
-      <li class="checkbox">
-        <label>
-          <input type="checkbox" id="registration" name="registration">
-          Sign me up for an account.
-        </label>
-        <p>
-          Any existing root Pod will be disabled.
-        </p>
-      </li>
-      <li class="checkbox" id="initializeForm">
-        <label>
-          <input type="checkbox" id="initialize" name="initialize">
-          Expose a public root Pod.
-        </label>
-        <p>
-          By default, the public has read and write access to the root Pod.
-          <br>
-          You typically only want to choose this
-          for rapid testing and development.
-        </p>
-      </li>
-    </ol>
-  </fieldset>
+  <div id="response-registration">
+    <%- include('../identity/email-password/register-response-partial.html.ejs', { idpIndex: '' }) %>
+  </div>
+</div>
 
-  <fieldset id="registrationForm">
-    <legend>Sign up</legend>
-    <%-
-      include('../identity/email-password/register-partial.html.ejs', {
-        allowRoot: true,
-      })
-    %>
-  </fieldset>
+<script>
+  function updateResponse(json) {
+    // Swap visibility
+    setVisibility('input-partial', false);
+    setVisibility('response-partial', true);
 
-  <p class="actions"><button type="submit">Complete setup</button></p>
-</form>
+    setVisibility('response-initialize', json.initialize);
 
-<!-- Show or hide the account creation form when needed -->
-<script>
-  [
-    'registration', 'registrationForm', 'initializeForm',
-  ].forEach(registerElement);
+    setVisibility('response-registration', json.registration);
+    if (json.registration) {
+      updateResponseFields(json);
+    }
+  }
+
+  setVisibility('response-partial', false);
 
-  Object.assign(visibilityConditions, {
-    registrationForm: () => elements.registration.checked,
-    initializeForm: () => !elements.registration.checked,
-  });
+  addPostListener('mainForm', 'error', '', updateResponse);
 </script>
diff --git a/templates/setup/input-partial.html.ejs b/templates/setup/input-partial.html.ejs
new file mode 100644
index 000000000..bf9eb785f
--- /dev/null
+++ b/templates/setup/input-partial.html.ejs
@@ -0,0 +1,69 @@
+<h1>Set up your Solid server</h1>
+<p>
+  Your Solid server needs a <strong>one-time setup</strong>
+  so it acts exactly the way you want.
+</p>
+
+<form method="post" id="mainForm">
+  <p class="error" id="error"></p>
+
+  <fieldset>
+    <legend>Accounts on this server</legend>
+    <ol>
+      <li class="checkbox">
+        <label>
+          <input type="checkbox" checked disabled>
+          Enable account registration.
+        </label>
+        <p>
+          You can disable account registration
+          by <a href="https://github.com/solid/community-server/blob/main/config/identity/README.md">changing the configuration</a>.
+        </p>
+      </li>
+      <li class="checkbox">
+        <label>
+          <input type="checkbox" id="registration" name="registration">
+          Sign me up for an account.
+        </label>
+        <p>
+          Any existing root Pod will be disabled.
+        </p>
+      </li>
+      <li class="checkbox" id="initializeForm">
+        <label>
+          <input type="checkbox" id="initialize" name="initialize">
+          Expose a public root Pod.
+        </label>
+        <p>
+          By default, the public has read and write access to the root Pod.
+          <br>
+          You typically only want to choose this
+          for rapid testing and development.
+        </p>
+      </li>
+    </ol>
+  </fieldset>
+
+  <fieldset id="registrationForm">
+    <legend>Sign up</legend>
+    <%-
+    include('../identity/email-password/register-partial.html.ejs', {
+    allowRoot: true,
+    })
+    %>
+  </fieldset>
+
+  <p class="actions"><button type="submit">Complete setup</button></p>
+</form>
+
+<!-- Show or hide the account creation form when needed -->
+<script>
+  [
+    'registration', 'registrationForm', 'initializeForm',
+  ].forEach(registerElement);
+
+  Object.assign(visibilityConditions, {
+    registrationForm: () => elements.registration.checked,
+    initializeForm: () => !elements.registration.checked,
+  });
+</script>
diff --git a/templates/setup/response.html.ejs b/templates/setup/response.html.ejs
deleted file mode 100644
index c416ef82e..000000000
--- a/templates/setup/response.html.ejs
+++ /dev/null
@@ -1,21 +0,0 @@
-<h1 id="public">Server setup complete</h1>
-<p>
-  Congratulations!
-  Your Solid server is now ready to use.
-  <br>
-  You can now visit its <a href="./">homepage</a>.
-</p>
-
-<% if (initialize && !registration) { %>
-<h2>Root Pod</h2>
-<p>
-  <strong>Warning: the root Pod is publicly accessible.</strong>
-  <br>
-  Prevent public write and control access to the root
-  by modifying its <a href=".acl">ACL document</a>.
-</p>
-<% } %>
-
-<% if (registration) { %>
-<%- include('../identity/email-password/register-response-partial.html.ejs', { authenticating: false }) %>
-<% } %>
diff --git a/templates/styles/main.css b/templates/styles/main.css
index 830c1f15c..77d8a1bf2 100644
--- a/templates/styles/main.css
+++ b/templates/styles/main.css
@@ -233,11 +233,18 @@ form ul.actions > li {
   margin-right: 1em;
 }
 
+/* Directly hide hidden elements. */
+.hidden {
+  display: none;
+}
+
+/* Hide form elements with a sliding animation so users can track more easily what is happening. */
 form.loaded * {
   max-height: 1000px;
   transition: max-height .2s;
 }
 form .hidden {
+  display: block;
   max-height: 0;
   overflow: hidden;
 }


Test patch:
diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts
index 36468643c..df242aae0 100644
--- a/test/integration/Identity.test.ts
+++ b/test/integration/Identity.test.ts
@@ -30,19 +30,6 @@ async function postForm(url: string, formBody: string): Promise<Response> {
   });
 }
 
-/**
- * Extracts the registration triple from the registration form body.
- */
-function extractRegistrationTriple(body: string, webId: string): string {
-  const error = load(body)('p.error').first().text();
-  const regex = new RegExp(
-    `<${webId}>\\s+<http://www.w3.org/ns/solid/terms#oidcIssuerRegistrationToken>\\s+"[^"]+"\\s*\\.`, 'u',
-  );
-  const match = regex.exec(error);
-  expect(match).toHaveLength(1);
-  return match![0];
-}
-
 // No way around the cookies https://github.com/panva/node-oidc-provider/issues/552 .
 // They will be simulated by storing the values and passing them along.
 // This is why the redirects are handled manually.
@@ -98,7 +85,8 @@ describe('A Solid server with IDP', (): void => {
     it('sends the form once to receive the registration triple.', async(): Promise<void> => {
       const res = await postForm(`${baseUrl}idp/register/`, formBody);
       expect(res.status).toBe(400);
-      registrationTriple = extractRegistrationTriple(await res.text(), webId);
+      const json = await res.json();
+      registrationTriple = json.details.quad;
     });
 
     it('updates the webId with the registration token.', async(): Promise<void> => {
@@ -114,10 +102,11 @@ describe('A Solid server with IDP', (): void => {
     it('sends the form again to successfully register.', async(): Promise<void> => {
       const res = await postForm(`${baseUrl}idp/register/`, formBody);
       expect(res.status).toBe(200);
-      const text = await res.text();
-      expect(text).toMatch(new RegExp(`your.WebID.*${webId}`, 'u'));
-      expect(text).toMatch(new RegExp(`your.email.address.*${email}`, 'u'));
-      expect(text).toMatch(new RegExp(`<code>&lt;${webId}&gt; &lt;http://www.w3.org/ns/solid/terms#oidcIssuer&gt; &lt;${baseUrl}&gt;\\.</code>`, 'mu'));
+      await expect(res.json()).resolves.toEqual(expect.objectContaining({
+        webId,
+        email,
+        oidcIssuer: baseUrl,
+      }));
     });
   });
 
@@ -146,7 +135,8 @@ describe('A Solid server with IDP', (): void => {
 
     it('initializes the session and logs in.', async(): Promise<void> => {
       const url = await state.startSession();
-      await state.parseLoginPage(url);
+      const res = await state.fetchIdp(url);
+      expect(res.status).toBe(200);
       await state.login(url, email, password);
       expect(state.session.info?.webId).toBe(webId);
     });
@@ -171,11 +161,13 @@ describe('A Solid server with IDP', (): void => {
       let res = await state.fetchIdp(url);
       expect(res.status).toBe(200);
 
+      // Will receive confirm screen here instead of login screen
       res = await state.fetchIdp(url, 'POST', '', APPLICATION_X_WWW_FORM_URLENCODED);
-      const nextUrl = res.headers.get('location');
+      const json = await res.json();
+      const nextUrl = json.location;
       expect(typeof nextUrl).toBe('string');
 
-      await state.handleLoginRedirect(nextUrl!);
+      await state.handleLoginRedirect(nextUrl);
       expect(state.session.info?.webId).toBe(webId);
     });
   });
@@ -186,8 +178,8 @@ describe('A Solid server with IDP', (): void => {
     it('sends the corresponding email address through the form to get a mail.', async(): Promise<void> => {
       const res = await postForm(`${baseUrl}idp/forgotpassword/`, stringify({ email }));
       expect(res.status).toBe(200);
-      expect(load(await res.text())('form p').first().text().trim())
-        .toBe('If your account exists, an email has been sent with a link to reset your password.');
+      const json = await res.json();
+      expect(json.email).toBe(email);
 
       const mail = sendMail.mock.calls[0][0];
       expect(mail.to).toBe(email);
@@ -206,15 +198,18 @@ describe('A Solid server with IDP', (): void => {
       // Reset password form has no action causing the current URL to be used
       expect(relative).toBeUndefined();
 
+      // Extract recordId from URL since JS is used to add it
+      const recordId = /\?rid=([^/]+)$/u.exec(nextUrl)?.[1];
+      expect(typeof recordId).toBe('string');
+
       // POST the new password to the same URL
-      const formData = stringify({ password: password2, confirmPassword: password2 });
+      const formData = stringify({ password: password2, confirmPassword: password2, recordId });
       res = await fetch(nextUrl, {
         method: 'POST',
         headers: { 'content-type': APPLICATION_X_WWW_FORM_URLENCODED },
         body: formData,
       });
       expect(res.status).toBe(200);
-      expect(await res.text()).toContain('Your password was successfully reset.');
     });
   });
 
@@ -229,9 +224,10 @@ describe('A Solid server with IDP', (): void => {
     it('can not log in with the old password anymore.', async(): Promise<void> => {
       const url = await state.startSession();
       nextUrl = url;
-      await state.parseLoginPage(url);
+      let res = await state.fetchIdp(url);
+      expect(res.status).toBe(200);
       const formData = stringify({ email, password });
-      const res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED);
+      res = await state.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED);
       expect(res.status).toBe(500);
       expect(await res.text()).toContain('Incorrect password');
     });
@@ -262,7 +258,8 @@ describe('A Solid server with IDP', (): void => {
     it('sends the form once to receive the registration triple.', async(): Promise<void> => {
       const res = await postForm(`${baseUrl}idp/register/`, formBody);
       expect(res.status).toBe(400);
-      registrationTriple = extractRegistrationTriple(await res.text(), webId2);
+      const json = await res.json();
+      registrationTriple = json.details.quad;
     });
 
     it('updates the webId with the registration token.', async(): Promise<void> => {
@@ -278,8 +275,11 @@ describe('A Solid server with IDP', (): void => {
     it('sends the form again to successfully register.', async(): Promise<void> => {
       const res = await postForm(`${baseUrl}idp/register/`, formBody);
       expect(res.status).toBe(200);
-      const text = await res.text();
-      expect(text).toMatch(new RegExp(`Your new Pod.*${baseUrl}${podName}/`, 'u'));
+      await expect(res.json()).resolves.toEqual(expect.objectContaining({
+        email: 'bob@test.email',
+        webId: webId2,
+        podBaseUrl: `${baseUrl}${podName}/`,
+      }));
     });
   });
 
@@ -296,21 +296,21 @@ describe('A Solid server with IDP', (): void => {
     it('sends the form to create the WebID and register.', async(): Promise<void> => {
       const res = await postForm(`${baseUrl}idp/register/`, formBody);
       expect(res.status).toBe(200);
-      const text = await res.text();
-
-      const matchWebId = /Your new WebID is [^>]+>([^<]+)/u.exec(text);
-      expect(matchWebId).toBeDefined();
-      expect(matchWebId).toHaveLength(2);
-      newWebId = matchWebId![1];
-      expect(text).toMatch(new RegExp(`new WebID is.*${newWebId}`, 'u'));
-      expect(text).toMatch(new RegExp(`your email address.*${newMail}`, 'u'));
-      expect(text).toMatch(new RegExp(`Your new Pod.*${baseUrl}${podName}/`, 'u'));
+      const json = await res.json();
+      expect(json).toEqual(expect.objectContaining({
+        webId: expect.any(String),
+        email: newMail,
+        oidcIssuer: baseUrl,
+        podBaseUrl: `${baseUrl}${podName}/`,
+      }));
+      newWebId = json.webId;
     });
 
     it('initializes the session and logs in.', async(): Promise<void> => {
       state = new IdentityTestState(baseUrl, redirectUrl, oidcIssuer);
       const url = await state.startSession();
-      await state.parseLoginPage(url);
+      const res = await state.fetchIdp(url);
+      expect(res.status).toBe(200);
       await state.login(url, newMail, password);
       expect(state.session.info?.webId).toBe(newWebId);
     });
diff --git a/test/integration/IdentityTestState.ts b/test/integration/IdentityTestState.ts
index 28597d006..59816a44a 100644
--- a/test/integration/IdentityTestState.ts
+++ b/test/integration/IdentityTestState.ts
@@ -87,21 +87,21 @@ export class IdentityTestState {
     expect(nextUrl.startsWith(this.oidcIssuer)).toBeTruthy();
 
     // Need to catch the redirect so we can copy the cookies
-    const res = await this.fetchIdp(nextUrl);
+    let res = await this.fetchIdp(nextUrl);
     expect(res.status).toBe(302);
     nextUrl = res.headers.get('location')!;
 
-    return nextUrl;
-  }
-
-  public async parseLoginPage(url: string): Promise<{ register: string; forgotPassword: string }> {
-    const res = await this.fetchIdp(url);
+    // Handle redirect
+    res = await this.fetchIdp(nextUrl);
     expect(res.status).toBe(200);
-    const text = await res.text();
-    const register = this.extractUrl(text, 'a:contains("Sign up")', 'href');
-    const forgotPassword = this.extractUrl(text, 'a:contains("Forgot password")', 'href');
 
-    return { register, forgotPassword };
+    // Need to send request to prompt API to get actual location
+    let json = await res.json();
+    res = await this.fetchIdp(json.controls.prompt);
+    json = await res.json();
+    nextUrl = json.location;
+
+    return nextUrl;
   }
 
   /**
@@ -111,8 +111,9 @@ export class IdentityTestState {
   public async login(url: string, email: string, password: string): Promise<void> {
     const formData = stringify({ email, password });
     const res = await this.fetchIdp(url, 'POST', formData, APPLICATION_X_WWW_FORM_URLENCODED);
-    expect(res.status).toBe(302);
-    const nextUrl = res.headers.get('location')!;
+    expect(res.status).toBe(200);
+    const json = await res.json();
+    const nextUrl = json.location;
 
     return this.handleLoginRedirect(nextUrl);
   }
diff --git a/test/integration/RestrictedIdentity.test.ts b/test/integration/RestrictedIdentity.test.ts
index 9a0db6593..a9fee024f 100644
--- a/test/integration/RestrictedIdentity.test.ts
+++ b/test/integration/RestrictedIdentity.test.ts
@@ -95,12 +95,13 @@ describe('A server with restricted IDP access', (): void => {
     // Logging into session
     const state = new IdentityTestState(baseUrl, 'http://mockedredirect/', baseUrl);
     const url = await state.startSession();
-    await state.parseLoginPage(url);
+    let res = await state.fetchIdp(url);
+    expect(res.status).toBe(200);
     await state.login(url, settings.email, settings.password);
     expect(state.session.info?.webId).toBe(webId);
 
     // Registration still works for this WebID
-    let res = await state.session.fetch(`${baseUrl}idp/register/`);
+    res = await state.session.fetch(`${baseUrl}idp/register/`);
     expect(res.status).toBe(200);
 
     res = await state.session.fetch(`${baseUrl}idp/register/`, {
diff --git a/test/integration/Setup.test.ts b/test/integration/Setup.test.ts
index b4be03ef3..f8976e975 100644
--- a/test/integration/Setup.test.ts
+++ b/test/integration/Setup.test.ts
@@ -33,21 +33,24 @@ describe('A Solid server with setup', (): void => {
   it('catches all requests.', async(): Promise<void> => {
     let res = await fetch(baseUrl, { method: 'GET', headers: { accept: 'text/html' }});
     expect(res.status).toBe(200);
+    expect(res.url).toBe(setupUrl);
     await expect(res.text()).resolves.toContain('Set up your Solid server');
 
     res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'GET', headers: { accept: 'text/html' }});
     expect(res.status).toBe(200);
+    expect(res.url).toBe(setupUrl);
     await expect(res.text()).resolves.toContain('Set up your Solid server');
 
-    res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'PUT', headers: { accept: 'text/html' }});
+    res = await fetch(joinUrl(baseUrl, '/random/path/'), { method: 'PUT' });
     expect(res.status).toBe(405);
-    await expect(res.text()).resolves.toContain('Set up your Solid server');
+    expect(res.url).toBe(setupUrl);
+    await expect(res.json()).resolves.toEqual(expect.objectContaining({ name: 'MethodNotAllowedHttpError' }));
   });
 
   it('can create a server that disables root but allows registration.', async(): Promise<void> => {
-    let res = await fetch(setupUrl, { method: 'POST', headers: { accept: 'text/html' }});
+    let res = await fetch(setupUrl, { method: 'POST' });
     expect(res.status).toBe(200);
-    await expect(res.text()).resolves.toContain('Server setup complete');
+    await expect(res.json()).resolves.toEqual({ initialize: false, registration: false });
 
     // Root access disabled
     res = await fetch(baseUrl);
@@ -57,7 +60,7 @@ describe('A Solid server with setup', (): void => {
     const registerParams = { email, podName, password, confirmPassword: password, createWebId: true };
     res = await fetch(joinUrl(baseUrl, 'idp/register/'), {
       method: 'POST',
-      headers: { accept: 'text/html', 'content-type': 'application/json' },
+      headers: { 'content-type': 'application/json' },
       body: JSON.stringify(registerParams),
     });
     expect(res.status).toBe(200);
@@ -70,11 +73,11 @@ describe('A Solid server with setup', (): void => {
   it('can create a server with a public root.', async(): Promise<void> => {
     let res = await fetch(setupUrl, {
       method: 'POST',
-      headers: { accept: 'text/html', 'content-type': 'application/json' },
+      headers: { 'content-type': 'application/json' },
       body: JSON.stringify({ initialize: true }),
     });
     expect(res.status).toBe(200);
-    await expect(res.text()).resolves.toContain('Server setup complete');
+    await expect(res.json()).resolves.toEqual({ initialize: true, registration: false });
 
     // Root access enabled
     res = await fetch(baseUrl);
@@ -85,7 +88,7 @@ describe('A Solid server with setup', (): void => {
     const registerParams = { email, podName, password, confirmPassword: password, createWebId: true, rootPod: true };
     res = await fetch(joinUrl(baseUrl, 'idp/register/'), {
       method: 'POST',
-      headers: { accept: 'text/html', 'content-type': 'application/json' },
+      headers: { 'content-type': 'application/json' },
       body: JSON.stringify(registerParams),
     });
     expect(res.status).toBe(500);
@@ -95,11 +98,19 @@ describe('A Solid server with setup', (): void => {
     const registerParams = { email, podName, password, confirmPassword: password, createWebId: true, rootPod: true };
     let res = await fetch(setupUrl, {
       method: 'POST',
-      headers: { accept: 'text/html', 'content-type': 'application/json' },
+      headers: { 'content-type': 'application/json' },
       body: JSON.stringify({ registration: true, initialize: true, ...registerParams }),
     });
     expect(res.status).toBe(200);
-    await expect(res.text()).resolves.toContain('Server setup complete');
+    const json = await res.json();
+    expect(json).toEqual(expect.objectContaining({
+      registration: true,
+      initialize: false,
+      oidcIssuer: baseUrl,
+      webId: `${baseUrl}profile/card#me`,
+      email,
+      podBaseUrl: baseUrl,
+    }));
 
     // Root profile created
     res = await fetch(joinUrl(baseUrl, '/profile/card'));
@@ -109,7 +120,7 @@ describe('A Solid server with setup', (): void => {
     // Pod root is not accessible even though initialize was set to true
     res = await fetch(joinUrl(baseUrl, 'resource'), {
       method: 'PUT',
-      headers: { accept: 'text/html', 'content-type': 'text/plain' },
+      headers: { 'content-type': 'text/plain' },
       body: 'random data',
     });
     expect(res.status).toBe(401);
diff --git a/test/unit/identity/ControlHandler.test.ts b/test/unit/identity/ControlHandler.test.ts
new file mode 100644
index 000000000..cafa667f2
--- /dev/null
+++ b/test/unit/identity/ControlHandler.test.ts
@@ -0,0 +1,52 @@
+import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation';
+import { ControlHandler } from '../../../src/identity/interaction/ControlHandler';
+import type { InteractionHandler, InteractionHandlerInput } from '../../../src/identity/interaction/InteractionHandler';
+import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute';
+import { APPLICATION_JSON } from '../../../src/util/ContentTypes';
+import { InternalServerError } from '../../../src/util/errors/InternalServerError';
+import { readJsonStream } from '../../../src/util/StreamUtil';
+
+describe('A ControlHandler', (): void => {
+  const input: InteractionHandlerInput = {} as any;
+  let controls: Record<string, jest.Mocked<InteractionRoute>>;
+  let source: jest.Mocked<InteractionHandler>;
+  let handler: ControlHandler;
+
+  beforeEach(async(): Promise<void> => {
+    controls = {
+      login: { getPath: jest.fn().mockReturnValue('http://example.com/login/') } as any,
+      register: { getPath: jest.fn().mockReturnValue('http://example.com/register/') } as any,
+    };
+
+    source = {
+      canHandle: jest.fn(),
+      handle: jest.fn().mockResolvedValue(new BasicRepresentation(JSON.stringify({ data: 'data' }), APPLICATION_JSON)),
+    } as any;
+
+    handler = new ControlHandler(source, controls);
+  });
+
+  it('can handle any input its source can handle.', async(): Promise<void> => {
+    await expect(handler.canHandle(input)).resolves.toBeUndefined();
+
+    source.canHandle.mockRejectedValueOnce(new Error('bad data'));
+    await expect(handler.canHandle(input)).rejects.toThrow('bad data');
+  });
+
+  it('errors in case its source does not return JSON.', async(): Promise<void> => {
+    source.handle.mockResolvedValueOnce(new BasicRepresentation());
+    await expect(handler.handle(input)).rejects.toThrow(InternalServerError);
+  });
+
+  it('adds controls to the source response.', async(): Promise<void> => {
+    const result = await handler.handle(input);
+    await expect(readJsonStream(result.data)).resolves.toEqual({
+      data: 'data',
+      apiVersion: '0.3',
+      controls: {
+        login: 'http://example.com/login/',
+        register: 'http://example.com/register/',
+      },
+    });
+  });
+});
diff --git a/test/unit/identity/IdentityProviderHttpHandler.test.ts b/test/unit/identity/IdentityProviderHttpHandler.test.ts
index 6e0deb201..9b5b2fdad 100644
--- a/test/unit/identity/IdentityProviderHttpHandler.test.ts
+++ b/test/unit/identity/IdentityProviderHttpHandler.test.ts
@@ -1,14 +1,12 @@
 import type { Provider } from 'oidc-provider';
 import type { Operation } from '../../../src/http/Operation';
-import type { ErrorHandler, ErrorHandlerArgs } from '../../../src/http/output/error/ErrorHandler';
-import type { ResponseDescription } from '../../../src/http/output/response/ResponseDescription';
 import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation';
 import type { Representation } from '../../../src/http/representation/Representation';
 import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata';
 import type { ProviderFactory } from '../../../src/identity/configuration/ProviderFactory';
 import type { IdentityProviderHttpHandlerArgs } from '../../../src/identity/IdentityProviderHttpHandler';
 import { IdentityProviderHttpHandler } from '../../../src/identity/IdentityProviderHttpHandler';
-import type { InteractionRoute } from '../../../src/identity/interaction/routing/InteractionRoute';
+import type { Interaction, InteractionHandler } from '../../../src/identity/interaction/InteractionHandler';
 import type { HttpRequest } from '../../../src/server/HttpRequest';
 import type { HttpResponse } from '../../../src/server/HttpResponse';
 import { getBestPreference } from '../../../src/storage/conversion/ConversionUtil';
@@ -16,25 +14,20 @@ import type {
   RepresentationConverter,
   RepresentationConverterArgs,
 } from '../../../src/storage/conversion/RepresentationConverter';
-import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError';
-import { joinUrl } from '../../../src/util/PathUtil';
-import { guardedStreamFrom, readableToString } from '../../../src/util/StreamUtil';
-import { CONTENT_TYPE, SOLID_META } from '../../../src/util/Vocabularies';
+import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../../src/util/ContentTypes';
+import { CONTENT_TYPE } from '../../../src/util/Vocabularies';
 
 describe('An IdentityProviderHttpHandler', (): void => {
-  const apiVersion = '0.2';
-  const baseUrl = 'http://test.com/';
-  const idpPath = '/idp';
   const request: HttpRequest = {} as any;
   const response: HttpResponse = {} as any;
+  const oidcInteraction: Interaction = {} as any;
   let operation: Operation;
+  let representation: Representation;
   let providerFactory: jest.Mocked<ProviderFactory>;
-  let routes: Record<'response' | 'complete' | 'error', jest.Mocked<InteractionRoute>>;
-  let controls: Record<string, string>;
   let converter: jest.Mocked<RepresentationConverter>;
-  let errorHandler: jest.Mocked<ErrorHandler>;
   let provider: jest.Mocked<Provider>;
-  let handler: IdentityProviderHttpHandler;
+  let handler: jest.Mocked<InteractionHandler>;
+  let idpHandler: IdentityProviderHttpHandler;
 
   beforeEach(async(): Promise<void> => {
     operation = {
@@ -45,44 +38,13 @@ describe('An IdentityProviderHttpHandler', (): void => {
     };
 
     provider = {
-      interactionDetails: jest.fn(),
+      interactionDetails: jest.fn().mockReturnValue(oidcInteraction),
     } as any;
 
     providerFactory = {
       getProvider: jest.fn().mockResolvedValue(provider),
     };
 
-    routes = {
-      response: {
-        getControls: jest.fn().mockReturnValue({ response: '/routeResponse' }),
-        supportsPath: jest.fn((path: string): boolean => /^\/routeResponse$/u.test(path)),
-        handleOperation: jest.fn().mockResolvedValue({
-          type: 'response',
-          details: { key: 'val' },
-          templateFiles: { 'text/html': '/response' },
-        }),
-      },
-      complete: {
-        getControls: jest.fn().mockReturnValue({}),
-        supportsPath: jest.fn((path: string): boolean => /^\/routeComplete$/u.test(path)),
-        handleOperation: jest.fn().mockResolvedValue({
-          type: 'complete',
-          details: { webId: 'webId' },
-          templateFiles: {},
-        }),
-      },
-      error: {
-        getControls: jest.fn().mockReturnValue({}),
-        supportsPath: jest.fn((path: string): boolean => /^\/routeError$/u.test(path)),
-        handleOperation: jest.fn().mockResolvedValue({
-          type: 'error',
-          error: new Error('test error'),
-          templateFiles: { 'text/html': '/response' },
-        }),
-      },
-    };
-    controls = { response: 'http://test.com/idp/routeResponse' };
-
     converter = {
       handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => {
         // Just find the best match;
@@ -92,91 +54,50 @@ describe('An IdentityProviderHttpHandler', (): void => {
       }),
     } as any;
 
-    errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({
-      statusCode: 400,
-      data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`),
-    })) } as any;
+    representation = new BasicRepresentation();
+    handler = {
+      handleSafe: jest.fn().mockResolvedValue(representation),
+    } as any;
 
     const args: IdentityProviderHttpHandlerArgs = {
-      baseUrl,
-      idpPath,
       providerFactory,
-      interactionRoutes: Object.values(routes),
       converter,
-      errorHandler,
+      handler,
     };
-    handler = new IdentityProviderHttpHandler(args);
-  });
-
-  it('throws a 404 if there is no matching route.', async(): Promise<void> => {
-    operation.target.path = joinUrl(baseUrl, 'invalid');
-    await expect(handler.handle({ request, response, operation })).rejects.toThrow(NotFoundHttpError);
+    idpHandler = new IdentityProviderHttpHandler(args);
   });
 
-  it('creates Representations for InteractionResponseResults.', async(): Promise<void> => {
-    operation.target.path = joinUrl(baseUrl, '/idp/routeResponse');
-    operation.method = 'POST';
-    operation.body = new BasicRepresentation('value', 'text/plain');
-    const result = (await handler.handle({ request, response, operation }))!;
-    expect(result).toBeDefined();
-    expect(routes.response.handleOperation).toHaveBeenCalledTimes(1);
-    expect(routes.response.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
-    expect(operation.body?.metadata.contentType).toBe('application/json');
-
-    expect(JSON.parse(await readableToString(result.data!)))
-      .toEqual({ apiVersion, key: 'val', authenticating: false, controls });
+  it('returns the handler result as 200 response.', async(): Promise<void> => {
+    const result = await idpHandler.handle({ operation, request, response });
     expect(result.statusCode).toBe(200);
-    expect(result.metadata?.contentType).toBe('text/html');
-    expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response');
+    expect(result.data).toBe(representation.data);
+    expect(result.metadata).toBe(representation.metadata);
+    expect(handler.handleSafe).toHaveBeenCalledTimes(1);
+    expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation, oidcInteraction });
   });
 
-  it('creates Representations for InteractionErrorResults.', async(): Promise<void> => {
-    operation.target.path = joinUrl(baseUrl, '/idp/routeError');
-    operation.method = 'POST';
-    operation.preferences = { type: { 'text/html': 1 }};
-
-    const result = (await handler.handle({ request, response, operation }))!;
-    expect(result).toBeDefined();
-    expect(routes.error.handleOperation).toHaveBeenCalledTimes(1);
-    expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
-
-    expect(JSON.parse(await readableToString(result.data!)))
-      .toEqual({ apiVersion, name: 'Error', message: 'test error', authenticating: false, controls });
-    expect(result.statusCode).toBe(400);
-    expect(result.metadata?.contentType).toBe('text/html');
-    expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response');
+  it('passes no interaction if the provider call failed.', async(): Promise<void> => {
+    provider.interactionDetails.mockRejectedValueOnce(new Error('no interaction'));
+    const result = await idpHandler.handle({ operation, request, response });
+    expect(result.statusCode).toBe(200);
+    expect(result.data).toBe(representation.data);
+    expect(result.metadata).toBe(representation.metadata);
+    expect(handler.handleSafe).toHaveBeenCalledTimes(1);
+    expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation });
   });
 
-  it('adds a prefilled field in case error requests had a body.', async(): Promise<void> => {
-    operation.target.path = joinUrl(baseUrl, '/idp/routeError');
-    operation.method = 'POST';
-    operation.preferences = { type: { 'text/html': 1 }};
-    operation.body = new BasicRepresentation('{ "key": "val" }', 'application/json');
-
-    const result = (await handler.handle({ request, response, operation }))!;
-    expect(result).toBeDefined();
-    expect(routes.error.handleOperation).toHaveBeenCalledTimes(1);
-    expect(routes.error.handleOperation).toHaveBeenLastCalledWith(operation, undefined);
-    expect(operation.body?.metadata.contentType).toBe('application/json');
-
-    expect(JSON.parse(await readableToString(result.data!))).toEqual(
-      { apiVersion, name: 'Error', message: 'test error', authenticating: false, controls, prefilled: { key: 'val' }},
+  it('converts input bodies to JSON.', async(): Promise<void> => {
+    operation.body.metadata.contentType = APPLICATION_X_WWW_FORM_URLENCODED;
+    const result = await idpHandler.handle({ operation, request, response });
+    expect(result.statusCode).toBe(200);
+    expect(result.data).toBe(representation.data);
+    expect(result.metadata).toBe(representation.metadata);
+    expect(handler.handleSafe).toHaveBeenCalledTimes(1);
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const { body, ...partialOperation } = operation;
+    expect(handler.handleSafe).toHaveBeenLastCalledWith(
+      { operation: expect.objectContaining(partialOperation), oidcInteraction },
     );
-    expect(result.statusCode).toBe(400);
-    expect(result.metadata?.contentType).toBe('text/html');
-    expect(result.metadata?.get(SOLID_META.template)?.value).toBe('/response');
-  });
-
-  it('indicates to the templates if the request is part of an auth flow.', async(): Promise<void> => {
-    operation.target.path = joinUrl(baseUrl, '/idp/routeResponse');
-    operation.method = 'POST';
-    const oidcInteraction = { session: { accountId: 'account' }, prompt: {}} as any;
-    provider.interactionDetails.mockResolvedValueOnce(oidcInteraction);
-    routes.response.handleOperation
-      .mockResolvedValueOnce({ type: 'response', templateFiles: { 'text/html': '/response' }});
-
-    const result = (await handler.handle({ request, response, operation }))!;
-    expect(result).toBeDefined();
-    expect(JSON.parse(await readableToString(result.data!))).toEqual({ apiVersion, authenticating: true, controls });
+    expect(handler.handleSafe.mock.calls[0][0].operation.body.metadata.contentType).toBe(APPLICATION_JSON);
   });
 });
diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts
index e506b7272..bffb4787e 100644
--- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts
+++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts
@@ -1,10 +1,13 @@
-import type { Configuration } from 'oidc-provider';
+import type { Configuration, KoaContextWithOIDC } from 'oidc-provider';
 import type { ErrorHandler } from '../../../../src/http/output/error/ErrorHandler';
 import type { ResponseWriter } from '../../../../src/http/output/ResponseWriter';
+import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
 import { IdentityProviderFactory } from '../../../../src/identity/configuration/IdentityProviderFactory';
+import type { Interaction, InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler';
 import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory';
 import type { HttpResponse } from '../../../../src/server/HttpResponse';
 import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
+import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
 
 /* eslint-disable @typescript-eslint/naming-convention */
 jest.mock('oidc-provider', (): any => ({
@@ -28,10 +31,13 @@ const routes = {
 
 describe('An IdentityProviderFactory', (): void => {
   let baseConfig: Configuration;
-  const baseUrl = 'http://test.com/foo/';
+  const baseUrl = 'http://example.com/foo/';
   const oidcPath = '/oidc';
-  const idpPath = '/idp';
-  const webId = 'http://alice.test.com/card#me';
+  const webId = 'http://alice.example.com/card#me';
+  const redirectUrl = 'http://example.com/login/';
+  const oidcInteraction: Interaction = {} as any;
+  let ctx: KoaContextWithOIDC;
+  let interactionHandler: jest.Mocked<InteractionHandler>;
   let adapterFactory: jest.Mocked<AdapterFactory>;
   let storage: jest.Mocked<KeyValueStorage<string, any>>;
   let errorHandler: jest.Mocked<ErrorHandler>;
@@ -41,6 +47,17 @@ describe('An IdentityProviderFactory', (): void => {
   beforeEach(async(): Promise<void> => {
     baseConfig = { claims: { webid: [ 'webid', 'client_webid' ]}};
 
+    ctx = {
+      method: 'GET',
+      request: {
+        href: 'http://example.com/idp/',
+      },
+    } as any;
+
+    interactionHandler = {
+      handleSafe: jest.fn().mockRejectedValue(new FoundHttpError(redirectUrl)),
+    } as any;
+
     adapterFactory = {
       createStorageAdapter: jest.fn().mockReturnValue('adapter!'),
     };
@@ -61,25 +78,13 @@ describe('An IdentityProviderFactory', (): void => {
       adapterFactory,
       baseUrl,
       oidcPath,
-      idpPath,
+      interactionHandler,
       storage,
       errorHandler,
       responseWriter,
     });
   });
 
-  it('errors if the idpPath parameter does not start with a slash.', async(): Promise<void> => {
-    expect((): any => new IdentityProviderFactory(baseConfig, {
-      adapterFactory,
-      baseUrl,
-      oidcPath,
-      idpPath: 'idp',
-      storage,
-      errorHandler,
-      responseWriter,
-    })).toThrow('idpPath needs to start with a /');
-  });
-
   it('creates a correct configuration.', async(): Promise<void> => {
     // This is the output of our mock function
     const provider = await factory.getProvider() as any;
@@ -98,7 +103,7 @@ describe('An IdentityProviderFactory', (): void => {
     expect(config.jwks).toEqual({ keys: [ expect.objectContaining({ kty: 'RSA' }) ]});
     expect(config.routes).toEqual(routes);
 
-    expect((config.interactions?.url as any)()).toBe('/idp/');
+    await expect((config.interactions?.url as any)(ctx, oidcInteraction)).resolves.toBe(redirectUrl);
     expect((config.audiences as any)(null, null, {}, 'access_token')).toBe('solid');
     expect((config.audiences as any)(null, null, { clientId: 'clientId' }, 'client_credentials')).toBe('clientId');
 
@@ -123,6 +128,17 @@ describe('An IdentityProviderFactory', (): void => {
     expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response, result: { statusCode: 500 }});
   });
 
+  it('errors if there is no valid interaction redirect.', async(): Promise<void> => {
+    interactionHandler.handleSafe.mockRejectedValueOnce(new Error('bad data'));
+    const provider = await factory.getProvider() as any;
+    const { config } = provider as { config: Configuration };
+    await expect((config.interactions?.url as any)(ctx, oidcInteraction)).rejects.toThrow('bad data');
+
+    interactionHandler.handleSafe.mockResolvedValueOnce(new BasicRepresentation());
+    await expect((config.interactions?.url as any)(ctx, oidcInteraction))
+      .rejects.toThrow('Could not correctly redirect for the given interaction.');
+  });
+
   it('copies a field from the input config if values need to be added to it.', async(): Promise<void> => {
     baseConfig.cookies = {
       long: { signed: true },
@@ -131,7 +147,7 @@ describe('An IdentityProviderFactory', (): void => {
       adapterFactory,
       baseUrl,
       oidcPath,
-      idpPath,
+      interactionHandler,
       storage,
       errorHandler,
       responseWriter,
@@ -153,7 +169,7 @@ describe('An IdentityProviderFactory', (): void => {
       adapterFactory,
       baseUrl,
       oidcPath,
-      idpPath,
+      interactionHandler,
       storage,
       errorHandler,
       responseWriter,
diff --git a/test/unit/identity/interaction/BaseInteractionHandler.test.ts b/test/unit/identity/interaction/BaseInteractionHandler.test.ts
new file mode 100644
index 000000000..4736ee2d0
--- /dev/null
+++ b/test/unit/identity/interaction/BaseInteractionHandler.test.ts
@@ -0,0 +1,70 @@
+import type { Operation } from '../../../../src/http/Operation';
+import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
+import type { Representation } from '../../../../src/http/representation/Representation';
+import { BaseInteractionHandler } from '../../../../src/identity/interaction/BaseInteractionHandler';
+import type { InteractionHandlerInput } from '../../../../src/identity/interaction/InteractionHandler';
+import { APPLICATION_JSON } from '../../../../src/util/ContentTypes';
+import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError';
+import { readJsonStream } from '../../../../src/util/StreamUtil';
+
+class DummyBaseInteractionHandler extends BaseInteractionHandler {
+  public constructor() {
+    super({ view: 'view' });
+  }
+
+  public async handlePost(input: InteractionHandlerInput): Promise<Representation> {
+    return new BasicRepresentation(JSON.stringify({ data: 'data' }), input.operation.target, APPLICATION_JSON);
+  }
+}
+
+describe('A BaseInteractionHandler', (): void => {
+  const handler = new DummyBaseInteractionHandler();
+
+  it('can only handle GET and POST requests.', async(): Promise<void> => {
+    const operation: Operation = {
+      method: 'DELETE',
+      target: { path: 'http://example.com/foo' },
+      body: new BasicRepresentation(),
+      preferences: {},
+    };
+    await expect(handler.canHandle({ operation })).rejects.toThrow(MethodNotAllowedHttpError);
+
+    operation.method = 'GET';
+    await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
+
+    operation.method = 'POST';
+    await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
+  });
+
+  it('returns the view on GET requests.', async(): Promise<void> => {
+    const operation: Operation = {
+      method: 'GET',
+      target: { path: 'http://example.com/foo' },
+      body: new BasicRepresentation(),
+      preferences: {},
+    };
+    const result = await handler.handle({ operation });
+    await expect(readJsonStream(result.data)).resolves.toEqual({ view: 'view' });
+  });
+
+  it('calls the handlePost function on POST requests.', async(): Promise<void> => {
+    const operation: Operation = {
+      method: 'POST',
+      target: { path: 'http://example.com/foo' },
+      body: new BasicRepresentation(),
+      preferences: {},
+    };
+    const result = await handler.handle({ operation });
+    await expect(readJsonStream(result.data)).resolves.toEqual({ data: 'data' });
+  });
+
+  it('rejects other methods.', async(): Promise<void> => {
+    const operation: Operation = {
+      method: 'DELETE',
+      target: { path: 'http://example.com/foo' },
+      body: new BasicRepresentation(),
+      preferences: {},
+    };
+    await expect(handler.handle({ operation })).rejects.toThrow(MethodNotAllowedHttpError);
+  });
+});
diff --git a/test/unit/identity/interaction/CompletingInteractionHandler.test.ts b/test/unit/identity/interaction/CompletingInteractionHandler.test.ts
index 4fbc4cb69..34a221d4f 100644
--- a/test/unit/identity/interaction/CompletingInteractionHandler.test.ts
+++ b/test/unit/identity/interaction/CompletingInteractionHandler.test.ts
@@ -1,7 +1,10 @@
 import type { Operation } from '../../../../src/http/Operation';
 import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
 import { CompletingInteractionHandler } from '../../../../src/identity/interaction/CompletingInteractionHandler';
-import type { Interaction, InteractionHandlerInput } from '../../../../src/identity/interaction/InteractionHandler';
+import type {
+  Interaction,
+  InteractionHandlerInput,
+} from '../../../../src/identity/interaction/InteractionHandler';
 import type {
   InteractionCompleter,
   InteractionCompleterInput,
@@ -11,7 +14,7 @@ import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplemen
 const webId = 'http://alice.test.com/card#me';
 class DummyCompletingInteractionHandler extends CompletingInteractionHandler {
   public constructor(interactionCompleter: InteractionCompleter) {
-    super(interactionCompleter);
+    super({}, interactionCompleter);
   }
 
   public async getCompletionParameters(input: Required<InteractionHandlerInput>): Promise<InteractionCompleterInput> {
@@ -28,7 +31,10 @@ describe('A CompletingInteractionHandler', (): void => {
 
   beforeEach(async(): Promise<void> => {
     const representation = new BasicRepresentation('', 'application/json');
-    operation = { body: representation } as any;
+    operation = {
+      method: 'POST',
+      body: representation,
+    } as any;
 
     interactionCompleter = {
       handleSafe: jest.fn().mockResolvedValue(location),
@@ -39,10 +45,15 @@ describe('A CompletingInteractionHandler', (): void => {
 
   it('calls the parent JSON canHandle check.', async(): Promise<void> => {
     operation.body.metadata.contentType = 'application/x-www-form-urlencoded';
-    await expect(handler.canHandle({ operation } as any)).rejects.toThrow(NotImplementedHttpError);
+    await expect(handler.canHandle({ operation, oidcInteraction } as any)).rejects.toThrow(NotImplementedHttpError);
+  });
+
+  it('can handle GET requests without interaction.', async(): Promise<void> => {
+    operation.method = 'GET';
+    await expect(handler.canHandle({ operation } as any)).resolves.toBeUndefined();
   });
 
-  it('errors if no OidcInteraction is defined.', async(): Promise<void> => {
+  it('errors if no OidcInteraction is defined on POST requests.', async(): Promise<void> => {
     const error = expect.objectContaining({
       statusCode: 400,
       message: 'This action can only be performed as part of an OIDC authentication flow.',
diff --git a/test/unit/identity/interaction/SessionHttpHandler.test.ts b/test/unit/identity/interaction/ExistingLoginHandler.test.ts
similarity index 50%
rename from test/unit/identity/interaction/SessionHttpHandler.test.ts
rename to test/unit/identity/interaction/ExistingLoginHandler.test.ts
index ff6206e2b..c649e0a23 100644
--- a/test/unit/identity/interaction/SessionHttpHandler.test.ts
+++ b/test/unit/identity/interaction/ExistingLoginHandler.test.ts
@@ -1,27 +1,17 @@
-import type { InteractionHandlerInput, Interaction } from '../../../../src/identity/interaction/InteractionHandler';
-import { SessionHttpHandler } from '../../../../src/identity/interaction/SessionHttpHandler';
+import { ExistingLoginHandler } from '../../../../src/identity/interaction/ExistingLoginHandler';
+import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler';
 import type {
   InteractionCompleter,
-  InteractionCompleterInput,
 } from '../../../../src/identity/interaction/util/InteractionCompleter';
+import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
 import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
 import { createPostJsonOperation } from './email-password/handler/Util';
 
-class PublicSessionHttpHandler extends SessionHttpHandler {
-  public constructor(interactionCompleter: InteractionCompleter) {
-    super(interactionCompleter);
-  }
-
-  public async getCompletionParameters(input: Required<InteractionHandlerInput>): Promise<InteractionCompleterInput> {
-    return super.getCompletionParameters(input);
-  }
-}
-
-describe('A SessionHttpHandler', (): void => {
+describe('An ExistingLoginHandler', (): void => {
   const webId = 'http://test.com/id#me';
   let oidcInteraction: Interaction;
   let interactionCompleter: jest.Mocked<InteractionCompleter>;
-  let handler: PublicSessionHttpHandler;
+  let handler: ExistingLoginHandler;
 
   beforeEach(async(): Promise<void> => {
     oidcInteraction = { session: { accountId: webId }} as any;
@@ -30,18 +20,19 @@ describe('A SessionHttpHandler', (): void => {
       handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'),
     } as any;
 
-    handler = new PublicSessionHttpHandler(interactionCompleter);
+    handler = new ExistingLoginHandler(interactionCompleter);
   });
 
   it('requires an oidcInteraction with a defined session.', async(): Promise<void> => {
     oidcInteraction.session = undefined;
-    await expect(handler.getCompletionParameters({ operation: {} as any, oidcInteraction }))
+    await expect(handler.handle({ operation: createPostJsonOperation({}), oidcInteraction }))
       .rejects.toThrow(NotImplementedHttpError);
   });
 
   it('returns the correct completion parameters.', async(): Promise<void> => {
     const operation = createPostJsonOperation({ remember: true });
-    await expect(handler.getCompletionParameters({ operation, oidcInteraction }))
-      .resolves.toEqual({ oidcInteraction, webId, shouldRemember: true });
+    await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(FoundHttpError);
+    expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
+    expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: true });
   });
 });
diff --git a/test/unit/identity/interaction/FixedInteractionHandler.test.ts b/test/unit/identity/interaction/FixedInteractionHandler.test.ts
new file mode 100644
index 000000000..85c9c0e2b
--- /dev/null
+++ b/test/unit/identity/interaction/FixedInteractionHandler.test.ts
@@ -0,0 +1,15 @@
+import type { Operation } from '../../../../src/http/Operation';
+import { FixedInteractionHandler } from '../../../../src/identity/interaction/FixedInteractionHandler';
+import { readJsonStream } from '../../../../src/util/StreamUtil';
+
+describe('A FixedInteractionHandler', (): void => {
+  const json = { data: 'data' };
+  const operation: Operation = { target: { path: 'http://example.com/test/' }} as any;
+  const handler = new FixedInteractionHandler(json);
+
+  it('returns the given JSON as response.', async(): Promise<void> => {
+    const response = await handler.handle({ operation });
+    await expect(readJsonStream(response.data)).resolves.toEqual(json);
+    expect(response.metadata.contentType).toBe('application/json');
+  });
+});
diff --git a/test/unit/identity/interaction/HtmlViewHandler.test.ts b/test/unit/identity/interaction/HtmlViewHandler.test.ts
new file mode 100644
index 000000000..504bdaca8
--- /dev/null
+++ b/test/unit/identity/interaction/HtmlViewHandler.test.ts
@@ -0,0 +1,86 @@
+import type { Operation } from '../../../../src/http/Operation';
+import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
+import { HtmlViewHandler } from '../../../../src/identity/interaction/HtmlViewHandler';
+import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute';
+import { TEXT_HTML } from '../../../../src/util/ContentTypes';
+import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError';
+import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
+import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
+import { readableToString } from '../../../../src/util/StreamUtil';
+import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
+
+describe('An HtmlViewHandler', (): void => {
+  const idpIndex = 'http://example.com/idp/';
+  let index: InteractionRoute;
+  let operation: Operation;
+  let templates: Record<string, jest.Mocked<InteractionRoute>>;
+  let templateEngine: TemplateEngine;
+  let handler: HtmlViewHandler;
+
+  beforeEach(async(): Promise<void> => {
+    index = {
+      getPath: jest.fn().mockReturnValue(idpIndex),
+    } as any;
+
+    operation = {
+      method: 'GET',
+      target: { path: 'http://example.com/idp/login/' },
+      preferences: { type: { 'text/html': 1 }},
+      body: new BasicRepresentation(),
+    };
+
+    templates = {
+      '/templates/login.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any,
+      '/templates/register.html.ejs': { getPath: jest.fn().mockReturnValue('http://example.com/idp/register/') } as any,
+    };
+
+    templateEngine = {
+      render: jest.fn().mockReturnValue(Promise.resolve('<html>')),
+    };
+
+    handler = new HtmlViewHandler(index, templateEngine, templates);
+  });
+
+  it('rejects non-GET requests.', async(): Promise<void> => {
+    operation.method = 'POST';
+    await expect(handler.canHandle({ operation })).rejects.toThrow(MethodNotAllowedHttpError);
+  });
+
+  it('rejects unsupported paths.', async(): Promise<void> => {
+    operation.target.path = 'http://example.com/idp/otherPath/';
+    await expect(handler.canHandle({ operation })).rejects.toThrow(NotFoundHttpError);
+  });
+
+  it('rejects requests that do not prefer HTML to JSON.', async(): Promise<void> => {
+    operation.preferences = { type: { '*/*': 1 }};
+    await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError);
+
+    operation.preferences = { type: { 'application/json': 1, 'text/html': 1 }};
+    await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError);
+
+    operation.preferences = { type: { 'application/json': 1, 'text/html': 0.8 }};
+    await expect(handler.canHandle({ operation })).rejects.toThrow(NotImplementedHttpError);
+  });
+
+  it('can handle matching requests.', async(): Promise<void> => {
+    await expect(handler.canHandle({ operation })).resolves.toBeUndefined();
+  });
+
+  it('returns the resolved template.', async(): Promise<void> => {
+    const result = await handler.handle({ operation });
+    expect(result.metadata.contentType).toBe(TEXT_HTML);
+    await expect(readableToString(result.data)).resolves.toBe('<html>');
+    expect(templateEngine.render).toHaveBeenCalledTimes(1);
+    expect(templateEngine.render)
+      .toHaveBeenLastCalledWith({ idpIndex, authenticating: false }, { templateFile: '/templates/login.html.ejs' });
+  });
+
+  it('sets authenticating to true if there is an active interaction.', async(): Promise<void> => {
+    const result = await handler.handle({ operation, oidcInteraction: {} as any });
+    expect(result.metadata.contentType).toBe(TEXT_HTML);
+    await expect(readableToString(result.data)).resolves.toBe('<html>');
+    expect(templateEngine.render).toHaveBeenCalledTimes(1);
+    expect(templateEngine.render)
+      .toHaveBeenLastCalledWith({ idpIndex, authenticating: true }, { templateFile: '/templates/login.html.ejs' });
+  });
+});
diff --git a/test/unit/identity/interaction/InteractionHandler.test.ts b/test/unit/identity/interaction/InteractionHandler.test.ts
index f74cee567..167fef7aa 100644
--- a/test/unit/identity/interaction/InteractionHandler.test.ts
+++ b/test/unit/identity/interaction/InteractionHandler.test.ts
@@ -1,22 +1,20 @@
 import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
-import type {
-  InteractionResponseResult,
-} from '../../../../src/identity/interaction/InteractionHandler';
+import type { Representation } from '../../../../src/http/representation/Representation';
 import {
   InteractionHandler,
 } from '../../../../src/identity/interaction/InteractionHandler';
 import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
 
 class SimpleInteractionHandler extends InteractionHandler {
-  public async handle(): Promise<InteractionResponseResult> {
-    return { type: 'response' };
+  public async handle(): Promise<Representation> {
+    return new BasicRepresentation();
   }
 }
 
 describe('An InteractionHandler', (): void => {
   const handler = new SimpleInteractionHandler();
 
-  it('only supports JSON data.', async(): Promise<void> => {
+  it('only supports JSON data or empty bodies.', async(): Promise<void> => {
     let representation = new BasicRepresentation('{}', 'application/json');
     await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined();
 
@@ -24,6 +22,7 @@ describe('An InteractionHandler', (): void => {
     await expect(handler.canHandle({ operation: { body: representation }} as any))
       .rejects.toThrow(NotImplementedHttpError);
 
-    await expect(handler.canHandle({ operation: {}} as any)).rejects.toThrow(NotImplementedHttpError);
+    representation = new BasicRepresentation();
+    await expect(handler.canHandle({ operation: { body: representation }} as any)).resolves.toBeUndefined();
   });
 });
diff --git a/test/unit/identity/interaction/LocationInteractionHandler.test.ts b/test/unit/identity/interaction/LocationInteractionHandler.test.ts
new file mode 100644
index 000000000..60125e90b
--- /dev/null
+++ b/test/unit/identity/interaction/LocationInteractionHandler.test.ts
@@ -0,0 +1,62 @@
+import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
+import type {
+  InteractionHandler,
+  InteractionHandlerInput,
+} from '../../../../src/identity/interaction/InteractionHandler';
+import { LocationInteractionHandler } from '../../../../src/identity/interaction/LocationInteractionHandler';
+import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
+import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
+import { readJsonStream } from '../../../../src/util/StreamUtil';
+
+describe('A LocationInteractionHandler', (): void => {
+  const representation = new BasicRepresentation();
+  const input: InteractionHandlerInput = {
+    operation: {
+      target: { path: 'http://example.com/target' },
+      preferences: {},
+      method: 'GET',
+      body: new BasicRepresentation(),
+    },
+  };
+  let source: jest.Mocked<InteractionHandler>;
+  let handler: LocationInteractionHandler;
+
+  beforeEach(async(): Promise<void> => {
+    source = {
+      canHandle: jest.fn(),
+      handle: jest.fn().mockResolvedValue(representation),
+    } as any;
+
+    handler = new LocationInteractionHandler(source);
+  });
+
+  it('calls the source canHandle function.', async(): Promise<void> => {
+    await expect(handler.canHandle(input)).resolves.toBeUndefined();
+    expect(source.canHandle).toHaveBeenCalledTimes(1);
+    expect(source.canHandle).toHaveBeenLastCalledWith(input);
+
+    source.canHandle.mockRejectedValueOnce(new Error('bad input'));
+    await expect(handler.canHandle(input)).rejects.toThrow('bad input');
+  });
+
+  it('returns the source output.', async(): Promise<void> => {
+    await expect(handler.handle(input)).resolves.toBe(representation);
+    expect(source.handle).toHaveBeenCalledTimes(1);
+    expect(source.handle).toHaveBeenLastCalledWith(input);
+  });
+
+  it('returns a location object in case of redirect errors.', async(): Promise<void> => {
+    const location = 'http://example.com/foo';
+    source.handle.mockRejectedValueOnce(new FoundHttpError(location));
+
+    const response = await handler.handle(input);
+    expect(response.metadata.identifier.value).toEqual(input.operation.target.path);
+    await expect(readJsonStream(response.data)).resolves.toEqual({ location });
+  });
+
+  it('rethrows non-redirect errors.', async(): Promise<void> => {
+    source.handle.mockRejectedValueOnce(new NotFoundHttpError());
+
+    await expect(handler.handle(input)).rejects.toThrow(NotFoundHttpError);
+  });
+});
diff --git a/test/unit/identity/interaction/PromptHandler.test.ts b/test/unit/identity/interaction/PromptHandler.test.ts
new file mode 100644
index 000000000..05a6834ef
--- /dev/null
+++ b/test/unit/identity/interaction/PromptHandler.test.ts
@@ -0,0 +1,37 @@
+import type { Operation } from '../../../../src/http/Operation';
+import type { Interaction } from '../../../../src/identity/interaction/InteractionHandler';
+import { PromptHandler } from '../../../../src/identity/interaction/PromptHandler';
+import type { InteractionRoute } from '../../../../src/identity/interaction/routing/InteractionRoute';
+import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
+
+describe('A PromptHandler', (): void => {
+  const operation: Operation = { target: { path: 'http://example.com/test/' }} as any;
+  let oidcInteraction: Interaction;
+  let promptRoutes: Record<string, jest.Mocked<InteractionRoute>>;
+  let handler: PromptHandler;
+
+  beforeEach(async(): Promise<void> => {
+    oidcInteraction = { prompt: { name: 'login' }} as any;
+    promptRoutes = {
+      login: { getPath: jest.fn().mockReturnValue('http://example.com/idp/login/') } as any,
+    };
+    handler = new PromptHandler(promptRoutes);
+  });
+
+  it('errors if there is no interaction.', async(): Promise<void> => {
+    await expect(handler.handle({ operation })).rejects.toThrow(BadRequestHttpError);
+  });
+
+  it('errors if the prompt is unsupported.', async(): Promise<void> => {
+    oidcInteraction.prompt.name = 'unsupported';
+    await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(BadRequestHttpError);
+  });
+
+  it('throws a redirect error with the correct location.', async(): Promise<void> => {
+    const error = expect.objectContaining({
+      statusCode: 302,
+      location: 'http://example.com/idp/login/',
+    });
+    await expect(handler.handle({ operation, oidcInteraction })).rejects.toThrow(error);
+  });
+});
diff --git a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts
index 4a7470571..92701c0a3 100644
--- a/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts
+++ b/test/unit/identity/interaction/email-password/handler/ForgotPasswordHandler.test.ts
@@ -3,7 +3,9 @@ import {
   ForgotPasswordHandler,
 } from '../../../../../../src/identity/interaction/email-password/handler/ForgotPasswordHandler';
 import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
+import type { InteractionRoute } from '../../../../../../src/identity/interaction/routing/InteractionRoute';
 import type { EmailSender } from '../../../../../../src/identity/interaction/util/EmailSender';
+import { readJsonStream } from '../../../../../../src/util/StreamUtil';
 import type { TemplateEngine } from '../../../../../../src/util/templates/TemplateEngine';
 import { createPostJsonOperation } from './Util';
 
@@ -11,11 +13,10 @@ describe('A ForgotPasswordHandler', (): void => {
   let operation: Operation;
   const email = 'test@test.email';
   const recordId = '123456';
-  const html = `<a href="/base/idp/resetpassword/${recordId}">Reset Password</a>`;
+  const html = `<a href="/base/idp/resetpassword/?rid=${recordId}">Reset Password</a>`;
   let accountStore: AccountStore;
-  const baseUrl = 'http://test.com/base/';
-  const idpPath = '/idp';
   let templateEngine: TemplateEngine<{ resetLink: string }>;
+  let resetRoute: jest.Mocked<InteractionRoute>;
   let emailSender: EmailSender;
   let handler: ForgotPasswordHandler;
 
@@ -30,16 +31,19 @@ describe('A ForgotPasswordHandler', (): void => {
       render: jest.fn().mockResolvedValue(html),
     } as any;
 
+    resetRoute = {
+      getPath: jest.fn().mockReturnValue('http://test.com/base/idp/resetpassword/'),
+    } as any;
+
     emailSender = {
       handleSafe: jest.fn(),
     } as any;
 
     handler = new ForgotPasswordHandler({
       accountStore,
-      baseUrl,
-      idpPath,
       templateEngine,
       emailSender,
+      resetRoute,
     });
   });
 
@@ -52,19 +56,20 @@ describe('A ForgotPasswordHandler', (): void => {
 
   it('does not send a mail if a ForgotPassword record could not be generated.', async(): Promise<void> => {
     (accountStore.generateForgotPasswordRecord as jest.Mock).mockRejectedValueOnce('error');
-    await expect(handler.handle({ operation })).resolves
-      .toEqual({ type: 'response', details: { email }});
+    const result = await handler.handle({ operation });
+    await expect(readJsonStream(result.data)).resolves.toEqual({ email });
     expect(emailSender.handleSafe).toHaveBeenCalledTimes(0);
   });
 
   it('sends a mail if a ForgotPassword record could be generated.', async(): Promise<void> => {
-    await expect(handler.handle({ operation })).resolves
-      .toEqual({ type: 'response', details: { email }});
+    const result = await handler.handle({ operation });
+    await expect(readJsonStream(result.data)).resolves.toEqual({ email });
+    expect(result.metadata.contentType).toBe('application/json');
     expect(emailSender.handleSafe).toHaveBeenCalledTimes(1);
     expect(emailSender.handleSafe).toHaveBeenLastCalledWith({
       recipient: email,
       subject: 'Reset your password',
-      text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/${recordId}`,
+      text: `To reset your password, go to this link: http://test.com/base/idp/resetpassword/?rid=${recordId}`,
       html,
     });
   });
diff --git a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts
index 456c85917..c4a9392e7 100644
--- a/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts
+++ b/test/unit/identity/interaction/email-password/handler/LoginHandler.test.ts
@@ -5,22 +5,11 @@ import type {
   InteractionHandlerInput,
 } from '../../../../../../src/identity/interaction/InteractionHandler';
 import type {
-  InteractionCompleterInput,
   InteractionCompleter,
 } from '../../../../../../src/identity/interaction/util/InteractionCompleter';
-
+import { FoundHttpError } from '../../../../../../src/util/errors/FoundHttpError';
 import { createPostJsonOperation } from './Util';
 
-class PublicLoginHandler extends LoginHandler {
-  public constructor(accountStore: AccountStore, interactionCompleter: InteractionCompleter) {
-    super(accountStore, interactionCompleter);
-  }
-
-  public async getCompletionParameters(input: Required<InteractionHandlerInput>): Promise<InteractionCompleterInput> {
-    return super.getCompletionParameters(input);
-  }
-}
-
 describe('A LoginHandler', (): void => {
   const webId = 'http://alice.test.com/card#me';
   const email = 'alice@test.email';
@@ -28,7 +17,7 @@ describe('A LoginHandler', (): void => {
   let input: Required<InteractionHandlerInput>;
   let accountStore: jest.Mocked<AccountStore>;
   let interactionCompleter: jest.Mocked<InteractionCompleter>;
-  let handler: PublicLoginHandler;
+  let handler: LoginHandler;
 
   beforeEach(async(): Promise<void> => {
     input = { oidcInteraction } as any;
@@ -42,41 +31,43 @@ describe('A LoginHandler', (): void => {
       handleSafe: jest.fn().mockResolvedValue('http://test.com/redirect'),
     } as any;
 
-    handler = new PublicLoginHandler(accountStore, interactionCompleter);
+    handler = new LoginHandler(accountStore, interactionCompleter);
   });
 
   it('errors on invalid emails.', async(): Promise<void> => {
     input.operation = createPostJsonOperation({});
-    await expect(handler.getCompletionParameters(input)).rejects.toThrow('Email required');
+    await expect(handler.handle(input)).rejects.toThrow('Email required');
     input.operation = createPostJsonOperation({ email: [ 'a', 'b' ]});
-    await expect(handler.getCompletionParameters(input)).rejects.toThrow('Email required');
+    await expect(handler.handle(input)).rejects.toThrow('Email required');
   });
 
   it('errors on invalid passwords.', async(): Promise<void> => {
     input.operation = createPostJsonOperation({ email });
-    await expect(handler.getCompletionParameters(input)).rejects.toThrow('Password required');
+    await expect(handler.handle(input)).rejects.toThrow('Password required');
     input.operation = createPostJsonOperation({ email, password: [ 'a', 'b' ]});
-    await expect(handler.getCompletionParameters(input)).rejects.toThrow('Password required');
+    await expect(handler.handle(input)).rejects.toThrow('Password required');
   });
 
   it('throws an error if there is a problem.', async(): Promise<void> => {
     input.operation = createPostJsonOperation({ email, password: 'password!' });
     accountStore.authenticate.mockRejectedValueOnce(new Error('auth failed!'));
-    await expect(handler.getCompletionParameters(input)).rejects.toThrow('auth failed!');
+    await expect(handler.handle(input)).rejects.toThrow('auth failed!');
   });
 
   it('throws an error if the account does not have the correct settings.', async(): Promise<void> => {
     input.operation = createPostJsonOperation({ email, password: 'password!' });
     accountStore.getSettings.mockResolvedValueOnce({ useIdp: false });
-    await expect(handler.getCompletionParameters(input))
+    await expect(handler.handle(input))
       .rejects.toThrow('This server is not an identity provider for this account.');
   });
 
   it('returns the correct completion parameters.', async(): Promise<void> => {
     input.operation = createPostJsonOperation({ email, password: 'password!' });
-    await expect(handler.getCompletionParameters(input))
-      .resolves.toEqual({ oidcInteraction, webId, shouldRemember: false });
+    await expect(handler.handle(input)).rejects.toThrow(FoundHttpError);
+
     expect(accountStore.authenticate).toHaveBeenCalledTimes(1);
     expect(accountStore.authenticate).toHaveBeenLastCalledWith(email, 'password!');
+    expect(interactionCompleter.handleSafe).toHaveBeenCalledTimes(1);
+    expect(interactionCompleter.handleSafe).toHaveBeenLastCalledWith({ oidcInteraction, webId, shouldRemember: false });
   });
 });
diff --git a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts
index 1a08ea2cd..52ff1c0e1 100644
--- a/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts
+++ b/test/unit/identity/interaction/email-password/handler/RegistrationHandler.test.ts
@@ -5,6 +5,7 @@ import {
 import type {
   RegistrationManager, RegistrationParams, RegistrationResponse,
 } from '../../../../../../src/identity/interaction/email-password/util/RegistrationManager';
+import { readJsonStream } from '../../../../../../src/util/StreamUtil';
 import { createPostJsonOperation } from './Util';
 
 describe('A RegistrationHandler', (): void => {
@@ -41,10 +42,9 @@ describe('A RegistrationHandler', (): void => {
   it('converts the stream to json and sends it to the registration manager.', async(): Promise<void> => {
     const params = { email: 'alice@test.email', password: 'superSecret' };
     operation = createPostJsonOperation(params);
-    await expect(handler.handle({ operation })).resolves.toEqual({
-      type: 'response',
-      details,
-    });
+    const result = await handler.handle({ operation });
+    await expect(readJsonStream(result.data)).resolves.toEqual(details);
+    expect(result.metadata.contentType).toBe('application/json');
 
     expect(registrationManager.validateInput).toHaveBeenCalledTimes(1);
     expect(registrationManager.validateInput).toHaveBeenLastCalledWith(params, false);
diff --git a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts
index 37f4c2176..cf589350d 100644
--- a/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts
+++ b/test/unit/identity/interaction/email-password/handler/ResetPasswordHandler.test.ts
@@ -3,6 +3,7 @@ import {
   ResetPasswordHandler,
 } from '../../../../../../src/identity/interaction/email-password/handler/ResetPasswordHandler';
 import type { AccountStore } from '../../../../../../src/identity/interaction/email-password/storage/AccountStore';
+import { readJsonStream } from '../../../../../../src/util/StreamUtil';
 import { createPostJsonOperation } from './Util';
 
 describe('A ResetPasswordHandler', (): void => {
@@ -27,26 +28,28 @@ describe('A ResetPasswordHandler', (): void => {
     const errorMessage = 'Invalid request. Open the link from your email again';
     operation = createPostJsonOperation({});
     await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
-    operation = createPostJsonOperation({}, '');
+    operation = createPostJsonOperation({ recordId: 5 });
     await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
   });
 
   it('errors for invalid passwords.', async(): Promise<void> => {
     const errorMessage = 'Your password and confirmation did not match.';
-    operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'otherPassword!' }, url);
+    operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'otherPassword!', recordId }, url);
     await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
   });
 
   it('errors for invalid emails.', async(): Promise<void> => {
     const errorMessage = 'This reset password link is no longer valid.';
-    operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url);
+    operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url);
     (accountStore.getForgotPasswordRecord as jest.Mock).mockResolvedValueOnce(undefined);
     await expect(handler.handle({ operation })).rejects.toThrow(errorMessage);
   });
 
   it('renders a message on success.', async(): Promise<void> => {
-    operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!' }, url);
-    await expect(handler.handle({ operation })).resolves.toEqual({ type: 'response' });
+    operation = createPostJsonOperation({ password: 'password!', confirmPassword: 'password!', recordId }, url);
+    const result = await handler.handle({ operation });
+    await expect(readJsonStream(result.data)).resolves.toEqual({});
+    expect(result.metadata.contentType).toBe('application/json');
     expect(accountStore.getForgotPasswordRecord).toHaveBeenCalledTimes(1);
     expect(accountStore.getForgotPasswordRecord).toHaveBeenLastCalledWith(recordId);
     expect(accountStore.deleteForgotPasswordRecord).toHaveBeenCalledTimes(1);
diff --git a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts b/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts
index 28bda2c1c..7ca15e559 100644
--- a/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts
+++ b/test/unit/identity/interaction/routing/BasicInteractionRoute.test.ts
@@ -1,92 +1,59 @@
+import type { Operation } from '../../../../../src/http/Operation';
+import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
+import type { Representation } from '../../../../../src/http/representation/Representation';
 import type {
   InteractionHandler,
 } from '../../../../../src/identity/interaction/InteractionHandler';
 import { BasicInteractionRoute } from '../../../../../src/identity/interaction/routing/BasicInteractionRoute';
-import { BadRequestHttpError } from '../../../../../src/util/errors/BadRequestHttpError';
-import { FoundHttpError } from '../../../../../src/util/errors/FoundHttpError';
-import { InternalServerError } from '../../../../../src/util/errors/InternalServerError';
+import { APPLICATION_JSON } from '../../../../../src/util/ContentTypes';
+import { NotFoundHttpError } from '../../../../../src/util/errors/NotFoundHttpError';
+import { createPostJsonOperation } from '../email-password/handler/Util';
 
 describe('A BasicInteractionRoute', (): void => {
-  const path = '^/route$';
-  const viewTemplates = { 'text/html': '/viewTemplate' };
-  let handler: jest.Mocked<InteractionHandler>;
-  const prompt = 'login';
-  const responseTemplates = { 'text/html': '/responseTemplate' };
-  const controls = { login: '/route' };
-  const response = { type: 'response' };
+  const path = 'http://example.com/idp/path/';
+  let operation: Operation;
+  let representation: Representation;
+  let source: jest.Mocked<InteractionHandler>;
   let route: BasicInteractionRoute;
 
   beforeEach(async(): Promise<void> => {
-    handler = {
-      handleSafe: jest.fn().mockResolvedValue(response),
-    } as any;
-
-    route = new BasicInteractionRoute(path, viewTemplates, handler, prompt, responseTemplates, controls);
-  });
-
-  it('returns its controls.', async(): Promise<void> => {
-    expect(route.getControls()).toEqual(controls);
-  });
+    operation = createPostJsonOperation({}, 'http://example.com/idp/path/');
 
-  it('supports a path if it matches the stored route.', async(): Promise<void> => {
-    expect(route.supportsPath('/route')).toBe(true);
-    expect(route.supportsPath('/notRoute')).toBe(false);
-  });
-
-  it('supports prompts when targeting the base path.', async(): Promise<void> => {
-    expect(route.supportsPath('/', prompt)).toBe(true);
-    expect(route.supportsPath('/notRoute', prompt)).toBe(false);
-    expect(route.supportsPath('/', 'notPrompt')).toBe(false);
-  });
+    representation = new BasicRepresentation(JSON.stringify({}), APPLICATION_JSON);
 
-  it('returns a response result on a GET request.', async(): Promise<void> => {
-    await expect(route.handleOperation({ method: 'GET' } as any))
-      .resolves.toEqual({ type: 'response', templateFiles: viewTemplates });
-  });
+    source = {
+      canHandle: jest.fn(),
+      handle: jest.fn().mockResolvedValue(representation),
+    } as any;
 
-  it('returns the result of the InteractionHandler on POST requests.', async(): Promise<void> => {
-    await expect(route.handleOperation({ method: 'POST' } as any))
-      .resolves.toEqual({ ...response, templateFiles: responseTemplates });
-    expect(handler.handleSafe).toHaveBeenCalledTimes(1);
-    expect(handler.handleSafe).toHaveBeenLastCalledWith({ operation: { method: 'POST' }});
+    route = new BasicInteractionRoute(path, source);
   });
 
-  it('creates an error result in case the InteractionHandler errors.', async(): Promise<void> => {
-    const error = new Error('bad data');
-    handler.handleSafe.mockRejectedValueOnce(error);
-    await expect(route.handleOperation({ method: 'POST' } as any))
-      .resolves.toEqual({ type: 'error', error, templateFiles: viewTemplates });
+  it('returns the given path.', async(): Promise<void> => {
+    expect(route.getPath()).toBe('http://example.com/idp/path/');
   });
 
-  it('re-throws redirect errors.', async(): Promise<void> => {
-    const error = new FoundHttpError('http://test.com/redirect');
-    handler.handleSafe.mockRejectedValueOnce(error);
-    await expect(route.handleOperation({ method: 'POST' } as any)).rejects.toThrow(error);
+  it('rejects other paths.', async(): Promise<void> => {
+    operation = createPostJsonOperation({}, 'http://example.com/idp/otherPath/');
+    await expect(route.canHandle({ operation })).rejects.toThrow(NotFoundHttpError);
   });
 
-  it('creates an internal error in case of non-native errors.', async(): Promise<void> => {
-    handler.handleSafe.mockRejectedValueOnce('notAnError');
-    await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({
-      type: 'error',
-      error: new InternalServerError('Unknown error: notAnError'),
-      templateFiles: viewTemplates,
-    });
+  it('rejects input its source cannot handle.', async(): Promise<void> => {
+    source.canHandle.mockRejectedValueOnce(new Error('bad data'));
+    await expect(route.canHandle({ operation })).rejects.toThrow('bad data');
   });
 
-  it('errors for non-supported operations.', async(): Promise<void> => {
-    const prom = route.handleOperation({ method: 'DELETE', target: { path: '/route' }} as any);
-    await expect(prom).rejects.toThrow(BadRequestHttpError);
-    await expect(prom).rejects.toThrow('Unsupported request: DELETE /route');
-    expect(handler.handleSafe).toHaveBeenCalledTimes(0);
+  it('can handle requests its source can handle.', async(): Promise<void> => {
+    await expect(route.canHandle({ operation })).resolves.toBeUndefined();
   });
 
-  it('defaults to empty controls.', async(): Promise<void> => {
-    route = new BasicInteractionRoute(path, viewTemplates, handler, prompt);
-    expect(route.getControls()).toEqual({});
+  it('lets its source handle requests.', async(): Promise<void> => {
+    await expect(route.handle({ operation })).resolves.toBe(representation);
   });
 
-  it('defaults to empty response templates.', async(): Promise<void> => {
-    route = new BasicInteractionRoute(path, viewTemplates, handler, prompt);
-    await expect(route.handleOperation({ method: 'POST' } as any)).resolves.toEqual({ ...response, templateFiles: {}});
+  it('defaults to an UnsupportedAsyncHandler if no source is provided.', async(): Promise<void> => {
+    route = new BasicInteractionRoute(path);
+    await expect(route.canHandle({ operation })).rejects.toThrow('This route has no associated handler.');
+    await expect(route.handle({ operation })).rejects.toThrow('This route has no associated handler.');
   });
 });
diff --git a/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts b/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts
new file mode 100644
index 000000000..9d8bb8ba8
--- /dev/null
+++ b/test/unit/identity/interaction/routing/RelativeInteractionRoute.test.ts
@@ -0,0 +1,30 @@
+import type {
+  InteractionHandler,
+} from '../../../../../src/identity/interaction/InteractionHandler';
+import type { InteractionRoute } from '../../../../../src/identity/interaction/routing/InteractionRoute';
+import { RelativeInteractionRoute } from '../../../../../src/identity/interaction/routing/RelativeInteractionRoute';
+
+describe('A RelativeInteractionRoute', (): void => {
+  const relativePath = '/relative/';
+  let route: jest.Mocked<InteractionRoute>;
+  let source: jest.Mocked<InteractionHandler>;
+  let relativeRoute: RelativeInteractionRoute;
+
+  beforeEach(async(): Promise<void> => {
+    route = {
+      getPath: jest.fn().mockReturnValue('http://example.com/'),
+    } as any;
+
+    source = {
+      canHandle: jest.fn(),
+    } as any;
+  });
+
+  it('returns the joined path.', async(): Promise<void> => {
+    relativeRoute = new RelativeInteractionRoute(route, relativePath, source);
+    expect(relativeRoute.getPath()).toBe('http://example.com/relative/');
+
+    relativeRoute = new RelativeInteractionRoute('http://example.com/', relativePath, source);
+    expect(relativeRoute.getPath()).toBe('http://example.com/relative/');
+  });
+});
diff --git a/test/unit/init/setup/SetupHandler.test.ts b/test/unit/init/setup/SetupHandler.test.ts
new file mode 100644
index 000000000..e703e273b
--- /dev/null
+++ b/test/unit/init/setup/SetupHandler.test.ts
@@ -0,0 +1,88 @@
+import type { Operation } from '../../../../src/http/Operation';
+import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
+import type { RegistrationResponse,
+  RegistrationManager } from '../../../../src/identity/interaction/email-password/util/RegistrationManager';
+import type { Initializer } from '../../../../src/init/Initializer';
+import { SetupHandler } from '../../../../src/init/setup/SetupHandler';
+import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
+import { readJsonStream } from '../../../../src/util/StreamUtil';
+
+describe('A SetupHandler', (): void => {
+  let operation: Operation;
+  let details: RegistrationResponse;
+  let registrationManager: jest.Mocked<RegistrationManager>;
+  let initializer: jest.Mocked<Initializer>;
+  let handler: SetupHandler;
+
+  beforeEach(async(): Promise<void> => {
+    operation = {
+      method: 'POST',
+      target: { path: 'http://example.com/setup' },
+      preferences: {},
+      body: new BasicRepresentation(),
+    };
+
+    initializer = {
+      handleSafe: jest.fn(),
+    } as any;
+
+    details = {
+      email: 'alice@test.email',
+      createWebId: true,
+      register: true,
+      createPod: true,
+    };
+
+    registrationManager = {
+      validateInput: jest.fn((input): any => input),
+      register: jest.fn().mockResolvedValue(details),
+    } as any;
+
+    handler = new SetupHandler({ registrationManager, initializer });
+  });
+
+  it('error if no Initializer is defined and initialization is requested.', async(): Promise<void> => {
+    handler = new SetupHandler({});
+    operation.body = new BasicRepresentation(JSON.stringify({ initialize: true }), 'application/json');
+    await expect(handler.handle({ operation })).rejects.toThrow(NotImplementedHttpError);
+  });
+
+  it('error if no RegistrationManager is defined and registration is requested.', async(): Promise<void> => {
+    handler = new SetupHandler({});
+    operation.body = new BasicRepresentation(JSON.stringify({ registration: true }), 'application/json');
+    await expect(handler.handle({ operation })).rejects.toThrow(NotImplementedHttpError);
+  });
+
+  it('calls the Initializer when requested.', async(): Promise<void> => {
+    operation.body = new BasicRepresentation(JSON.stringify({ initialize: true }), 'application/json');
+    const result = await handler.handle({ operation });
+    await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: true, registration: false });
+    expect(result.metadata.contentType).toBe('application/json');
+    expect(initializer.handleSafe).toHaveBeenCalledTimes(1);
+    expect(registrationManager.validateInput).toHaveBeenCalledTimes(0);
+    expect(registrationManager.register).toHaveBeenCalledTimes(0);
+  });
+
+  it('calls the RegistrationManager when requested.', async(): Promise<void> => {
+    const body = { registration: true, email: 'test@example.com' };
+    operation.body = new BasicRepresentation(JSON.stringify(body), 'application/json');
+    const result = await handler.handle({ operation });
+    await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: false, registration: true, ...details });
+    expect(result.metadata.contentType).toBe('application/json');
+    expect(initializer.handleSafe).toHaveBeenCalledTimes(0);
+    expect(registrationManager.validateInput).toHaveBeenCalledTimes(1);
+    expect(registrationManager.register).toHaveBeenCalledTimes(1);
+    expect(registrationManager.validateInput).toHaveBeenLastCalledWith(body, true);
+    expect(registrationManager.register).toHaveBeenLastCalledWith(body, true);
+  });
+
+  it('defaults to an empty JSON body if no data is provided.', async(): Promise<void> => {
+    operation.body = new BasicRepresentation();
+    const result = await handler.handle({ operation });
+    await expect(readJsonStream(result.data)).resolves.toEqual({ initialize: false, registration: false });
+    expect(result.metadata.contentType).toBe('application/json');
+    expect(initializer.handleSafe).toHaveBeenCalledTimes(0);
+    expect(registrationManager.validateInput).toHaveBeenCalledTimes(0);
+    expect(registrationManager.register).toHaveBeenCalledTimes(0);
+  });
+});
diff --git a/test/unit/init/setup/SetupHttpHandler.test.ts b/test/unit/init/setup/SetupHttpHandler.test.ts
index d58bcf510..b1cf83b33 100644
--- a/test/unit/init/setup/SetupHttpHandler.test.ts
+++ b/test/unit/init/setup/SetupHttpHandler.test.ts
@@ -1,13 +1,8 @@
 import type { Operation } from '../../../../src/http/Operation';
-import type { ErrorHandler, ErrorHandlerArgs } from '../../../../src/http/output/error/ErrorHandler';
-import type { ResponseDescription } from '../../../../src/http/output/response/ResponseDescription';
 import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
 import type { Representation } from '../../../../src/http/representation/Representation';
 import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
-import type { RegistrationManager,
-  RegistrationResponse } from '../../../../src/identity/interaction/email-password/util/RegistrationManager';
-import type { Initializer } from '../../../../src/init/Initializer';
-import type { SetupInput } from '../../../../src/init/setup/SetupHttpHandler';
+import type { InteractionHandler } from '../../../../src/identity/interaction/InteractionHandler';
 import { SetupHttpHandler } from '../../../../src/init/setup/SetupHttpHandler';
 import type { HttpRequest } from '../../../../src/server/HttpRequest';
 import type { HttpResponse } from '../../../../src/server/HttpResponse';
@@ -15,25 +10,20 @@ import { getBestPreference } from '../../../../src/storage/conversion/Conversion
 import type { RepresentationConverterArgs,
   RepresentationConverter } from '../../../../src/storage/conversion/RepresentationConverter';
 import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
-import { APPLICATION_JSON } from '../../../../src/util/ContentTypes';
-import type { HttpError } from '../../../../src/util/errors/HttpError';
-import { InternalServerError } from '../../../../src/util/errors/InternalServerError';
+import { APPLICATION_JSON, APPLICATION_X_WWW_FORM_URLENCODED } from '../../../../src/util/ContentTypes';
 import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError';
-import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
-import { guardedStreamFrom, readableToString } from '../../../../src/util/StreamUtil';
-import { CONTENT_TYPE, SOLID_META } from '../../../../src/util/Vocabularies';
+import { readableToString } from '../../../../src/util/StreamUtil';
+import type { TemplateEngine } from '../../../../src/util/templates/TemplateEngine';
+import { CONTENT_TYPE } from '../../../../src/util/Vocabularies';
 
 describe('A SetupHttpHandler', (): void => {
-  let request: HttpRequest;
+  const request: HttpRequest = {} as any;
   const response: HttpResponse = {} as any;
   let operation: Operation;
-  const viewTemplate = '/templates/view';
-  const responseTemplate = '/templates/response';
   const storageKey = 'completed';
-  let details: RegistrationResponse;
-  let errorHandler: jest.Mocked<ErrorHandler>;
-  let registrationManager: jest.Mocked<RegistrationManager>;
-  let initializer: jest.Mocked<Initializer>;
+  let representation: Representation;
+  let interactionHandler: jest.Mocked<InteractionHandler>;
+  let templateEngine: jest.Mocked<TemplateEngine>;
   let converter: jest.Mocked<RepresentationConverter>;
   let storage: jest.Mocked<KeyValueStorage<string, any>>;
   let handler: SetupHttpHandler;
@@ -41,32 +31,15 @@ describe('A SetupHttpHandler', (): void => {
   beforeEach(async(): Promise<void> => {
     operation = {
       method: 'GET',
-      target: { path: 'http://test.com/setup' },
-      preferences: { type: { 'text/html': 1 }},
+      target: { path: 'http://example.com/setup' },
+      preferences: {},
       body: new BasicRepresentation(),
     };
 
-    errorHandler = { handleSafe: jest.fn(({ error }: ErrorHandlerArgs): ResponseDescription => ({
-      statusCode: 400,
-      data: guardedStreamFrom(`{ "name": "${error.name}", "message": "${error.message}" }`),
-    })) } as any;
-
-    initializer = {
-      handleSafe: jest.fn(),
-    } as any;
-
-    details = {
-      email: 'alice@test.email',
-      createWebId: true,
-      register: true,
-      createPod: true,
+    templateEngine = {
+      render: jest.fn().mockReturnValue(Promise.resolve('<html>')),
     };
 
-    registrationManager = {
-      validateInput: jest.fn((input): any => input),
-      register: jest.fn().mockResolvedValue(details),
-    } as any;
-
     converter = {
       handleSafe: jest.fn((input: RepresentationConverterArgs): Representation => {
         // Just find the best match;
@@ -76,148 +49,71 @@ describe('A SetupHttpHandler', (): void => {
       }),
     } as any;
 
+    representation = new BasicRepresentation();
+    interactionHandler = {
+      handleSafe: jest.fn().mockResolvedValue(representation),
+    } as any;
+
     storage = new Map<string, any>() as any;
 
     handler = new SetupHttpHandler({
-      initializer,
-      registrationManager,
       converter,
       storageKey,
       storage,
-      viewTemplate,
-      responseTemplate,
-      errorHandler,
+      handler: interactionHandler,
+      templateEngine,
     });
   });
 
-  // Since all tests check similar things, the test functionality is generalized in here
-  async function testPost(input: SetupInput, error?: HttpError): Promise<void> {
-    operation.method = 'POST';
-    const initialize = Boolean(input.initialize);
-    const registration = Boolean(input.registration);
-    const requestBody = { initialize, registration };
-    if (Object.keys(input).length > 0) {
-      operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json');
-    }
-
-    const result = await handler.handle({ operation, request, response });
-    expect(result).toBeDefined();
-    expect(initializer.handleSafe).toHaveBeenCalledTimes(!error && initialize ? 1 : 0);
-    expect(registrationManager.validateInput).toHaveBeenCalledTimes(!error && registration ? 1 : 0);
-    expect(registrationManager.register).toHaveBeenCalledTimes(!error && registration ? 1 : 0);
-    let expectedResult: any = { initialize, registration };
-    if (error) {
-      expectedResult = { name: error.name, message: error.message };
-    } else if (registration) {
-      Object.assign(expectedResult, details);
-    }
-    expect(JSON.parse(await readableToString(result.data!))).toEqual(expectedResult);
-    expect(result.statusCode).toBe(error?.statusCode ?? 200);
-    expect(result.metadata?.contentType).toBe('text/html');
-    expect(result.metadata?.get(SOLID_META.template)?.value).toBe(error ? viewTemplate : responseTemplate);
-
-    if (!error && registration) {
-      expect(registrationManager.validateInput).toHaveBeenLastCalledWith(requestBody, true);
-      expect(registrationManager.register).toHaveBeenLastCalledWith(requestBody, true);
-    }
-  }
+  it('only accepts GET and POST operations.', async(): Promise<void> => {
+    operation = {
+      method: 'DELETE',
+      target: { path: 'http://example.com/setup' },
+      preferences: {},
+      body: new BasicRepresentation(),
+    };
+    await expect(handler.handle({ operation, request, response })).rejects.toThrow(MethodNotAllowedHttpError);
+  });
 
-  it('returns the view template on GET requests.', async(): Promise<void> => {
+  it('calls the template engine for GET requests.', async(): Promise<void> => {
     const result = await handler.handle({ operation, request, response });
-    expect(result).toBeDefined();
-    expect(JSON.parse(await readableToString(result.data!))).toEqual({});
-    expect(result.statusCode).toBe(200);
+    expect(result.data).toBeDefined();
+    await expect(readableToString(result.data!)).resolves.toBe('<html>');
     expect(result.metadata?.contentType).toBe('text/html');
-    expect(result.metadata?.get(SOLID_META.template)?.value).toBe(viewTemplate);
 
     // Setup is still enabled since this was a GET request
     expect(storage.get(storageKey)).toBeUndefined();
   });
 
-  it('simply disables the handler if no setup is requested.', async(): Promise<void> => {
-    await expect(testPost({ initialize: false, registration: false })).resolves.toBeUndefined();
+  it('returns the handler result as 200 response.', async(): Promise<void> => {
+    operation.method = 'POST';
+    const result = await handler.handle({ operation, request, response });
+    expect(result.statusCode).toBe(200);
+    expect(result.data).toBe(representation.data);
+    expect(result.metadata).toBe(representation.metadata);
+    expect(interactionHandler.handleSafe).toHaveBeenCalledTimes(1);
+    expect(interactionHandler.handleSafe).toHaveBeenLastCalledWith({ operation });
 
     // Handler is now disabled due to successful POST
     expect(storage.get(storageKey)).toBe(true);
   });
 
-  it('defaults to an empty body if there is none.', async(): Promise<void> => {
-    await expect(testPost({})).resolves.toBeUndefined();
-  });
-
-  it('calls the initializer when requested.', async(): Promise<void> => {
-    await expect(testPost({ initialize: true, registration: false })).resolves.toBeUndefined();
-  });
-
-  it('calls the registrationManager when requested.', async(): Promise<void> => {
-    await expect(testPost({ initialize: false, registration: true })).resolves.toBeUndefined();
-  });
-
-  it('converts non-HTTP errors to internal errors.', async(): Promise<void> => {
-    converter.handleSafe.mockRejectedValueOnce(new Error('bad data'));
-    const error = new InternalServerError('bad data');
-    await expect(testPost({ initialize: true, registration: false }, error)).resolves.toBeUndefined();
-  });
-
-  it('errors on non-GET/POST requests.', async(): Promise<void> => {
-    operation.method = 'PUT';
-    const requestBody = { initialize: true, registration: true };
-    operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json');
-    const error = new MethodNotAllowedHttpError();
-
-    const result = await handler.handle({ operation, request, response });
-    expect(result).toBeDefined();
-    expect(initializer.handleSafe).toHaveBeenCalledTimes(0);
-    expect(registrationManager.register).toHaveBeenCalledTimes(0);
-    expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
-    expect(errorHandler.handleSafe).toHaveBeenLastCalledWith({ error, preferences: { type: { [APPLICATION_JSON]: 1 }}});
-
-    expect(JSON.parse(await readableToString(result.data!))).toEqual({ name: error.name, message: error.message });
-    expect(result.statusCode).toBe(405);
-    expect(result.metadata?.contentType).toBe('text/html');
-    expect(result.metadata?.get(SOLID_META.template)?.value).toBe(viewTemplate);
-
-    // Setup is not disabled since there was an error
-    expect(storage.get(storageKey)).toBeUndefined();
-  });
-
-  it('errors when attempting registration when no RegistrationManager is defined.', async(): Promise<void> => {
-    handler = new SetupHttpHandler({
-      errorHandler,
-      initializer,
-      converter,
-      storageKey,
-      storage,
-      viewTemplate,
-      responseTemplate,
-    });
-    operation.method = 'POST';
-    const requestBody = { initialize: false, registration: true };
-    operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json');
-    const error = new NotImplementedHttpError('This server is not configured to support registration during setup.');
-    await expect(testPost({ initialize: false, registration: true }, error)).resolves.toBeUndefined();
-
-    // Setup is not disabled since there was an error
-    expect(storage.get(storageKey)).toBeUndefined();
-  });
-
-  it('errors when attempting initialization when no Initializer is defined.', async(): Promise<void> => {
-    handler = new SetupHttpHandler({
-      errorHandler,
-      registrationManager,
-      converter,
-      storageKey,
-      storage,
-      viewTemplate,
-      responseTemplate,
-    });
+  it('converts input bodies to JSON.', async(): Promise<void> => {
     operation.method = 'POST';
-    const requestBody = { initialize: true, registration: false };
-    operation.body = new BasicRepresentation(JSON.stringify(requestBody), 'application/json');
-    const error = new NotImplementedHttpError('This server is not configured with a setup initializer.');
-    await expect(testPost({ initialize: true, registration: false }, error)).resolves.toBeUndefined();
+    operation.body.metadata.contentType = APPLICATION_X_WWW_FORM_URLENCODED;
+    const result = await handler.handle({ operation, request, response });
+    expect(result.statusCode).toBe(200);
+    expect(result.data).toBe(representation.data);
+    expect(result.metadata).toBe(representation.metadata);
+    expect(interactionHandler.handleSafe).toHaveBeenCalledTimes(1);
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const { body, ...partialOperation } = operation;
+    expect(interactionHandler.handleSafe).toHaveBeenLastCalledWith(
+      { operation: expect.objectContaining(partialOperation) },
+    );
+    expect(interactionHandler.handleSafe.mock.calls[0][0].operation.body.metadata.contentType).toBe(APPLICATION_JSON);
 
-    // Setup is not disabled since there was an error
-    expect(storage.get(storageKey)).toBeUndefined();
+    // Handler is now disabled due to successful POST
+    expect(storage.get(storageKey)).toBe(true);
   });
 });