Skip to main content

Build an Isomorphic Application using Deno and React without WebPack

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.

webpack

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

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.

tenet

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).

Set-up

Start with npm init

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

Backend

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

server.tsx
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

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.dev/.

jspm

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"
},

Create a common dependency deps.ts file

deps.ts
// @deno-types="https://denopkg.com/soremwar/deno_types/react/v16.13.1/react.d.ts"
import React from "https://jspm.dev/react@17.0.2";
// @deno-types="https://denopkg.com/soremwar/deno_types/react-dom/v16.13.1/server.d.ts"
import ReactDOMServer from "https://jspm.dev/react-dom@17.0.2/server";
// @deno-types="https://denopkg.com/soremwar/deno_types/react-dom/v16.13.1/react-dom.d.ts"
import ReactDOM from "https://jspm.dev/react-dom@17.0.2";

export { React, ReactDOM, ReactDOMServer };

Add below lines in server.tsx

server.tsx
import { Application, Router } from "https://deno.land/x/oak@v6.0.1/mod.ts";

import { React, ReactDOMServer, ReactDOM } from "./dep.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 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]

TS7026 [ERROR]: JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.
return

Hello SSR

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.

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

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

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

server.tsx
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.

Client Side App

Add List Todos to React App

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.tsx
function 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>
);
}
interface Todo {
task: string;
}
interface ListTodos{
items: Todo[]
}
function ListTodos({ items = [] }: ListTodos) {
return (
<div>
<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>
</div>
);
}
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.

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

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

todo png

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.

Create a file app.tsx:

app.tsx
import { React } from "./dep.ts";
/*
// enable in case of old react module
declare global {
namespace JSX {
interface IntrinsicElements {
[key: string]: any;
}
}
}
*/

interface AppProps {
todos?: Todo[];
}
interface Todo {
task: string;
}

function App({ todos = [] }: AppProps) {
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>
);
}
interface ListTodos {
items: Todo[];
}
function ListTodos({ items = [] }: ListTodos) {
return (
<div>
<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>
</div>
);
}
export default App;
note

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.tsx
import { React, ReactDOMServer, ReactDOM } from "./dep.ts";

import App from "./app.tsx";

/// 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
);

// 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.

Adding delete functionality on client-side

app.tsx
function ListTodos({ items = [] }: ListTodos) {
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.emit to create hydrate js for the client-side.

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

client.tsx
import { React, ReactDOM } from "./dep.ts";

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 { files } = await Deno.emit("./client.tsx", { bundle: "module" });
const clientJS = files["deno:///bundle.js"] || "";

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.emit, 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"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
"lib": [
"DOM",
"ES2017",
"deno.ns"
] /* Specify library files to be included in the compilation. */,
"strict": true /* Enable all strict type-checking options. */,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
"skipLibCheck": true /* Skip type checking of declaration files. */,
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}
}
note

You can use runtime compile 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.emit on runtime.

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.tsx
function 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>

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")
);

After the changes, all the files will look as below.

app.tsx
import { React } from "./dep.ts";
interface AppProps {
todos?: Todo[];
}
interface Todo {
task: string;
}

function App({ todos = [] }: AppProps) {
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>
);
}
interface ListTodos {
items: Todo[];
}
function ListTodos({ items = [] }: ListTodos) {
const [deletedIdxs, setDeletedIdxs] = (React as any).useState([]);
return (
<div>
<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" : "green" }}
>
{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>
</div>
);
}

export default App;
client.tsx
import { React, ReactDOM } from "./dep.ts";

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")
);
server.tsx
import { Application, Router } from "https://deno.land/x/oak@v6.0.1/mod.ts";

import { React, ReactDOMServer } from "./dep.ts";

import App from "./app.tsx";

const app = new Application();

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: any) => {
context.response.body = Array.from(todos.values());
})
.get("/todos/:id", (context: any) => {
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: any) => {
const body = context.request.body();
if (body.type === "json") {
const todo = await body.value;
todos.set(Date.now(), todo);
}
context.response.body = { status: "OK" };
});

const { files } = await Deno.emit("./client.tsx", { bundle: "module" });
const clientJS = files["deno:///bundle.js"] || "";

const serverrouter = new Router();
serverrouter.get("/static/client.js", (context: any) => {
context.response.headers.set("Content-Type", "text/html");
context.response.body = clientJS;
});
app.use(router.routes());
app.use(serverrouter.routes());

app.use(router.allowedMethods());

console.log("server is running on http://localhost:8000/");
await app.listen({ port: 8000 });

function handlePage(ctx: any) {
try {
const body = ReactDOMServer.renderToString(
<App todos={[]} /> // change here to pass todos as props
);
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>
<script>
window.__INITIAL_STATE__ = {"todos": ${JSON.stringify(
Array.from(todos.values())
)}};
</script>
</head>
<body >
<div id="root">${body}</div>
<script src="http://localhost:8000/static/client.js" defer></script>
</body>
</html>`;
} catch (error) {
console.error(error);
}
}
package.json
{
"name": "deno-react-ssr",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "deno run --allow-net --allow-read --unstable server.tsx -c tsconfig.json",
"start:clean": "deno run --allow-net --allow-read --unstable --reload server.tsx -c tsconfig.json",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

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.

Final Domo

todo gif

Hope you like this tutorial, Please follow me and clap for me on medium: isomorphic-application