personal website
1---
2title: "Back to Basics: Making a Node.js Web Application"
3description: Taking a break from Javascript (meta) frameworks and making a web application and website with Hono and Node.js as the foundation.
4date: "2023-10-28"
5draft: false
6link: https://github.com/zeucapua/robin-tutorial
7---
8
9### Why?
10
11For my latest project, I wanted to get away from the bustling modern world of JS (meta) frameworks and return to the basics. Since I just started learning web development over a year ago, I’ve only been learning abstractions based on any given UI framework. But I wanted to know if there is a simpler way to understand and make small web applications? Here are my notes on how to make a small web application from start to finish!
12
13### What are we building?
14
15Robin is a project time tracker, inspired by [Watson CLI](https://github.com/TailorDev/Watson) tool. A user can create projects and simply clock in and clock out of a session. All sessions are counted to get a total time spent doing projects. The front end will allow for simple CRUD actions to manage the data.
16
17### The Stack
18
19Robin will be a Node.js (Node) web application, built with [Hono](http://hono.dev) as our server framework. Deployed on [Railway](http://railway.app) alongside a PostgreSQL database. The database is managed and query using [Drizzle ORM](http://orm.drizzle.team). We will be setting up the project so that we can create a front-end website using `tsx` components with [HTMX](http://htmx.org) for a future follow-up blog.
20
21If you want to see the codebase, check out the annotated [Github repository](https://github.com/zeucapua/robin-tutorial) and give it a star if you found it useful!
22
23### How does it work?
24
25
26
27Before writing any code, I think we should take a step back and check how websites work. When someone goes to a URL, the browser makes an HTTP GET request to the index endpoint. Endpoints are how clients, like our browser, can interact and tell the server to do things. In this case, the server starts turning the TSX template we wrote into HTML and returns it back with any Javascript to the browser. The browser then takes the HTML and JS to render the page so the user can look and interact with it.
28
29To put it in other words, we deal with a client making an HTTP request to the server that responds back with data we can parse and use. We can put all of our pages and CRUD actions into server endpoints that we can interact with.
30
31### Author’s Note
32
33This blog assumes **NOTHING** of the reader. That means that this blog will have sections setting up the project in *painfully detailed step-by-step instructions.* However, I will not be going over installing terminal commands like `npm/pnpm` , `tsc` , `git`, `gh`, etc. I will try my darnedest not to be sidetracked, and keep my focus on creating and deploying a Node.js web application, but no promises.
34
35### Installation
36
37Here’s how to get started (using a terminal):
38
39- Create a new folder (`mkdir robin-tutorial` ) and go inside it (`cd robin-tutorial`)
40- We’ll start a new Node project by using `pnpm init`, which should generate a `package.json` file.
41 - For this tutorial, we will be using `pnpm` , but `npm` should be similar (`pnpm init` = `npm init` , `pnpm add` = `npm install` , etc.)
42- From here we have to install our packages, which in our case are:
43 - Hono (our server framework): `pnpm add hono @hono/node-server`
44 - Dotenv (to access our `.env` variables): `pnpm add dotenv`
45 - Drizzle ORM (to manipulate our database): `pnpm add drizzle-orm pg` & `pnpm add -D drizzle-kit @types/pg`
46 - TSX (our HTML templates in TS): `pnpm add -D tsx`
47- Before moving on, you can look inside the folder to ensure that we have a `node_modules` folder, `package.json` file (which we change in a moment), and a `pnpm-lock.yaml` file (I assume this sets the packages’ version).
48- To setup TSX, run `tsc --init` to create a `tsconfig.json` that we will edit to ensure the following properties are not commented. Use a text editor to recreate the following:
49
50```json
51{
52 "compilerOptions": {
53 "target": "es2016",
54 "jsx": "react-jsx",
55 // some stuff...
56 "jsxImportSource": "hono/jsx",
57
58 // some stuff...
59 // the following are already set by `tsc --init`, but make sure anyway!
60 "module": "commonjs",
61 "esModuleInterop": true,
62 "forceConsistentCasingInFileNames": true,
63 "strict": true,
64 "skipLibCheck": true
65 }
66}
67```
68
69- Afterwards, let’s add a new `src` folder with our files inside: `index.tsx` (our app’s entry point), `components.tsx` (our JSX templates), and `schema.ts` (used to model our database with Drizzle).
70- Lastly, let’s modify our `package.json` and change our main file and add scripts to run our application, including some for using Drizzle (will be explained later):
71
72```json
73{
74 // ...
75 "main": "src/index.tsx",
76 "scripts": {
77 "start": "tsx src/index.tsx",
78
79 // for drizzle, will be used later
80 "generate": "drizzle-kit generate:pg",
81 "migrate": "drizzle-kit migrate:pg"
82 },
83 // ...
84}
85```
86
87### Did you know Hono means ‘Fire’ in Japanese?
88
89Hono is a Node server framework which makes coding endpoints easy. Other similar frameworks would be Elysia, Fastify, and Express.
90
91To start our project, start by creating a new `Hono` object and subsequently call functions with the appropriate HTTP request and endpoint. Afterwards export and serve the web app. This will be inside our `index.tsx` file.
92
93```tsx
94// index.tsx
95// ---------------------------------------
96
97/* 🪂 Import packages (installed via npm/pnpm) */
98
99// Hono packages
100import { Hono } from 'hono';
101import { serve } from "@hono/node-server";
102
103// loads environment variables from `.env`, will be used later
104import * as dotenv from "dotenv";
105dotenv.config();
106
107// ---------------------------------------
108
109/* 🏗️ Configure Hono Web Application */
110
111// initialize web application
112const app = new Hono();
113
114// ---------------------------------------
115
116/* 🛣️ Route Endpoints */
117
118// GET index page
119app.get("/", async (c) => {
120 // return HTML response
121 return c.html(
122 <h1>Hello world!</h1>
123 );
124});
125
126export default app;
127
128// ---------------------------------------
129
130/* 🚀 Deployment */
131
132// use `.env` set PORT, for Railway deployment
133const PORT = Number(process.env.PORT) || 3000;
134
135// become a server, to deploy as Node.js app on Railway
136serve({
137 fetch: app.fetch,
138 port: PORT
139});
140
141// ---------------------------------------
142```
143
144Now going back to the terminal, we can run our web application by using the start script from the `package.json` file that we set up earlier: `pnpm run start`. Use the browser and go to `[http://localhost:3000](http://localhost:3000)` and you should be greeted with a big bold “**Hello world!”**
145
146<aside>
147💡 If you’re familiar with modern JS (meta) frameworks, making any changes while a development (dev) server is running will cause a re-render, allowing you to see changes in styling, for example. This is because of HMR (Hot Module Reloading). We **don’t** have HMR in this project. So any further changes will require you to stop (`ctrl-c` in the terminal) and restart the dev server (`pnpm run start`).
148
149</aside>
150
151### Database Setup with Drizzle (fo’ shizzle)
152
153Now that we have the basic web application setup, let’s move our focus onto the database that we’ll use for our time tracking functions. Drizzle ORM (Object-Relational Mapping) is a library to manage and communicate with the database via Typescript (TS) code. We can use the ORM to create the source of truth for the database’s schema. Let’s set it (and our hosted DB) up!
154
155- Provision a new PostgreSQL (postgres) database on Railway by creating a new project.
156
157
158
159- Once deployed, go to the **Variables** tab on the postgres service and copy the `DATABASE_URL` value…
160
161
162
163- …which we will add to a new `.env` file in our root directory.
164
165```
166# .env
167DATABASE_URL=postgresql://<username>:<password>@<location>:<port>/<dbname>
168```
169
170- Moving on, we now need to define the shape of our data in our `schema.ts` file using Drizzle:
171
172```tsx
173// schema.ts
174// ---------------------------------------
175
176/* Import packages (installed via npm/pnpm) */
177// drizzle-orm packages
178import { relations } from "drizzle-orm";
179import { pgTable, serial, timestamp, varchar } from "drizzle-orm/pg-core";
180
181// ---------------------------------------
182
183/* Data Models */
184// >> find more information on defining the schema:
185// >> https://orm.drizzle.team/docs/sql-schema-declaration
186export const projects = pgTable("projects", {
187 id: serial("id").primaryKey(),
188 name: varchar("name", { length: 100 }).unique()
189});
190
191export const sessions = pgTable("sessions", {
192 id: serial("id").primaryKey(),
193 start: timestamp("start").defaultNow(),
194 end: timestamp("end"),
195 projectName: varchar("project_name").notNull()
196});
197
198/* Relationships Between Models */
199// find more information on declaring relations:
200// https://orm.drizzle.team/docs/rqb#declaring-relations
201export const projects_relations = relations(projects, ({ many }) => ({
202 sessions: many(sessions)
203}));
204
205export const sessions_relations = relations(sessions, ({ one }) => ({
206 project: one(projects, {
207 fields: [sessions.projectName],
208 references: [projects.name]
209 })
210}));
211
212// ---------------------------------------
213```
214
215This schema will create a one-to-many relationship where a **project** can have multiple **sessions**. Visually it’ll look like so, thanks to [DiagramGPT](https://www.eraser.io/diagramgpt):
216
217
218
219- To turn this schema into our database’s tables, we need to create a `drizzle.config.ts` file in the root directory to setup the migration correctly, giving it the schema file, the folder that will hold the migrations, and the `DATABASE_URL` as the connection string to the database.
220
221```tsx
222// ---------------------------------------
223
224/* Import packages (installed via npm/pnpm) */
225
226// to type check the configuration
227import type { Config } from "drizzle-kit";
228
229// load .env variables
230import * as dotenv from "dotenv";
231dotenv.config();
232
233// ---------------------------------------
234
235/* declare Drizzle config */
236export default {
237 schema: "./src/schema.ts",
238 out: "./drizzle",
239 driver: "pg",
240 dbCredentials: {
241 connectionString: process.env.DATABASE_URL as string
242 }
243} satisfies Config
244
245// ---------------------------------------
246```
247
248- Once that is set, we need to generate a SQL migration file using the `generate` script we made earlier inside the `package.json` file, then push the changes with the `migrate` script.
249
250```
251# scripts declared in 'package.json'
252
253# runs 'drizzle-kit generate:pg'
254pnpm run generate
255
256# runs 'drizzle-kit push:pg'
257pnpm run migrate
258```
259
260- Check your Railway deployment to see if the migration went through by ensuring our **projects** and **sessions** tables are in the postgres’ data tab.
261
262
263
264- Finally, import the relevant packages and setup the Drizzle client ready for use in the next
265
266```tsx
267// index.tsx
268// ---------------------------------------
269
270/* 🪂 Import packages (installed via npm/pnpm) */
271// ...
272
273// Database Driver
274import { Pool } from "pg";
275
276// Drizzle ORM packages
277import * as schema from "./schema";
278import { desc, eq } from "drizzle-orm";
279import { drizzle } from "drizzle-orm/node-postgres";
280
281// ---------------------------------------
282
283/* 🏗️ Configure Hono Web Application */
284// ...
285
286// create pool connection to database
287const pool = new Pool({
288 connectionString: process.env.DATABASE_URL
289});
290
291// initialize ORM client with schema types
292const database = drizzle(pool, { schema });
293
294// ---------------------------------------
295```
296
297### Implementing CRUD API with HTML Endpoints
298
299Let’s implement the `GET` and `POST` HTTP endpoints to create and read **projects** to demonstrate how it’s written in Hono. Endpoints are made by calling the HTTP verbs’ function on the `app` variable, passing a string representing the path and an async function with the context as a parameter. Here, the context (`c`) is used to handle both the incoming `Request` and outgoing `Response`.
300
301```tsx
302// index.tsx
303// ---------------------------------------
304
305/* 🛣️ Route Endpoints */
306// ...
307
308// GET: return project by name
309app.get("/api/project/:name", async (c) => {
310 // get route parameter (denoted with ':')
311 const name = c.req.param("name") as string;
312
313 // query database to find project with name
314 const result = await database.query.projects.findFirst({
315 where: eq(schema.projects.name, name)
316 });
317
318 // return JSON response
319 return c.json({ result });
320});
321
322// POST: create new project with name
323app.post("/api/project/:name", async (c) => {
324 // get route parameter (denoted with ':')
325 const name = c.req.param("name") as string;
326
327 // create a new project
328 const result = await database
329 .insert(schema.projects)
330 .values({ name })
331 .returning();
332
333 // return JSON response
334 return c.json({ result: result[0] });
335});
336```
337
338For this code snippet, the endpoints will run database queries and inserts with our Drizzle client based on the name given as part of the path and then return the results. We separate these functions with different HTTP verbs, even if they are under the same path/endpoint.
339
340Now what are projects but holders of our sessions. Implementing these aren’t going to be as easy as our project endpoints since we need to ensure that all sessions started must end, as well as ensuring we are returning null if there is no latest session for the project.
341
342```tsx
343// index.tsx
344// ---------------------------------------
345
346/* 🛣️ Route Endpoints */
347// ...
348
349// GET latest session under project name
350app.get("/api/session/:name", async (c) => {
351 const name = c.req.param("name") as string;
352
353 // get latest session
354 const latest = await database.query.sessions.findFirst({
355 where: eq(schema.sessions.projectName, name),
356 orderBy: [desc(schema.sessions.start)]
357 });
358
359 // return null if latest is undefined
360 return c.json({ result: latest ?? null });
361});
362
363// POST create a new session under project name
364app.post("/api/session/:name", async (c) => {
365 const name = c.req.param("name") as string;
366
367 // get latest session
368 const latest = await database.query.sessions.findFirst({
369 where: eq(schema.sessions.projectName, name),
370 orderBy: [desc(schema.sessions.start)]
371 });
372
373 // if no session OR latest already has an end time, then create a new session
374 // else end the current session
375 if (!latest || latest.end !== null) {
376 const result = await database
377 .insert(schema.sessions)
378 .values({ projectName: name })
379 .returning();
380
381 return c.json({ result: result[0] });
382 }
383 else {
384 const updated = await database
385 .update(schema.sessions)
386 .set({ end: new Date })
387 .where( eq(schema.sessions.id, latest.id) )
388 .returning();
389
390 return c.json({ result: updated[0] });
391 }
392});
393```
394
395Now we can test our application by running a local development (dev) server with `pnpm run start` in a terminal, and then using another to make `curl` requests. The following will make `POST` requests to create a project and session, `GET` the current session, and lastly `POST` to end the latest session. These should give you back JSON responses like those below on each request.
396
397```bash
398> curl -X POST http://localhost:3000/api/project/coding
399{"result":{"id":1,"name":"coding"}}
400
401> curl -X POST http://localhost:3000/api/session/coding
402{"result":{"id":2,"start":"2023-10-29T22:43:25.588Z","end":null,"projectName":"coding"}}
403
404> curl -X POST http://localhost:3000/api/session/coding
405{"result":{"id":2,"start":"2023-10-29T22:43:25.588Z","end":"2023-10-29T22:44:17.350Z","projectName":"coding"}}%
406```
407
408### Git & Github Repository Setup
409
410We can easily deploy this application by putting this project in a repository on Github and then hosting it in our Railway project alongside our postgres database. Here’s the step by step (according to Notion AI):
411
4121. Create a new repository on GitHub.
4132. In your terminal, navigate to the root directory of your project.
4143. Initialize Git in the project folder by running the command: `git init`.
4154. Add all the files in your project to the Git repository by running the command: `git add .`.
4165. Commit the changes by running the command: `git commit -m "Initial commit"`.
4176. Add the remote repository URL as the origin by running the command: `git remote add origin <remote_repository_url>`.
4187. Push the changes to the remote repository by running the command: `git push -u origin master`.
4198. Provide your GitHub username and password when prompted.
420
421After following these steps, your project will be pushed to GitHub and will be visible in your repository.
422
423### Deploying the Node.js Web Application on Railway
424
425From here, go back to the Railway project and press ‘Add’. Choose ‘Deploy from Github’ and find your repository. It should start deploying right away, **but** we need to change a few settings to get it working properly.
426
427To connect to our website publicly, we want to go to service’s ‘Settings’, go down to ‘Networking’ and press the ‘Generate Domain’ button. This should give you a URL you can enter with your browser.
428
429
430
431We also need to give the website access to our postgres database. Before we added the `DATABASE_URL` to a `.env` file, but since that isn’t in our repository (because it can be leaked on Github), Railway makes this easy for us by going to the ‘Variables’ tab and adding a ‘Variable Reference’, where we can add our `DATABASE_URL` variable from the database automatically.
432
433
434
435And now the project is live online! No need to run a local server, you can now access your endpoint as long as you have internet connection. For example, you can run the same `curl` requests, but now with the live URL (**note**: use `https` , not `http` when using the live URL).
436
437```bash
438> curl -X POST https://robin-tutorial-production.up.railway.app/api/project/coding
439{"result":{"id":1,"name":"coding"}}
440```
441
442### That’s It…. FOR NOW
443
444We now have a working CRUD web application online! Next steps is to get the TSX setup to use with a new blog on how to use HTMX. This will turn our application to an actual, honest to goodness, functional **website,** like with inputs, buttons, and styling! I’m working hard behind the scenes to learn how to implement HTMX and keep it understandable for you and me 😅
445
446That’s in the future though! For now, I’d like to thank you for reading this blog. I very much appreciate it, and if you can do me a favor, take a look at the links down below. Catch you in the next one!
447
448### Shameless Plugs
449
450- If you’d like to clone the source code for this project, it is public with a commented repository on my Github [here](https://github.com/zeucapua/robin-tutorial).
451- This project was made live on my Twitch stream. Code new projects with me weekly on [twitch.tv/zeu_dev](http://twitch.tv/zeu_dev).
452- Any comments or questions can reach me on Twitter. Follow me at [twitter.com/zeu_dev](http://twitter.com/zeu_dev).
453- Interested on other stuff? Visit my personal website at [zeu.dev](http://zeu.dev) and my other blogs on [thoughts.zeu.dev](http://thoughts.zeu.dev)!