DEV Community

Cover image for Building Server-Side Rendering (SSR) Applications with Deno and JSX
Francisco Mendes
Francisco Mendes

Posted on

Building Server-Side Rendering (SSR) Applications with Deno and JSX

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 .
Enter fullscreen mode Exit fullscreen mode

Inside deno.json we will add the following tasks:

{
  "tasks": {
    "dev": "deno run --watch app/main.ts",
    "build": "deno compile app/main.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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");
};
// ...
Enter fullscreen mode Exit fullscreen mode

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");
};
Enter fullscreen mode Exit fullscreen mode

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();
// ...
Enter fullscreen mode Exit fullscreen mode

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");
});
// ...
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
// ...
Enter fullscreen mode Exit fullscreen mode

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");
});
// ...
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
// ...
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Github Repo

Top comments (0)