In recent times, one of the most talked about topics in the JavaScript Frontend community has been SRR Streaming using Frontend Frameworks to create a Full Stack application.
Nowadays there are numerous backend frameworks that allow you to use JSX instead of templating engines to create application UIs and in this article we will take advantage of this Hono.js functionality.
Introduction
In this article we will set up a project in Deno, we will define a base Layout that can be reused by different pages, we will define the routes and pages of our application, as well as we will protect these same routes to ensure that you have access or not through the status of the app.
To give you a little more context, in this article we are going to use the following technologies:
- Hono - a minimal and fast JavaScript framework
Before starting this article, I recommend that you have Deno installed and that you have a brief experience using Node.
Scaffold Deno Project
The first thing that needs to be done is to generate the project's base files with the following command:
deno init .
Inside deno.json
we will add the following tasks:
{
"tasks": {
"dev": "deno run --watch app/main.ts",
"build": "deno compile app/main.ts"
}
}
Still in deno.json
, we will define the imports of our project and which dependencies we will use:
{
// ...
"imports": {
"hono": "https://deno.land/x/hono@v3.11.9/mod.ts",
"hono/middleware": "https://deno.land/x/hono@v3.11.9/middleware.ts",
"hono/streaming": "https://deno.land/x/hono@v3.11.9/jsx/streaming.ts",
"hono/helpers": "https://deno.land/x/hono@v3.11.9/helper.ts"
}
}
Now we can run the command deno task dev
and it will watch the changes we make to the project and hot reload it.
Create Layout
The effort in this step involves defining the base document that will be reused by the application. Ideally this document should have specified which libraries we will use through the CDN, meta tags, navigation components and everything else.
In today's article we will create the following:
/** @jsx jsx */
/** @jsxFrag Fragment */
import { type FC, jsx } from "hono/middleware";
export const Document: FC<{ title: string; }> = (props) => {
return (
<html>
<head>
<title>{props.title}</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/water.css@2/out/water.css"
/>
</head>
<body>{props.children}</body>
</html>
);
};
In the component above we created the base structure of the HTML document, we defined it as a functional component and we have two props, one which is the title of the page and the other is the children of the component. Just like we added a css library to style our application.
Authorization Middlewares
In this step we will create two middlewares with different functions but with the same purpose, checking the user's session status and redirecting them to the desired page.
Starting with the definition of isLoggedIn
, the idea is to allow the user to visit a protected page if they have a session, otherwise they will be redirected to one of the authentication pages.
import type { Context, Next } from "hono";
import { getCookie } from "hono/helpers";
export const isLoggedIn = async (c: Context, next: Next) => {
const session = getCookie(c, "session");
if (session) return await next();
return c.redirect("/auth/sign-up");
};
// ...
Let's create the isLoggedOut
function, which would be used on authentication pages, to ensure that the user who already has a session does not fall into the trap of creating a new one. So if you have a session, you are redirected to a protected route.
// ...
export const isLoggedOut = async (c: Context, next: Next) => {
const session = getCookie(c, "session");
if (!session) return await next();
return c.redirect("/protected");
};
Page creation
Now that we have the base document and the necessary middlewares, we can move on to defining each of the pages of our application.
Each page will be an isolated router that will contain two endpoints, one for rendering the UI and the other for processing data from the frontend.
Signup
Starting by importing the necessary dependencies and modules:
/** @jsx jsx */
/** @jsxFrag Fragment */
import { jsx } from "hono/middleware";
import { renderToReadableStream } from "hono/streaming";
import { Hono } from "hono";
import { setCookie } from "hono/helpers";
import { Document } from "../layouts/Document.tsx";
import { isLoggedOut } from "../middlewares/authorization.ts";
const router = new Hono();
// ...
Next we will define the route that will be responsible for handling the processing of frontend data, which in this case will be obtaining the value of the username through which we create a session and then redirect it to the protected route. If the username is not provided, we will remain on the same page.
// ...
router.post("/", isLoggedOut, async (c) => {
const data = await c.req.formData();
const username = data.get("username")?.toString();
if (username) {
setCookie(c, "session", username);
return c.redirect("/protected");
}
return c.redirect("/auth/sign-up");
});
// ...
The next step is to define our UI, in which we will define a form with just two fields, one for the username and the other for the password. Then the form data will be submitted to the action that we defined previously.
// ...
router.get("/", isLoggedOut, (c) => {
const stream = renderToReadableStream(
<Document title="Sign up Page">
<form action="/auth/sign-up" method="POST">
<div>
<label for="username">Username:</label>
<input
id="username"
name="username"
type="text"
value=""
minLength="3"
required
/>
</div>
<div>
<label for="password">Password:</label>
<input
id="password"
name="password"
type="password"
value=""
minLength="8"
required
/>
</div>
<div>
<button type="submit">Create Account</button>
<a href="/auth/sign-in">
<small>Go to Login</small>
</a>
</div>
</form>
</Document>,
);
return c.body(stream, {
headers: {
"Content-Type": "text/html; charset=UTF-8",
"Transfer-Encoding": "chunked",
},
});
});
export default router;
Protected Page
On this page we will take a very similar approach to what we did in the previous point. This time we are going to use a set of very interesting and familiar Hono primitives.
First, we import the necessary dependencies and modules:
/** @jsx jsx */
/** @jsxFrag Fragment */
import { deleteCookie, ErrorBoundary, type FC, jsx } from "hono/middleware";
import { renderToReadableStream, Suspense } from "hono/streaming";
import { Hono } from "hono";
import { getCookie } from "hono/helpers";
import { Document } from "../layouts/Document.tsx";
import { isLoggedIn } from "../middlewares/authorization.ts";
const router = new Hono();
// ...
Next, we define the data processing route, which in this case will only remove the current session and redirect the user to the authentication page.
// ...
router.post("/", isLoggedIn, (c) => {
deleteCookie(c, "session");
return c.redirect("/auth/sign-in");
});
// ...
Now, to be different from the previous page, we will create a component that will consume an external API such as JSONPlaceholder. And after obtaining this data we will list it all in an unordered list.
// ...
const TodoList: FC = async () => {
const response = await fetch("https://jsonplaceholder.typicode.com/todos");
const todos = await response.json() as Array<{ id: number; title: string }>;
return (
<ul>
{todos.map((todo) => <li>{todo.title}</li>)}
</ul>
);
};
// ...
Now in our UI we will get the current session to render the username in HTML and define a small form to allow them to end the session.
Then, taking advantage of primitives like ErrorBoundary
and Suspense
. This will allow the page to be rendered on the server side and while the promise of the HTTP request we make to the API is not yet resolved, we show a fallback with a loading state.
If an error occurs resolving the promise or there is a rendering error, the error bubbles up and is caught by the error boundary, instead of the page breaking, it shows an error message.
// ...
router.get("/", isLoggedIn, (c) => {
const session = getCookie(c, "session");
const stream = renderToReadableStream(
<Document title="Protected Page">
<div>
<h1>Protected route!</h1>
<p>Hello {session}</p>
<form action="/protected" method="POST">
<button type="submit">Logout</button>
</form>
</div>
<ErrorBoundary
fallback={<h4>An error has occurred, please try again.</h4>}
>
<Suspense fallback={<h4>Loading...</h4>}>
<TodoList />
</Suspense>
</ErrorBoundary>
</Document>,
);
return c.body(stream, {
headers: {
"Content-Type": "text/html; charset=UTF-8",
"Transfer-Encoding": "chunked",
},
});
});
export default router;
Set up App instance
In this step, we are going to import the necessary middlewares for the application, not forgetting the routers that were created just now and serve the app. Like this:
import { Hono } from "hono";
import { getCookie } from "hono/helpers";
import SignUp from "./pages/SignUp.tsx";
import Protected from "./pages/Protected.tsx";
const app = new Hono();
app.get("/", (c) => {
const session = getCookie(c, "session");
const path = session ? "/protected" : "/auth/sign-up";
return c.redirect(path);
});
app.route("/auth/sign-up", SignUp);
app.route("/protected", Protected);
Deno.serve({ port: 3333 }, app.fetch);
In the code above, as you may have noticed, a root route was defined that checks whether the user has a session or not and from there redirects the user to the available pages.
Conclusion
I hope you found this article helpful, whether you're using the information in an existing project or just giving it a try for fun.
Please let me know if you notice any mistakes in the article by leaving a comment. And, if you'd like to see the source code for this article, you can find it on the github repository linked below.
Top comments (0)