Build an Isomorphic Application Using Deno and React Without WebPack

Deepak Vishwakarma
Aug 8, 2020 · 9 min read

Currently setting up a Server Side Render (SSR) application is a pain in nodejs. There are many scaffolds available for nodejs. But it comes with its own tech-depth and learning curves. This also includes hidden configurations of Webpack.

All in all, when you give Webpack a chance, your encounter will rarely be a pleasant one.

Read More: https://www.north-47.com/knowledge-base/webpack-the-good-the-bad-and-the-ugly/

1. Overview

According to the wiki, An isomorphic JavaScript(also known as Universal JavaScript) is described as JavaScript applications that run both on the client and the server.

https://unsplash.com/photos/c_Hi3DzlC0g

If I say, you can build an entire SSR without setting up installing any external nodejs dependency. Would you believe it? I guess NO.

However, In this tutorial, I will explain how to set up a simple SSR app without installing a single nodejs library or bundler. That also including a hydrate react app(isomorphic app).

2. Set-up

a. Start an app with npm inits: Don’t be afraid, To do things differently, we will not install any nodejs libraries. However, I still like npm as a task runner. So let’s use it. Create a folder SSR and init npm package.json

$ md -p examples/ssr$ cd examples/ssr## init npm package
$ npm init --y

3. Backend

a. Add Basic Deno server: Create server.tsx a file and add below code

import { Application, Router } from "https://deno.land/x/oak@v6.0.1/mod.ts";const app = new Application();const router = new Router();
router.get("/", handlePage);
app.use(router.routes());
app.use(router.allowedMethods());
console.log("server is running on http://localhost:8000/");
await app.listen({ port: 8000 });
function handlePage(ctx: any) {
try {
ctx.response.body = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body >
<div id="root"><h1>Hello SSR</h1></div>
</body>
</html>`;
} catch (error) {
console.error(error);
}
}

note: We will use oak module here to create Deno server. You can create your own server. For that read my article Creating Routing/Controller in Deno Server(From Scratch)

Add below command in package.json.

"scripts": {
"start": "deno run --allow-net server.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},

Run: Now we can run the application and verify on http://localhost:8000/.

npm run start

b. Add React Server Render: Now we can run the application. Let us add our first rendering code. For that, we need to ReactJS. Since Deno uses ES Module import, We will use the CDN hosted version of react and react-dom. For that, there is a good CDN provider https://jspm.org/

jspm provides a module CDN allowing any package from npm to be directly loaded in the the browser and other JS environments as a fully optimized native JavaScript module.

Now since we are going to write some TSX syntax(typescript JSX). We have to change the file extension of server.ts to server.tsx. Let’s do that and update package.json.

mv server.ts server.tsx

// package.json
"scripts": {
"start": "deno run --allow-net server.tsx",
"test": "echo \"Error: no test specified\" && exit 1"
},

c. Add below lines in server.tsx

import { Application, Router } from "https://deno.land/x/oak@v6.0.1/mod.ts";import React from "https://dev.jspm.io/react@16.13.1";
import ReactDomServer from "https://dev.jspm.io/react-dom@16.13.1/server";
const app = new Application();const router = new Router();
router.get("/", handlePage);
app.use(router.routes());
app.use(router.allowedMethods());
console.log("server is running on http://localhost:8000/");
await app.listen({ port: 8000 });
function App() {
return <h1>Hello SSR</h1>;
}
function handlePage(ctx: any) {
try {
const body = ReactDomServer.renderToString(<App />);
ctx.response.body = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body >
<div id="root">${body}</div>
</body>
</html>`;
} catch (error) {
console.error(error);
}
}

Run the app again. You will see errors on the console.

TS7026 [ERROR]: JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.
return <h1>Hello SSR</h1>

This error is due to missing typings to react. Since we do not include types to react. We have to let know the typescript compiler. How it should treat JSX(TSX) syntax.

To suppress these errors, Add below lines.

declare global {
namespace JSX {
interface IntrinsicElements {
[key: string]: any;
}

}
}
function App() {
return <h1>Hello SSR</h1>;
}

Now run the server again. You can see your first React SSR running on the browser. Nice!

d. Adding Server Controller- Create Backend APIs

Let’s move further and start adding a few core features for Server. Let’s add some server-side data for our app. For that, we will include a few routes on Oak Server. Oak

const router = new Router();
router.get("/", handlePage);
let todos: Map<number, any> = new Map();function init() {
todos.set(todos.size + 1, { id: Date.now(), task: "build an ssr deno app" });
todos.set(todos.size + 1, {
id: Date.now(),
task: "write blogs on deno ssr",
});
}
init();
router
.get("/todos", (context) => {
context.response.body = Array.from(todos.values());
})
.get("/todos/:id", (context) => {
if (
context.params &&
context.params.id &&
todos.has(Number(context.params.id))
) {
context.response.body = todos.get(Number(context.params.id));
} else {
context.response.status = 404;
}
})
.post("/todos", async (context) => {
const body = context.request.body();
if (body.type === "json") {
const todo = await body.value;
todos.set(Date.now(), todo);
}
context.response.body = { status: "OK" };
});
app.use(router.routes());
app.use(router.allowedMethods());

Here in the above code, We have created three routes.

  1. GET /todos/ to get a list of the todos
  2. GET /todos/:id to todo by id
  3. POST /todos/ create a new todo

function init() to create some initial dummy todos. You can use postman to try-out get and post data.

4. Client-Side App

a. Add List Todos to React Client: Since now we have API to create todos and consume todos. Let’s list down all this on our react app. For that add the below-mentioned code.

// server.tsxfunction App() {
return (
<div>
<div className="jumbotron jumbotron-fluid">
<div className="container">
<h1 className="display-4">ToDo's App</h1>
<p className="lead">This is our simple todo app.</p>
<ListTodos items={Array.from(todos.values())} />
</div>
</div>
</div>
);
}
function ListTodos({ items = [] }: any) {
return (
<>
<ul className="list-group">
{items.map((todo: any, index: number) => {
return (
<li key={index} className="list-group-item">
{todo.task}
<button
type="button"
className="ml-2 mb-1 close"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
</li>
);
})}
</ul>
</>
);
}
function handlePage(ctx: any) {
try {
const body = ReactDomServer.renderToString(<App />);
ctx.response.body = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<title>Document</title>
</head>
<body >
<div id="root">${body}</div>
</body>
</html>`;

Update all the changes and run the app. You will see a list of Todos containing two rows of initial data. You can use curl post data to route POST/todos/ to create new records. Once you add a post, refresh the page, You will see added new post data.

curl --header "Content-Type: application/json" \
--request POST \
--data '{"task":"Create postman script"}' \
http://localhost:8000/todos/

If you noticed, I have added basic bootstrap to make UI nicer. You can use some other CSS library.

Tada! Now you have running the SSR app. You can replace the in-memory todos store to any persistent database. The result will be the same.

Now time to add some interactive behavior in Our react app(client-side). But before doing that, let’s move our react code to some separate file app.tsx.

b. Create a file app.tsx:

import React from "https://dev.jspm.io/react@16.13.1";declare global {
namespace JSX {
interface IntrinsicElements {
[key: string]: any;
}
}
}
function App({ todos = [] }: any) {
return (
<div>
<div className="jumbotron jumbotron-fluid">
<div className="container">
<h1 className="display-4">ToDo's App</h1>
<p className="lead">This is our simple todo app.</p>
<ListTodos items={todos} />
</div>
</div>
</div>
);
}
function ListTodos({ items = [] }: any) {
return (
<>
<ul className="list-group">
{items.map((todo: any, index: number) => {
return (
<li key={index} className="list-group-item">
{todo.task}
<button
type="button"
className="ml-2 mb-1 close"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
</li>
);
})}
</ul>
</>
);
}
export default App;

Notice the change in the App component. Since we do not have direct access to todos now, We need to pass data as props while rendering it. Corresponding changes have been done for ListTodos.

// server.tsximport React from "https://dev.jspm.io/react@16.13.1";
import ReactDomServer from "https://dev.jspm.io/react-dom@16.13.1/server";
import App from "./app.tsx";
/// rest of the codefunction handlePage(ctx: any) {
try {
const body = ReactDomServer.renderToString(
<App todos={Array.from(todos.values())} /> // change here to pass todos as props
);
// rest of the code
}

Run the app and see changes on the browser, If all good there will be no change in the final output.

c. Adding delete functionality on client-side

// app.tsxfunction ListTodos({ items = [] }: any) {
const [deletedIdxs, setDeletedIdxs] = (React as any).useState([]);
return (
<>
<ul className="list-group">
{items.map((todo: any, index: number) => {
const deleted = deletedIdxs.indexOf(index) !== -1;
return (
<li
key={index}
className="list-group-item"
style={{ color: deleted && "red" }}
>
{todo.task}
<button
type="button"
className="ml-2 mb-1 close"
aria-label="Close"
onClick={() => setDeletedIdxs([...deletedIdxs, index])}
>
<span aria-hidden="true">&times;</span>
</button>
</li>
);
})}
</ul>
</>
);
}

Once you do the above changes and try to delete by clicking on cross-button. You will see no change in UI. By code, it should turn the element color to red. So what could be the reason for that?

Answer: Hydrate

Since we are using ReactDomServer.renderToString the library which converts React app to string. So we lose all JS capabilities. To re-enable react js on the client-side. For that React provides you Hydrate module(API). This hydrate API re-enable the react feature on the client-side again. This makes our app Isomorphic app. More: Hydrate

Adding hydrate is a tough task to do. But Awesome Deno shines well here too. Deno provides Bundle API to convert a script to js. We will use Deno.bundle to create hydrate js for the client-side.

d. Create a new file client.tsx and add below codes:

// client.tsximport React from "https://dev.jspm.io/react@16.13.1";
import ReactDom from "https://dev.jspm.io/react-dom@16.13.1";
import App from "./app.tsx";(ReactDom as any).hydrate(<App todos={[]} />, document.getElementById("root"));

Add below codes to compile and convert client.tsx to serve as a route in our server.

// server.tsx// initial code
const [_, clientJS] = await Deno.bundle("./client.tsx");
const serverrouter = new Router();
serverrouter.get("/static/client.js", (context) => {
context.response.headers.set("Content-Type", "text/html");
context.response.body = clientJS;
});
app.use(router.routes());
app.use(serverrouter.routes());
// rest of the code
function handlePage(ctx: any) {
try {
const body = ReactDomServer.renderToString(
<App todos={Array.from(todos.values())} /> // change here to pass todos as props
);
ctx.response.body = `<!DOCTYPE html>
<html lang="en">
<!--Rest of the code -->
<div id="root">${body}</div>
<script src="http://localhost:8000/static/client.js" defer></script>
</body>
</html>`;
} catch (error) {
console.error(error);
}

Since we are using unstable API deno.bundle, You have to update package.json and add more flags. Same time, We are using DOM with typescript. So we have to add custom tsconfig.json.

// package.json{
"scripts": {
"start": "deno run --allow-net --allow-read --unstable server.tsx -c tsconfig.json",
"test": "echo \"Error: no test specified\" && exit 1"
}
}

e. Create a filetsconfig.json:

// tsconfig.json{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"lib": [
"DOM",
"ES2017",
"deno.ns"
]
,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

Note: You can use bundler as CLI to convert client.tsx before even starting the server. However, I just wanna show a cool way of doing it. So I use Deno.bundle on runtime.

4. Final Touch

Initialize initial state: Once you do all the above-mentioned changes, Re-Run app. You will notice the list is the visible and hidden same time. This is because we react hydrate start working and it is trying to re-initialize the app. So all the data we render from the server is gone to persist data we need to pass data as application initial data. There are a lot of patterns to pass initial data. We will use the simple window global data.

Let’s start data on the window after making below changes on the given files.

// server.tsxfunction handlePage(ctx: any) {
try {
const body = ReactDomServer.renderToString(
<App todos={[]} />
);
ctx.response.body = `<!DOCTYPE html>
<title>Document</title>
<script>
window.__INITIAL_STATE__ = {"todos": ${JSON.stringify(
Array.from(todos.values())
)}};

</script>
</head>

Let’s update client.tsx accordingly.

// client.tsx// initial codes
declare global {
var __INITIAL_STATE__: any;
}
import App from "./app.tsx";
const { todos } = window.__INITIAL_STATE__ || { todos: [] };
(ReactDom as any).hydrate(
<App todos={todos} />,
document.getElementById("root")
);

Now you have a running, working SSR/Isomorphic App that is fully written in Deno. We didn’t use any nodejs/npm modules or WebPack.

Thanks for reading this tutorial. Please follow me to support me. For more of my work, check-out my website https://decipher.dev/.

You can find all the code in examples/ssr folder on my Github repo.

Demo:

Originally published at https://decipher.dev.

The Startup

Get smarter at building your thing. Join The Startup’s +799K followers.

Deepak Vishwakarma

Written by

I am UI/UX lead developer, tech enthusiastic person. I am working in one of the biggest FinTech company, trying to solve basic challenges on ground issues.

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +799K followers.

Deepak Vishwakarma

Written by

I am UI/UX lead developer, tech enthusiastic person. I am working in one of the biggest FinTech company, trying to solve basic challenges on ground issues.

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +799K followers.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store