Deploying a React Watchlist Tracker App to Production Using DeployHQ

Deploying a React Watchlist Tracker App to Production Using DeployHQ

In today's tutorial, we will learn how to self-host and set up our server, which will enable us to deploy any web application online. There are a few ways of deploying applications online. Two strategies involve using a VPS, a Virtual Private Server, and a shared managed hosting platform like Vercel, Netlify, WordPress, GoDaddy, etc.

How does a VPS compare to a managed hosting platform?

A VPS is a virtual machine that provides dedicated server resources on a physically shared server with other users. It is actually a middle-tier hosting for websites and applications, offering more control and customization compared to shared hosting. Examples of some VPS hosting platforms include Hetzner, Akamai, Vultr, Cloudcone.

On the other hand, it is easier to host websites using a managed hosting platform. Such platforms provide tools, workflows, and infrastructure for building and deploying web applications. These platforms perform load balancing and caching on their own. They work well for any developer looking to build and deploy web applications quickly without further configurations.

As always, there are pros and cons to using each strategy. One of the most significant differences is that when using a VPS, you get complete customization since you are in control of the whole server and all that comes with it. That means setting up the development environment, firewall rules, hosting, etc. This customization adds to the complexity, and you need more technical support since you are doing everything yourself. You should know that a managed hosting platform is really friendly to beginners since most of the tools are pre-set, and you get support and documentation. Because it's already set up and managed, you will not get that high level of customization you would get from VPS.

Also, you have to take into consideration the price difference. Most VPS are paid, although you can fully customize your server to make it lightweight or as robust as you will ever need. In performance terms, that puts it a step above any managed hosting platform. The latter does have free plans, so you need to look at the differences. The general consumers would want managed hosting platforms because they are free. However, if you desire more power and want to host advanced applications within one application, then VPS is the way to go.

Overview of what the Watchlist Tracker app does

Our Watchlist Tracker application is a straightforward but powerful full-stack CRUD application that will be used to track movie watchlists. This application will also enable its users to easily add movies or series that they want to watch, update the movie title or rating, and remove movies they have already watched or no longer wish to track. The app gives users access to a simple interface to organize and catch up on films of interest, making it a tool fit for those movie enthusiasts who want to stay ahead in keeping tabs on their watchlist.

You can see what the app looks like below:

Homepage

Super Watchlist App Homepage

Movie/series Item Page

Super Watchlist App Item Page

Add New Item page

Super Watchlist App Add New Item page

Setting Up the Vite Watchlist Tracker app

Prerequisites

Before we begin building the application, it's essential to have your development environment set up and working. Ensure that you have the following installed on your machine:

  • VS Code - A code editor

  • Node.js and npm - A cross-platform, open-source JavaScript runtime environment

  • GIT - A distributed version control system

  • Bun - A JavaScript runtime, package manager, test runner, and bundler

  • Vite - A modern JavaScript build tool

  • SQLite - A portable database

  • PM2 - A process manager for the JavaScript runtime Node.js.

  • An API testing tool like Postman, Thunder Client, or an alternative

There is an excellent free SQLite Viewer extension for VS Code, which can be helpful, too, alongside using the command line. It's good for quickly viewing the data inside of your database.

The tech stack

Our Watchlist Tracker app is built using a very modern and forward-thinking technical stack, which provides an excellent development experience. I have chosen to use tools like Bun, Hono, Vite, TanStack, Hetzner, and DeployHQ because they all offer developers a modern build experience.

Let's take a look at the technologies we will be using in this project:

Backend

  • Bun: Bun is a high-speed JavaScript runtime environment, much like Node.js; however, the developer experience and overall speed are even quicker. Bun allows developers to execute code quickly and comes with countless built-in support for many features that developers expect, like a test runner and bundler.

  • Hono: Hono is a lightweight web framework that works seamlessly alongside Bun, which is why it's a good match when you want to build a fast API. Hono's straightforward approach means that developers can create application logic that does not require much time and high complexity.

  • Prisma ORM: Prisma ORM is a powerful and modern Object-Relational Mapping tool that simplifies database management. It is type-safe, so interacting with the database ensures data integrity and fewer errors at runtime.

  • SQLite: SQLite is a lightweight, file-based database that excels in small or medium-sized projects. Its setup is quick, and it works best when working on a project that requires speed and little complexity.

Frontend

  • Vite: Vite is a next-generation front-end build tool that can be used to create different types of JavaScript applications. It has been designed for speed and can be used to build projects in React, Vue and other JavaScript frameworks.

  • Tailwind CSS: Tailwind CSS is a utility-first CSS framework that allows developers to rapidly build a user interface with low-level utility classes. It allows for a lot of customization and is very good at creating responsive websites.

  • TanStack Router: TanStack Router is a flexible and powerful routing solution for React applications. It supports advanced features such as nested routes and page transitions, making it an excellent modern option for setting up routing within a single-page application.

Hosting and deployment

  • Hetzner: Hetzner is a popular and reliable cloud hosting provider known for its good performance and affordable options. With Hetzner's infrastructure, you can be sure that your app will remain accessible while remaining performant as it is accessed by users all over the world.

  • DeployHQ: DeployHQ is a platform that can simplify the deployment process. It allows developers to deploy their websites from Git, SVN, and Mercurial repos to their own servers. This process guarantees that your app's code is always up to date in production and that there is a reliable automated deployment process, which is better for security.

Building the Watchlist Tracker app

Okay, let's start building our app! This section will be split into two parts: first, we will make the backend, and then we will create the frontend.

Building the backend

Please create a new folder on your computer for the project called watchlist-tracker-app and then cd into it. Now, create a new Bun project for the backend by using these commands shown here:

mkdir backend
cd backend
bun init -y
mkdir src
touch src/server.ts

Our project should now be set up. We just have to install dependencies, work on the configuration, and write some server code. Open the project in your code editor.

Now install the dependencies for our server using this command:

bun add hono prisma @prisma/client

We have added Bun as a runtime environment, Hono as our API server, and Prisma as our database ORM.

Lets now setup Prisma ORM and SQLite with this command:

npx prisma init

Prisma should be configured to work in our server now, so in the next step, we shall configure our database schema. So replace all of the code in prisma/schema.prisma with this code:

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

generator client {
  provider = "prisma-client-js"
}

model WatchlistItem {
  id          Int      @id @default(autoincrement())
  name        String
  image       String
  rating      Float
  description String
  releaseDate DateTime
  genre       String
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

Our database schema is set up, and it is connected to our SQLite database file.

Now run this migration script to create the SQLite database:

npx prisma migrate dev --name init

Great, now that the Prisma migration is complete, we can work on our API file.

Lets now create our main API file, which will use Hono. Go to the server file inside of src/server.ts and add this code to the file:

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { PrismaClient } from '@prisma/client';

const app = new Hono();

app.use(cors());

const prisma = new PrismaClient();

app.get('/watchlist', async (c) => {
  const items = await prisma.watchlistItem.findMany();
  return c.json(items);
});

app.get('/watchlist/:id', async (c) => {
  const id = c.req.param('id');
  const item = await prisma.watchlistItem.findUnique({
    where: { id: Number(id) },
  });
  return item ? c.json(item) : c.json({ error: 'Item not found' }, 404);
});

app.post('/watchlist', async (c) => {
  const data = await c.req.json();
  const newItem = await prisma.watchlistItem.create({ data });
  return c.json(newItem);
});

app.put('/watchlist/:id', async (c) => {
  const id = c.req.param('id');
  const data = await c.req.json();
  const updatedItem = await prisma.watchlistItem.update({
    where: { id: Number(id) },
    data,
  });
  return c.json(updatedItem);
});

app.delete('/watchlist/:id', async (c) => {
  const id = c.req.param('id');
  await prisma.watchlistItem.delete({ where: { id: Number(id) } });
  return c.json({ success: true });
});

Bun.serve({
  fetch: app.fetch,
  port: 8000,
});

console.log('Server is running on http://localhost:8000');

export default app;

With this file, our server will be using Bun and Hono and run on port 8000. We also have all of the CRUD (Create, Read, Update, Delete) endpoints for our watchlist tracker. All our data is saved inside an SQLite database.

All that remains is to create the run and build scripts for our server. Then, we can test the endpoints to make sure that they work as expected. Add these run scripts to our package.json file:

"scripts": {
    "start": "bun run src/server.ts",
    "build": "bun build src/server.ts --outdir ./dist --target node"
},

Ok, now if you run the command bun run start, you should see this in your terminal confirming that the server is running:

Server is running on http://localhost:8000

If you run the command bun run build, it should create a dist folder that is ready for production. We will need this when we deploy our application on Hetzner or any online server.

Alright, let's quickly test our backend endpoints to ensure that they work as expected. Then, we can start working on our front end. We have five endpoints to test: two GET, one POST, one PUT, and one DELETE. I'm going to use Postman to test the API.

Watchlist App API POST Endpoint

Method: POST Endpoint: http://localhost:8000/watchlist

This is our POST endpoint, which is used to send a JSON object with the movie/series data to our database.

Watchlist App API POST Endpoint

Watchlist App API GET All Endpoint

Method: GET Endpoint: http://localhost:8000/watchlist

This is our primary GET endpoint, which will return an array of objects that our front end will fetch. It returns an array of objects with our data or an empty array if we have yet to post any data to our database.

Watchlist App API GET All Endpoint

Watchlist App API GET By ID Endpoint

Method: GET Endpoint: http://localhost:8000/watchlist/3

This is our GET endpoint for getting items by their ID. It returns only that object and shows an error if the item does not exist.

Watchlist App API GET By ID Endpoint

Watchlist App API PUT Endpoint

Method: PUT Endpoint: http://localhost:8000/watchlist/3

This is our PUT endpoint for updating items using their ID. It returns only that object and shows an error if the item does not exist.

Watchlist App API PUT Endpoint

Watchlist App API DELETE Endpoint

Method: DELETE Endpoint: http://localhost:8000/watchlist/3

This is our DELETE endpoint for deleting items using their ID. It returns a success object and shows an error if the item does not exist.

Watchlist App API DELETE Endpoint

That is, our API up and running. We can start on the front-end code now.

Building the frontend

Make sure that you are inside the root folder for watchlist-tracker-app, and then run these scripts below to create a React project using Vite that is set up for TypeScript with all of our packages and dependencies:

bun create vite client --template react-ts
cd client
bunx tailwindcss init -p
bun install -D tailwindcss postcss autoprefixer tailwindcss -p
bun install @tanstack/react-router axios dayjs
cd src
mkdir components pages
touch api.ts
touch components/{AddItemForm,FormField,Header}.tsx pages/{AddItem,Home,ItemDetail}.tsx
cd ..

This script basically uses the Bun runtime environment to install and set up our project. All of the necessary files and folders have been created, so we just need to add the code. We have setup our Vite project to use Tailwind CSS for the styling and we have TanStack Router for page routing with axios for fetching data and dayjs for doing date conversions in our form.

Thanks to this build script, our job is now significantly simpler, so let's start adding the code to our files. Up first will be some configuration files. Replace all of the code inside the tailwind.config.js file with this code:

module.exports = {
  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {},
  },
  plugins: [],
};

This file is pretty explanatory. We need this file so that Tailwind CSS works throughout our project.

Now replace all of the code in our src/index.css file with this code, we need to add Tailwind directives so we can use them in our CSS files:

@tailwind base;
@tailwind components;
@tailwind utilities;

With these directives added we can access Tailwind CSS styles in all of our CSS files. Next delete all of the CSS code inside of the App.css file as we no longer need it.

Alright, now for the final configuration file, and then we can work on our pages and components.

Add this code to the api.ts file in the root folder:

import axios from 'axios';

const API_URL = 'http://localhost:8000';

export interface WatchlistItem {
  id: number;
  name: string;
  image: string;
  rating: number;
  description: string;
  genre: string;
  releaseDate: string;
}

export const fetchWatchlist = () =>
  axios.get<WatchlistItem[]>(`${API_URL}/watchlist`);

export const fetchItem = (id: number) =>
  axios.get<WatchlistItem>(`${API_URL}/watchlist/${id}`);

export const createItem = (data: Omit<WatchlistItem, 'id'>) =>
  axios.post<WatchlistItem>(`${API_URL}/watchlist`, data);

export const updateItem = (id: number, data: Partial<WatchlistItem>) =>
  axios.put<WatchlistItem>(`${API_URL}/watchlist/${id}`, data);

export const deleteItem = (id: number) =>
  axios.delete(`${API_URL}/watchlist/${id}`);

This file exports the endpoints, which our frontend will need to connect to on our backend. Remember that our backend API is located at http://localhost:8000.

Ok good let's replace all of the code in our App.tsx file with this new code:

import {
  RouterProvider,
  createRouter,
  createRootRoute,
  createRoute,
} from '@tanstack/react-router';
import Home from './pages/Home';
import ItemDetail from './pages/ItemDetail';
import AddItem from './pages/AddItem';

const rootRoute = createRootRoute();

const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: Home,
});

const itemDetailRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/item/$id',
  component: ItemDetail,
});

const addItemRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/add-item',
  component: AddItem,
});

const routeTree = rootRoute.addChildren([
  indexRoute,
  itemDetailRoute,
  addItemRoute,
]);

const router = createRouter({ routeTree });

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router;
  }
}

function App() {
  return <RouterProvider router={router} />;
}

export default App;

This is our main entry point component for our app and this component holds the routes for all of our pages.

Next, we shall work on the main components and pages. We have three component files and three pages of files. Starting with the components add this code to our file in components/AddItemForm.tsx:

import axios from 'axios';
import dayjs from 'dayjs';
import { useNavigate } from '@tanstack/react-router';
import FormField from './FormField';

const API_URL = 'http://localhost:8000';

interface FormProps {
  formData: {
    name: string;
    image: string;
    rating: number | string;
    description: string;
    releaseDate: string;
    genre: string;
  };
  handleChange: (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => void;
}

const AddItemForm: React.FC<FormProps> = ({ formData, handleChange }) => {
  const navigate = useNavigate();

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    try {
      const formattedReleaseDate = dayjs(formData.releaseDate).toISOString();

      const dataToSend = {
        ...formData,
        releaseDate: formattedReleaseDate,
        rating: Number(formData.rating),
      };

      await axios.post(`${API_URL}/watchlist`, dataToSend);

      navigate({ to: '/' });
    } catch (error) {
      console.error('Error adding item:', error);
    }
  };

  return (
    <form
      onSubmit={handleSubmit}
      className="bg-gray-800 text-white p-4 rounded-lg"
    >
      <FormField
        label="Name"
        name="name"
        value={formData.name}
        onChange={handleChange}
        placeholder="Name"
      />
      <FormField
        label="Image URL"
        name="image"
        value={formData.image}
        onChange={handleChange}
        placeholder="Image URL"
      />
      <FormField
        label="Rating"
        name="rating"
        value={formData.rating}
        onChange={handleChange}
        type="number"
        placeholder="Rating"
      />
      <FormField
        label="Description"
        name="description"
        value={formData.description}
        onChange={handleChange}
        type="textarea"
        placeholder="Description"
      />
      <FormField
        label="Release Date"
        name="releaseDate"
        value={formData.releaseDate}
        onChange={handleChange}
        type="date"
      />
      <FormField
        label="Genre"
        name="genre"
        value={formData.genre}
        onChange={handleChange}
        placeholder="Genre"
      />
      <button
        type="submit"
        className="bg-blue-500 px-4 py-2 rounded text-white hover:bg-blue-600"
      >
        Add Item
      </button>
    </form>
  );
};

export default AddItemForm;

This component is used for adding items to our database. The user will use this form component to send a POST request to the backend.

Now lets add the code for components/FormField.tsx:

import React from 'react';

interface FormFieldProps {
  label: string;
  name: string;
  value: string | number;
  onChange: (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => void;
  type?: 'text' | 'number' | 'date' | 'textarea';
  placeholder?: string;
}

const FormField: React.FC<FormFieldProps> = ({
  label,
  name,
  value,
  onChange,
  type = 'text',
  placeholder,
}) => (
  <div className="mb-4">
    <label className="block text-sm font-medium mb-2">{label}</label>
    {type === 'textarea' ? (
      <textarea
        name={name}
        value={value as string}
        onChange={onChange}
        className="w-full p-2 rounded bg-gray-700"
        placeholder={placeholder}
      />
    ) : (
      <input
        name={name}
        value={value}
        onChange={onChange}
        type={type}
        className="w-full p-2 rounded bg-gray-700"
        placeholder={placeholder}
        {...(type === 'number' && { min: 1, max: 10 })}
      />
    )}
  </div>
);

export default FormField;

This is a reusable form field component for our form. It keeps our code DRY because we can just use the same component for multiple fields which means our codebase is smaller.

And lastly lets add the code for components/Header.tsx:

import { Link } from '@tanstack/react-router';

const Header: React.FC = () => {
  return (
    <header className="bg-gray-800 p-4 rounded-lg mb-4">
      <nav className="flex flex-row gap-4 items-center">
        <Link to="/" className="text-white hover:no-underline">
          <div className="bg-sky-500 text-white p-2">Super Watchlist</div>
        </Link>
        <Link to="/" className="text-white hover:underline">
          Home
        </Link>
        <Link to="/add-item" className="text-white hover:underline">
          Add New Item
        </Link>
      </nav>
    </header>
  );
};

export default Header;

Because of this header component, each page has a header with a main navigation.

All we have left is the three pages and then our app is done. So add this following code to pages/AddItem.tsx:

import React, { useState } from 'react';
import AddItemForm from '../components/AddItemForm';
import Header from '../components/Header';

const AddItem: React.FC = () => {
  const [formData, setFormData] = useState({
    name: '',
    image: '',
    rating: '',
    description: '',
    releaseDate: '',
    genre: '',
  });

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { name, value } = e.target;
    const newValue = name === 'rating' ? Number(value) : value;
    setFormData({ ...formData, [name]: newValue });
  };

  return (
    <div className="container mx-auto p-4">
      <Header />
      <h1 className="text-2xl font-bold mb-4">Add a Movie/Series</h1>
      <AddItemForm formData={formData} handleChange={handleChange} />
    </div>
  );
};

export default AddItem;

This is essentially the page for adding items to our database which has the form component in it.

Right so next lets add the code for pages/Home.tsx:

import React, { useEffect, useState } from 'react';
import { fetchWatchlist, WatchlistItem } from '../api';
import { Link } from '@tanstack/react-router';
import dayjs from 'dayjs';
import Header from '../components/Header';

const Home: React.FC = () => {
  const [watchlist, setWatchlist] = useState<WatchlistItem[]>([]);

  useEffect(() => {
    const getWatchlist = async () => {
      try {
        const response = await fetchWatchlist();
        setWatchlist(response.data);
      } catch (error) {
        console.error('Error fetching watchlist:', error);
      }
    };

    getWatchlist();
  }, []);

  return (
    <div className="container mx-auto p-4">
      <Header />
      <h1 className="text-2xl font-bold mb-4">Watchlist</h1>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
        {watchlist.map((item) => (
          <div
            key={item.id}
            className="bg-gray-800 text-white p-4 rounded-lg shadow-lg flex flex-col justify-between h-full"
          >
            <div>
              <img
                src={item.image}
                alt={item.name}
                className="w-full h-48 object-cover mb-4 rounded"
              />
              <h2 className="text-xl font-semibold mb-2">{item.name}</h2>
              <p className="text-sm mb-2">Rating: {item.rating}</p>
              <p className="text-sm mb-2">{item.description}</p>
              <p className="text-sm mb-2">{item.genre}</p>
              <p className="text-sm mb-2">
                Release Date: {dayjs(item.releaseDate).format('MMMM D, YYYY')}
              </p>
            </div>
            <Link
              to={`/item/${item.id}`}
              params={{ id: item.id.toString() }}
              className="mt-4 text-blue-400 hover:underline self-start"
            >
              View Details
            </Link>
          </div>
        ))}
      </div>
    </div>
  );
};

export default Home;

As you can imagine, this will be our homepage, which sends a GET request to the backend, which then retrieves an array of objects for all of the items in our database.

Finally add the code for pages/ItemDetail.tsx:

import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from '@tanstack/react-router';
import { fetchItem, updateItem, deleteItem } from '../api';
import dayjs from 'dayjs';
import Header from '../components/Header';

interface WatchlistItem {
  id: number;
  name: string;
  image: string;
  rating: number;
  description: string;
  genre: string;
  releaseDate: string;
}

const ItemDetail: React.FC = () => {
  const { id } = useParams({ from: '/item/$id' });
  const navigate = useNavigate({ from: '/item/$id' });
  const [item, setItem] = useState<WatchlistItem | null>(null);
  const [isEditing, setIsEditing] = useState(false);
  const [formData, setFormData] = useState({
    name: '',
    image: '',
    rating: 0,
    description: '',
    genre: '',
  });

  useEffect(() => {
    const getItem = async () => {
      if (id) {
        try {
          const response = await fetchItem(Number(id));
          setItem(response.data);
          setFormData({
            name: response.data.name,
            image: response.data.image,
            rating: response.data.rating,
            description: response.data.description,
            genre: response.data.genre,
          });
        } catch (error) {
          console.error('Error fetching item:', error);
        }
      }
    };

    getItem();
  }, [id]);

  const handleDelete = async () => {
    if (id) {
      try {
        await deleteItem(Number(id));
        navigate({ to: '/' });
      } catch (error) {
        console.error('Error deleting item:', error);
      }
    }
  };

  const handleEdit = () => {
    setIsEditing(true);
  };

  const handleSave = async () => {
    if (id && item) {
      try {
        await updateItem(Number(id), formData);
        setIsEditing(false);
        setItem({ ...item, ...formData });
      } catch (error) {
        console.error('Error updating item:', error);
      }
    }
  };

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { name, value } = e.target;
    setFormData((prev) => ({
      ...prev,
      [name]: name === 'rating' ? parseFloat(value) : value,
    }));
  };

  if (!item) return <div>Loading...</div>;

  return (
    <div className="container mx-auto p-4">
      <Header />
      <h1 className="text-2xl font-bold mb-4">Watchlist - {item.name}</h1>
      <div>
        {isEditing ? (
          <div className="bg-gray-800 text-white p-4 rounded-lg">
            <h2 className="text-xl font-semibold mb-2">Edit {item.name}</h2>
            <input
              type="text"
              name="name"
              value={formData.name}
              onChange={handleChange}
              className="w-full p-2 mb-4 rounded text-black"
              placeholder="Name"
            />
            <input
              type="text"
              name="image"
              value={formData.image}
              onChange={handleChange}
              className="w-full p-2 mb-4 rounded text-black"
              placeholder="Image URL"
            />
            <input
              type="number"
              name="rating"
              value={formData.rating}
              onChange={handleChange}
              className="w-full p-2 mb-4 rounded text-black"
              placeholder="Rating"
              min={1}
              max={10}
            />
            <textarea
              name="description"
              value={formData.description}
              onChange={handleChange}
              className="w-full p-2 mb-4 rounded text-black"
              placeholder="Description"
            />
            <input
              type="text"
              name="genre"
              value={formData.genre}
              onChange={handleChange}
              className="w-full p-2 mb-4 rounded text-black"
              placeholder="Genre"
            />
            <button
              onClick={handleSave}
              className="bg-blue-500 px-4 py-2 rounded text-white"
            >
              Save
            </button>
          </div>
        ) : (
          <div className="bg-gray-800 text-white p-4 rounded-lg">
            <img
              src={item.image}
              alt={item.name}
              className="w-full h-48 object-cover mb-4 rounded"
            />
            <h2 className="text-xl font-semibold mb-2">{item.name}</h2>
            <p className="text-sm mb-2">Rating: {item.rating}</p>
            <p className="text-sm mb-2">{item.description}</p>
            <p className="text-sm mb-2">{item.genre}</p>
            <p className="text-sm mb-2">
              Release Date: {dayjs(item.releaseDate).format('MMMM D, YYYY')}{' '}
            </p>
            <button
              onClick={handleEdit}
              className="bg-yellow-500 px-4 py-2 rounded text-white mr-2"
            >
              Edit
            </button>
            <button
              onClick={handleDelete}
              className="bg-red-500 px-4 py-2 rounded text-white"
            >
              Delete
            </button>
          </div>
        )}
      </div>
    </div>
  );
};

export default ItemDetail;

This page displays individual item pages by their ID. There is also a form for editing and deleting items from the database.

That's it. Our application is ready to use. Make sure that both the backend and client servers are running. You should see the app running in the browser here: http://localhost:5173/.

Run both servers with these commands inside their folders:

# Backend
bun run start

# Client
bun run dev

Ok, well done. Our application is complete! Let's now deploy it to GitHub!

Deploying the app to GitHub

Deploying our application to GitHub is pretty straightforward. Firstly put a .gitignore file inside of the root folder for our watchlist-tracker-app. You can just copy and paste the .gitignore file from either the backend or client folders. Now go to your GitHub (create an account if you do not have one) and create a repo for your watchlist-tracker-app.

Inside of the root folder for watchlist-tracker-app use the command line to upload your codebase. See this example code and adapt it for your own repository:

git init
git add .
git commit -m "first commit"
git branch -M main
git remote add origin https://github.com/your-repo/watchlist-tracker-app.git
git push -u origin main

There is one very important last point to be aware of. When we upload our code to Hetzner, we will no longer be able to access the backend via localhost, so we have to update the API route. There is a variable called const API_URL = 'http://localhost:8000'; in two files at the top of them. The files are api.ts and components/AddItemForm.tsx. Replace the variable API URL with the one below and then re-upload your codebase to GitHub.

const API_URL = '/api';

That's all there is to it. Your codebase should now be online on GitHub so we can now work on deploying it to Hetzner.

Create an account on Hetzner

With our watchlist tracker app complete, it's now time to deploy our application to the Hetzner VPS platform. Hetzner is a paid platform, but the versatility it offers is unmatched. The cheapest plan is around €4.51/$4.88/£3.76 a month. It's well worth having your self-hosted online server because, as a developer, you can use it for learning, practice, production deployments, and so much more. You can always cancel the server subscription and get it back when needed.

Most VPS are essentially the same because they are all servers that can run different operating systems, like Linux. Each VPS provider has a different interface and setup, but the fundamental structure is quite the same. By learning how to self-host an application on Hetzner, you can easily reuse these same skills and knowledge to deploy an application on a different VPS platform.

Some use cases for a VPS include:

  • Web Hosting

  • App Hosting

  • Game Servers

  • VPN or Proxy Servers

  • Email Servers

  • Database Hosting

  • Automation and Development Tasks

  • Media Streaming and File Hosting

  • Data Analysis and Machine Learning

  • Learning and Experimentation

You can see what the current pricing looks like for Hetzner here:

Hetzner pricing

Go to the Hetzner website and click on the red Sign Up button in the middle of the page. Alternatively, you can click on the Login button in the top right-hand corner. You should see a menu with options for Cloud, Robot, konsoleH, and DNS. Click on any one of them to go to the login and register form page.

Hetzner website homepage

Hetzner login menu

Now, you should see the login and register form page. Click the register button to create an account and complete the sign-up process. You will probably need to pass the verification stage by either using your PayPal account or having your passport ready.

Hetzner login and register form page

You should now be able to log in to your account and create and buy a server. Choose a location that is near you, and then select Ubuntu as the image. See this example for reference.

Hetzner create a server

Under Type, choose shared vCPU, and then select a configuration for your server. We are deploying a simple application that only requires a few resources. If you want, feel free to get a better server—it's up to you. A dedicated vCPU performs better but costs more money. You can also choose between x86 (Intel/AMD) and Arm64 (Ampere) processors.

Hetzner server type

The default configuration should be acceptable. See the example below. For security reasons, though, it's important to add an SSH key.

Hetzner networking and ssh configuration

SSH keys provide a more secure way to authenticate to your server than a traditional password. The key needs to be in OpenSSH format, ensuring a high level of security for your server. Depending on your operating system, you can do a Google search for "Generate an SSH key on Mac" or "Generate an SSH key on Windows." I will give you a quick guide for generating an SSH key on a Mac.

Firstly open your terminal application and then type the following command replacing "your_email@example.com" with your actual email address:

ssh-keygen -t rsa -b 4096 -C "your_email@example.com"

The -b 4096 part ensures that the key is 4096 bits for increased security. Next save the key after prompted to do so. You can accept the default location or choose a custom one:

/Users/your_user/.ssh/id_rsa

Setting a passphrase is optional you can skip this step. Now, load the SSH key into your SSH agent by running the following commands:

First, start the agent:

eval "$(ssh-agent -s)"

Then, add the SSH key:

ssh-add -K ~/.ssh/id_rsa

To copy your SSH public key to your clipboard, run:

pbcopy < ~/.ssh/id_rsa.pub

This copies the public key so you can add it to Hetzner or any other service that requires an SSH key. Paste your SSH key into this form box and add it.

Hetzner add SSH key

You don't need to worry about Volumes, Placement groups, Labels, or Cloud config because they are outside the scope of this project. Backups can be helpful, but they will add cost, so they are optional for this project. We will do the Firewall later, so you don't have to worry about it now. Choose a name for your server, and then go ahead and create and buy the server with your current settings, and your Hetzner account will be ready to go.

Hetzner create and buy now

Ok, good. We now have an account on Hetzner. In the next section, we will set up our firewall, configure our Linux operating system, and get our application online.

Deploying the Watchlist Tracker app to Hetzner

Create a Hetzner firewall

Before we SSH into our Linux OS, let's first set up our firewall rules. We need port 22 open so that we can use SSH, and we need port 80 open as port 80 is a TCP port that's the default network port for web servers using HTTP (Hypertext Transfer Protocol). It's used to communicate between web browsers and servers, delivering and receiving web content. This is how we get our application to work online.

Navigate to Firewalls in the main menu and then create a firewall with the inbound rules shown below:

Hetzner Firewalls menu

Hetzner create firewall

Now, with our firewall working, we can set up a Linux environment and get our application deployed, so let's do that next.

Setup our Linux environment

Connecting to our remote Hetzner server should be pretty straightforward because we have created an SSH key for logging in. We can connect using the terminal or even a code editor like VS Code, which will give us the ability to see and browse our files much more easily without having to use the command line. If you want to use the Visual Studio Code Remote - SSH extension, you can do so. For this project, we will stick with the terminal.

Open your terminal and type this SSH command to log in to your remote server. Replace the address 11.11.111.111 with your actual Hetzner IP address, which you can find in the servers section:

Hetzner IP and server

ssh root@11.11.111.111

You should now be logged into your server, which displays the welcome screen and other private information like your IP address. I'm just showing the welcome part of the screen here:

Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 6.8.0-45-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

Okay, great. Now, we can finally start to run some commands that will set up our development environment before we get our application online. By the way, if you want to exit and close the SSH connection to your server, you can either type exit followed by clicking the enter button or use the keyboard shortcut Ctrl + D.

The first thing we need to do is update and upgrade the packages on our Linux system. We can do that with one command, so type this command into the terminal and hit enter:

sudo apt update && sudo apt upgrade -y

Now, we need to install the rest of our development packages and dependencies. First up is Node.js and npm, so install them with this command:

curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs

Next is Nginx, which we will need to use as a reverse proxy. A reverse proxy is a server that sits between clients and backend servers, forwarding client requests to the appropriate backend server and then returning the server's response to the client. It acts as an intermediary, managing and routing incoming requests to improve performance, security, and scalability. So install it with this command:

sudo apt install nginx

We will require Git, for which we need to pull our code from GitHub and upload it to our remote server. So install it with this command:

sudo apt install git

The Bun runtime will be helpful for running our applications. First, we have to install the unzip package as required prior to installing Bun. Afterwards, we can install Bun. These are the commands we need:

sudo apt install unzip
curl -fsSL https://bun.sh/install | bash

To run both the React frontend and the backend server on the same Hetzner server, we need to manage both services simultaneously. This typically involves setting up a process manager like PM2 to run the backend server and using Nginx as a reverse proxy to handle incoming requests to both the front end and back end.

Install PM2 by using this command here:

sudo npm install -g pm2

Right, that takes care of our Linux environment setup. In the next section, we will download our codebase from GitHub onto our remote server and configure our Nginx server to get our app working online.

Deploy our application online on Hetzner

I'm going to assume that you already know how to navigate the command line. If not you can Google it. We will be changing directories and managing files. Start by cloning the GitHub repo with your project and copying it onto the remote server. Use the command below for reference:

cd /var/www
git clone https://github.com/your-repo/watchlist-tracker-app.git

Our web application files will be stored inside of /var/www. You can see all the files on your Linux OS by using the commands ls which is used to list files and pwd which is used to print the working directory. To learn more about the Linux command line take a look at this tutorial for The Linux command line for beginners.

So now that we have our application on our remote server we can create a production build of the backend and frontend. To do this, we just have to cd into the root for the folder backend and client inside of our watchlist-tracker-app project and run the commands shown below:

# First install the project dependencies for the backend and client
bun install

# Then create a production build for the backend and client
bun run build

We are using Bun as our runtime so we will use the bun commands for the install and building steps.

Alright, now let's configure our Nginx server. We will use the nano terminal editor to write the code inside of the file. Run this command in the terminal to open a Nginx file for our watchlist tracker app:

sudo nano /etc/nginx/sites-available/watchlist-tracker-app

If you're not familiar with the nano code editor, check out this cheat sheet.

You just need to copy and paste this configuration into the file and save it. Be sure to replace the server_name IP address with your own Hetzner IP address:

server {
    listen 80;
    server_name 11.11.111.111;

    # Proxy requests to the Vite preview server
    location / {
        proxy_pass http://localhost:4173;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

    # Proxy requests to the backend
    location /api/ {
        proxy_pass http://localhost:8000/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Nginx is very often used to serve the role of a reverse proxy server. A reverse proxy sits between the client-your user's browser and your backend server. It receives requests from the client and forwards them to one or more backend servers. Once the backend processes the request, the reverse proxy forwards the response back to the client. In such a setup, Nginx would be the entry point for incoming traffic and route requests to specific services, say Vite frontend or API backend.

Vite production preview builds run on port 4173, and our backend server runs on port 8000. If you change these values, then make sure you update them in this Nginx configuration file, too; otherwise, the servers won't work.

Enable the Nginx site if it's not already enabled with this command:

sudo ln -s /etc/nginx/sites-available/watchlist-tracker-app /etc/nginx/sites-enabled/

Now test the Nginx configuration to ensure there are no syntax errors and restart Nginx to apply the changes with these commands:

sudo nginx -t
sudo systemctl restart nginx

We are almost done. Just one step left. If you go to your Hetzner IP address now in a browser, you should see an error like 502 Bad Gateway. That's because we have no running server yet; first, we need to run both servers simultaneously using PM2. So, we have to set PM2 to start on the system boot so that our application will always be online. Do this by running these commands in the terminal:

pm2 startup
pm2 save

Now, we have to get the backend and frontend servers running. Run these commands from inside the root of their folders.

Let's start with the backend server, so run this command in the terminal:

pm2 start dist/server.js --name "backend" --interpreter bun

Lastly, let's get the client frontend server running, so run this command in the terminal:

pm2 start bun --name "client" -- run preview

You can run the command pm2 status to check if both servers are online and running, as shown below. To learn about all of the other PM2 commands, read the documentation on PM2 Process Management Quick Start:

PM2 status watchlist app backend and client servers online

You can test if the servers are reachable by running these curl commands in the terminal. You should get back the HTML code if they are working:

# Vite.js
curl http://localhost:4173

# Backend server (Bun and Hono)
curl http://localhost:8000

# Live website address (change to your IP address)
curl http://11.11.111.111

Go to the IP address for your Hetzner server. If you did everything correctly, you should see your app deployed and online! Websites typically have domain names, and the IP address remains hidden from the search bar. It's fairly common for developers to buy domains and then change the nameservers to connect them to a host's server. This is beyond the scope of this tutorial, but you can easily learn how to do it by doing a Google search. Namecheap is my preferred domain register.

Using DeployHQ to streamline the deployment process

Right, we are very close to completion. Now, the final step is to use DeployHQ to streamline the deployment process. DeployHQ makes deployments easy and is much better for security purposes. The traditional way to update a codebase on an online server is to use git pull to get the latest changes from your GitHub repo. However, doing git pull is not a good practice since it might expose git folders, and the website most likely will not be minified, uglified, and so on.

DeployHQ plays a crucial role here. It securely copies the modified files in the configured folder, ensuring that no changes are visible in the git logs on your server. This may seem like a trade-off, but it's a security feature that reassures you of the safety of your deployment. If you're familiar with platforms like Vercel or Netlify, you'll find these auto deployments quite similar. In this case, you have a setup that can work with any online server on a VPS.

One thing worth mentioning is that it's important that we create a non-root user for our online Linux remote server. Signing in as the root user is not always the best practice; it's better to have another user set up with similar privileges. DeployHQ also discourages using the root user for sign-in. In order for DeployHQ to work, we need to use SSH to sign into our account. We will do this after we have a DeployHQ account because we have to place our DeployHQ SSH public key on our online Ubuntu server.

DeployHQ gives you free access to all of their features for 10 days with no obligations when you sign up for the first time. Afterward, your account will revert to a free plan that allows you to deploy a single project up to 5 times a day.

Start by going to the DeployHQ website and creating an account by clicking one of those buttons you see below:

DeployHQ Website Homepage

Ok, you should now see the welcome to DeployHQ screen with a Create a project button. Click the button to create a project like shown here:

DeployHQ Create a project screen

On the next screen, you need to create a new project. So please give it a name, and select your GitHub repo. Also, choose a zone for your project and then create a project:

DeployHQ New Project

You should now see the server screen, as shown below. This means it's time to create another Linux user for our remote server so we don't have to rely on the root user.

DeployHQ server setup page

Start by logging into your server as the root user, and then create a new user with this command below. Replace new_username with a username that you want to use for the new user:

adduser new_username

You will be asked to set a password, and there will be prompts for entering details like your full name, room number, etc. You only need to set a password. You can skip the other prompt steps and leave them blank by pressing Enter until they are all gone.

It's also a good idea to add the new user to the sudo group so they can have administrative privileges like the root user. Do this with the command shown here:

usermod -aG sudo new_username

The new user now needs SSH access for the server. First switch to the new user and then create the .ssh directory for them with these commands:

# Switch to the new user
su - new_username

# Create the .ssh directory
mkdir -p ~/.ssh
chmod 700 ~/.ssh

Now we have to add our local Public Key to authorized_keys on the server so on your local machine copy your public key with this command:

cat ~/.ssh/id_rsa.pub

Now on the server open the authorized_keys file so it can be edited with the nano editor:

nano ~/.ssh/authorized_keys

Paste the copied public key into this file. Before you save the file copy and paste the DeployHQ SSH key from the server page into the same file. You can see the SSH key when you check the box for Use SSH key rather than password for authentication?. Now save the file and set the correct permissions for the file with this command:

chmod 600 ~/.ssh/authorized_keys

You can now test the SSH login for the new user. First, log out from the remote server and then try to log in again, but this time using the new user you created and not root. See the example below:

ssh new_username@your_server_ip

Assuming you did everything correctly, you should now be able to log in with your new user.

Now we can finally complete the server form. Fill in the form with your information see this example below:

Name: watchlist-tracker-app Protocol: SSH/SFTP Hostname: 11.11.111.111 Port: 22 Username: new_username Use SSH key rather than password for authentication?: checked Deployment Path: /var/www/watchlist-tracker-app

The deployment path should be the location on your server where your GitHub repo is. Now you can go ahead and create a server. If you encounter any problems then it might be due to your firewall settings so read the documentation on Which IP addresses should I allow through my firewall?.

You should now see the New Deployment screen as shown here:

DeployHQ New Deployment Screen

After a successful deployment, you should be presented with this screen:

DeployHQ successful deployment screen

The final step is to set up automatic deployments so that when you push changes from your local repository to GitHub, they are automatically deployed to your remote server. You can do this from the Automatic Deployments page, which is located on the left sidebar of your DeployHQ account. See the example here:

DeployHQ Automatic Deployments screen

We are all done; congratulations, you have learned how to build a full-stack React application, deploy the codebase to GitHub, host the application on a VPS running Linux Ubuntu, and set up automatic deployments with DeployHQ. Your developer game has leveled up!

Conclusion

It's efficient and enjoyable to build a full-stack CRUD app like Watchlist Tracker with modern tools and technologies. We used a pretty strong tech stack: Bun, Hono, Prisma ORM, and SQLite on the back end, while Vite, Tailwind CSS, and TanStack Router on the front end help make it responsive and functional. Hetzner will ensure that the app is reliable and well-performing no matter where the users are.

Deployment by DeployHQ makes deployment quite easy. You just need to push updates straight from your Git repository to the cloud server. Any changes you make in your repository will automatically be deployed to your production server so that the latest version of your application is live. This saves time because automated deployments cut down on the number of errors related to deployment, so it is worth adding to any form of development workflow.

This tutorial should help you deploy all kinds of applications to production using a VPS like Hetzner with automatic git deployments thanks to DeployHQ.

Did you find this article valuable?

Support andrewbaisden.dev by becoming a sponsor. Any amount is appreciated!