Hacking Strapi with Koa Middleware and Transform Streams
How I used Koa middleware to customize the Strapi login page with an injected inline script.
Introduction
For a recent project, I needed to customize the Strapi admin panel login page. Strapi doesn’t expose a way to customize this page with plugins, so I had to find my own way.
There are a few ways to do this:
- Maintaining a fork of Strapi
- Using patch-package to patch the component
- Writing a workaround without modifying the package
I went with (what seemed to be) the simplest option available to me and decided to write a workaround.
Strapi Extensibility
Strapi is relatively flexible and can be extended with plugins. Plugins can provide:
- Field types
- Pages in the admin panel
- Routes (map an HTTP path and verb to a controller)
- Controllers (receive requests and return responses)
- Content types
- Middlewares (intercept and modify requests/responses)
My first idea was to add a new page and redirect the admin panel login page to my custom one.
Redirects with Middleware
With Strapi, you can add a new middleware to the stack in your config/middlewares.ts
file. The default looks something like this:
If you create a new file in src/middlewares
, you can reference it in the config/middlewares.ts
file to register it.
For example, I created src/middlewares/login-page.ts
and added "global::login-page"
to the list.
Note: This is a global middleware. If I could edit the definition of the route I wanted to modify, I could have added a route-specific middleware instead of applying a middleware to every request and filtering paths manually.
Strapi uses Koa internally, so its middlewares follow Koa patterns.
The middleware function receives a request context and next
function.
By changing where you call next()
in our middleware, you can run code before and/or after the request is processed.
For more details, see this diagram from the Strapi docs (found on this page).
For my use case, I am just setting a response header, so the location of the next()
call doesn’t make a difference.
You could also register a route, like this example from the Strapi docs. Both solutions produce the same outcome, but I prefer the one here because it is easier to understand without digging into Strapi’s routing system.
A curveball: Client-side rendering
The redirect middleware works if you navigate directly to the login page in your browser; however, when you log out, the navigation is handled on the client side, so you see the default Strapi login page. The redirect only occurs if you reload the page.
In hindsight, that problem was pretty obvious, but that’s what it’s like being a programmer :)
This is where my “workaround” gets promoted to “hacky workaround.” Strapi uses React Router 5 for the admin panel, but it doesn’t expose a way for me to add or modify client-side routes. I really should have patched the package, but instead, I had some more fun with middleware.
I kept my existing middleware, but I added an interceptor for HTML pages.
Note: I used
startsWith
instead of an equality comparison because HTMLContent-Type
headers can specify additional directives after the content type. Strapi adds acharset
directive, so itsContent-Type
header looks like:Content-Type: text/html; charset=utf-8
Strapi defines its initial page HTML here and, during the build, pre-renders it to a static file here.
When serving static files, the response body (stored in ctx.body
) is a Node.js Readable stream.
Even though the files transferred were small, I wanted to challenge myself and inject the content without waiting for it to be read completely and converting it into a string.
What a great excuse to learn about…
Transform Streams!
Transform streams are a Node.js and web-standard feature that allows you to receive data from a readable stream, modify it, and write it to a writable stream.
We will be discussing the Node.js-specific stream.Transform
API, but the web standard version works similarly.
Here’s a simple example:
The Node.js documentation has a full guide on implementing a transform stream.
To use it, we would pipe
our Readable into it:
The content of newReadableStream
would be based on the input read from readableStream
and modified by transformStream
.
In practice, our solution would look something like this:
Now, we can hack together a script that runs when the user navigates to the login page on the client:
Put this into the customHTML
variable inside a <script>
tag, and it should run on all HTML pages that Strapi serves.
It’s not beautiful, but it works! :)