When it comes to building multi-page applications (MPAs), there are countless languages and frameworks to choose from. But there are a few that focus on the most basic language for building MPAs – HTML.
With most of the options out there, you need to learn how to install and configure many packages to get the most basic MPA ready for development.
What if there was a framework that let you build your app with a simple HTML file and work your way to adding more pieces to it – like styling, interactions, and data? And at any point, you would be able to deploy your app, even with zero JavaScript.
Enhance is a framework that can do all of that.
In this tutorial, you will learn what Enhance is and how to build an example project with it. I will also show you how the progressive enhancement mindset can improve the way you build websites.
To jump ahead:
Its official documentation defines Enhance as “a web standards-based HTML framework.” This means that it allows us to build websites using the standards that the web platform provides us with.
This is good for multiple reasons. First, our app’s bundle size can be smaller because we don’t need to install extra libraries to get basic things done. Second, our website will be more performant because it would be using what was optimized for the browser to run. Third, it makes team collaboration much easier because all developers know the standards, so there will be fewer things to learn.
Building the application’s user interface in Enhance is easy because it’s based on web components. So, we get all the great benefits, like reusability, composability, and scoped styling.
To make things even easier, Enhance doesn’t require us to work with the web component API directly (although we can). Instead, we can simply return an HTML string from the component, and it would render it as expected.
Enhance handles all the necessary work to get web components working in SSR, which isn’t a pleasant experience if you need to do it yourself.
Enhance also provides us with a simple way to add pages to our app through its file-system based router. It’s not only used for adding pages for the user to see but it also allows us to add API routes to handle server requests and provide the data that our app needs. I’ll show you how to create both route types later in this tutorial.
Enhance was built on the idea of progressive enhancement. Progressive enhancement means that you don’t need to work on every aspect of your app (like JavaScript, fetching data, or styling) to get your app ready for testing or even deployment.
With progressive enhancement in mind, you can build your app incrementally. So you start with your HTML, extract some pieces to components, provide hard-coded data to your app, fetch real data, and add user interactivity to your forms with JavaScript in the browser.
At any point in the steps above, you should be able to test your app or even deploy it — and you can enhance it later.
Now, we have an idea of what Enhance is and why we would want to use it. Let’s learn how to build a simple app to see it in action.
The app we’ll build will be very simple so we can focus on the concepts. In this app, we will show a form with a text input, and when it’s submitted, the server will convert the entered text to uppercase.
The first version of the app wont use JavaScript in the browser. This means that after the user submits the form, it will reload the page to show the result.
After that, we will enhance it with JavaScript so we can see the result immediately on the page, without reloading.
Let’s start by creating a new Enhance project. First, make sure you have Node v16 or higher installed on your machine. Then, run this from your terminal:
npm create "@enhance" ./enhance-uppercase -y
Next, install the dependencies and start the project:
cd ./enhance-uppercase npm install npm start
After that, you should see your app running at http://localhost:3333/.
Routing in Enhance is based on the file system, which means that the URL of the page you add will be based on where you define the page file and what you name that file.
All page routes should live under app/pages/
. If it’s named index.html
, then it will be the root page – i.e. /
. So, to add /about
page, for example, you need to add app/pages/about.html
.
You can learn more about routing from the Enhance docs. In this example, we will be working on the homepage, app/pages/index.html
.
So, page routes are for the pages the user sees. If we want to define API routes, then we can follow the same convention except we should define them in app/api/
.
We know where to define API routes, but we don’t know what they are for. Let’s explain that here.
When the user requests a page in Enhance, among other things, the router matches the user request’s URL with the routes the developer has defined in the app.
So, if the user requests /about
page, then the router looks for app/pages/about.html
to serve to the user. That’s when we don’t define an API route for that page. If the app also has an API route defined for that request, then it would execute app/api/about.mjs
before app/pages/about.html
is served.
This gives us the chance to fetch and process any data before serving the page to the user. So, in Enhance, we can return any data we want from the API route of the requested page to the requested page.
To understand this better, let’s see a code example.
If we take the About page example, we would have app/api/about.mjs
:
export async function get (request) { return { json: { userBio: 'example bio' } } }
So, when the user requests /about
, we will return JSON data from get
(because it is a GET request).
To consume that data, we can access it from the state
object passed to the page. So, the page route should be defined in app/pages/about.mjs
. Note how we defined it as .mjs
instead of .html
to access the data:
export default function about ({ html, state }) { return html User bio: ${state.userBio}
Back in our project, we need to add a form with a text input. So, replace everything in app/pages/index.html
with:
<style> main { width: 500px; margin: 100px auto; font-family: sans-serif; } form { display: flex; align-items: center; margin-bottom: 10px;; } input { border: 1px solid #888; border-radius: 4px; margin-right: 10px; padding: 3px 10px; } button { background: #eee; padding: 5px 10px; border-radius: 4px; } </style> <main> <form action="/"> <input type="text" name="text" /> <button>Convert</button> </form> <div class="result"> Result: <span class="output"></span> </div> </main>
If you check your browser, you will see the text input, the submit button, and the result section below that.
This is a simple example, but what if you want to use this form in multiple places? Then, it’s better to extract it as a custom element.
In Enhance, you can define custom elements in app/elements/
. So, let’s create an elements
directory in app/
, and then add uppercase-form.mjs
into app/elements/
.
Custom elements are defined as pure functions. So, let’s define that element like this:
export default function UppercaseForm({ html }) { return html` <style> main { width: 500px; margin: 100px auto; font-family: sans-serif; } form { display: flex; align-items: center; margin-bottom: 10px;; } input { border: 1px solid #888; border-radius: 4px; margin-right: 10px; padding: 3px 10px; } button { background: #eee; padding: 5px 10px; border-radius: 4px; } </style> <main> <form action="/"> <input type="text" name="text" /> <button>Convert</button> </form> <div class="result"> Result: <span class="output"></span> </div> </main> ` }
So, it’s similar to what we have in pages/index.html
except that the HTML is rendered using the html
function provided in the parameters.
To use it in pages/index.html
, just remove everything and add:
<uppercase-form />
Note how we don’t need to import anything. Enhance automatically imports all custom elements in the pages.
The form we created has action
set to /
. This means that when the form is submitted, it will send a GET request to http://localhost:3333/
. Because we already have a page defined at pages/index.html
, the response will display that page. But, we also need to return the uppercase version of the text with that response. To do that, we need to define a GET API route for /
.
Following the API routes convention, we should define it in app/api/index.mjs
.
So, add that file and put the following into it:
export function get (req) { const text = req.query.text || '' const transformedText = text.toUpperCase() return { json: { transformedText } } }
In the code above, we defined a function with the name get
because it should handle the GET request of that page.
Because it’s a GET request, we access the form data through the request query string, which is req.query
. The text value is available as text
because the input has name="text"
.
If the text is not available in the request (which means the form has not yet been submitted), then we default back to an empty string const text = req.query.text || ''
.
After we convert the text to uppercase, we return it as a JSON response. This way we can access it from our custom elements through state.store.transformedText
. Let me show you how to do it in the next section.
As I mentioned in the section above, we can access JSON data in our custom elements through the store
we get in the parameters.
So, update the parameters of the custom element to this:
export default function UppercaseForm({ html, state }) {
Then, display it in <span class="output">
.
<div class="result"> Result: <span class="output">${state.store.transformedText}</span> </div>
Now, if you submit the form, you should see the text converted to uppercase and displayed in the result section.
The app is working as expected and can be deployed. However, we can improve it by displaying the result without reloading the page. To do that, let’s enhance our app with some JavaScript on the client side.
To make the custom element available on the client side (i.e. JavaScript in the browser), we need to redefine the element for the browser. We’ll do this in a <script>
tag.
For simplicity, let’s define it as an inlined JavaScript in our custom element HTML in app/elements/uppercase-form.mjs
.
So, add this <script>
in the custom element HTML below </main>
.
<script type="module"> class UppercaseForm extends HTMLElement { constructor () { super() this.form = this.querySelector('form') this.output = this.querySelector('.output') this.form.addEventListener('submit', this.onSubmit.bind(this)) } async onSubmit (e) { e.preventDefault() const formData = new FormData(e.target) const queryString = new URLSearchParams(formData).toString() const result = await fetch('/?' + queryString, { headers: { 'accept': 'application/json' }, method: 'get' }) const json = await result.json() this.output.textContent = json.transformedText } } customElements.define('uppercase-form', UppercaseForm) </script>
After adding this, the uppercase-form
element will be kind of “hydrated” on the browser side.
Now when the user submits the form, the client JavaScript will handle it by sending the request using the browser’s native Fetch API. But note how we needed to set headers to { 'accept': 'application/json' }
to make our API route return it as a JSON response instead of a regular HTML response.
If you test the app now, it should convert the text and display it without reloading the page.
The great thing about this approach is that our app will still work even if JavaScript is disabled in the browser because it’s progressively enhanced.
There are still more things to learn about Enhance. I encourage you to check out the docs to learn about all of its features.
The important thing we explored in this tutorial is how powerful Enhance is when we want to build our apps with progressive enhancement in mind.
After finishing the project in this tutorial, I recommend trying to build a counter app without JavaScript, and then improving it with JavaScript. If you need help with that, check out how it was built in an example from the docs.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.