DEV Community

Cover image for Remult: Build a type-safe, full-stack app with TypeScript
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Remult: Build a type-safe, full-stack app with TypeScript

Written by Vijit Ail✏️

Simple CRUD-based modules, a common requirement of any business, should be simple to build and maintain. Remult is a comprehensive framework that allows developers to build full-stack, type-safe apps using only TypeScript code.

This article will introduce Remult’s basic concepts and will demonstrate how Remult can be used to simplify and speed up your web app development process!

In this guide, we’ll create a simple booking form and we’ll store the form submissions in a MongoDB collection. We'll build the UI using React and then add styling with Spectre.css.

Jump ahead

Get to know the Remult framework

Remult is a CRUD framework that uses TypeScript entities for CRUD operations. It also provides a type-safe API client and an ORM for backend database operations.

This framework abstracts away and reduces the boilerplate code in your application. It makes it easy to build full-stack applications using TypeScript and also allows developers to integrate with other frameworks such as Express.js and Angular.

Remult is a middle-ground. It doesn't force you to work in a certain way; instead, it provides many options for your projects.

Set up the React project with Remult

Let's start by creating a React project using Create React App and selecting the TypeScript template:

> npx create-react-app remult-react-booking-app --template typescript
> cd remult-react-booking-app
Enter fullscreen mode Exit fullscreen mode

Next, we’ll install the required dependencies.

> npm i axios express remult dotenv
> npm i -D @types/express ts-node-dev concurrently
Enter fullscreen mode Exit fullscreen mode

In the above code, we’re using the concurrently package. This package is required because we’ll be serving the client and server code simultaneously from the root of React project.

Now, create a tsconfig file for the server, like so:

// tsconfig.server.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "emitDecoratorMetadata": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Then, in the main tsconfig.json file, add the experimentalDecorators option to enable the use of decorators.

// tsconfig.json

{
  "compilerOptions": {
    // ...
    "experimentalDecorators": true
  },
}
Enter fullscreen mode Exit fullscreen mode

Update the package.json file, like so:

// package.json

{
  "proxy": "http://localhost:3002",
  // ...
  "scripts": {
  // ...
    "start:dev": "concurrently -k -n \"SERVER,WEB\" -c \"bgBlue.bold,bgGreen.bold\" \"ts-node-dev -P tsconfig.server.json src/server/\" \"react-scripts start\""
  },
}
Enter fullscreen mode Exit fullscreen mode

Here, we add the proxy option to let the webpack dev server know to proxy API requests on ports 3000 to 3002 while the app is running on the local environment. We also add an npm script to initiate the frontend and API dev server simultaneously.

Initialize the remultExpress middleware

Now, let’s create a server folder inside the src folder created by Create React App and create an api.ts file that will initialize the remultExpress middleware.

// src/server/api.ts

import { remultExpress } from "remult/remult-express";

export const api = remultExpress();
Enter fullscreen mode Exit fullscreen mode

Next, create a .env file for the server and specify the API port number.

// src/server/.env

API_PORT=3002
Enter fullscreen mode Exit fullscreen mode

Next, create an index.ts file that will serve as the root file for the server, initialize express, load the environment variables, and register the remultExpress middleware.

// src/server/index.ts

import { config } from "dotenv";
config({ path: __dirname + "/.env" });

import express from "express";
import { api } from "./api";

const app = express();
app.use(api);

app.listen(process.env.API_PORT || 3002, () => console.log("Server started"));
Enter fullscreen mode Exit fullscreen mode

Initialize Remult on the frontend

We'll use the global Remult object in the React app to communicate with the API server via the axios HTTP client.

// src/common.ts

import axios from "axios";
import { Remult } from "remult";

export const remult = new Remult(axios);
Enter fullscreen mode Exit fullscreen mode

At this point, the primary project setup is complete and ready to run on the local server.

Use the following command:

> npm run start:dev
Enter fullscreen mode Exit fullscreen mode

Add the database connection

In this guide, we’ll use MongoDB to store our form submissions. To set up the MongoDB connection pool for Remult, use the remultExpress middleware’s dataProvider option.

First, you must install mongodb as a dependency in your project, like so:

> npm i mongodb
Enter fullscreen mode Exit fullscreen mode

The dataProvider option can accept an async() function that connects to MongoDB and returns the MongoDataProvider object, which acts as a connector for Remult.

// src/server/api.ts

import { MongoDataProvider } from "remult/remult-mongo";

export const api = remultExpress({
  dataProvider: async () => {
    const client = new MongoClient(process.env.MONGO_URL || "");
    await client.connect();
    console.log("Database connected");
    return new MongoDataProvider(client.db("remult-booking"), client);
  },
});
Enter fullscreen mode Exit fullscreen mode

Generate the API endpoints with Remult entities

Entities are used by Remult for generating API endpoints, API queries, and database commands. entity is used as a model class for both frontend and backend code.

We will require two entities in order to define the booking object and the available daily slots.

Create a shared folder inside src, and it will include code shared between the frontend and backend. Then, create another subfolder for storing entities in the shared folder, and create the entity class files: Booking.entity.ts and Slot.entity.ts.

To create an entity, define a class with the required properties, and use the @Entity decorator. The @Entity decorator accepts an essential argument used to determine the API route, the default database collection or table name, and an options argument used to define entity-related properties and operations.

For this guide, the Slot entity can be defined as follows:

// src/shared/entities/Slot.entity.ts

import { Entity, Fields, IdEntity } from "remult";

@Entity("slots")
export class Slot extends IdEntity {
  @Fields.string()
  startTime: String;

  @Fields.string()
  endTime: String;
}
Enter fullscreen mode Exit fullscreen mode

The @Fields.string decorator defines an entity data field of type String. This decorator is also used to describe field-related properties like validation rules and operations.

// src/shared/entities/Booking.entity.ts

import { Entity, Fields, IdEntity, Validators } from "remult";

@Entity("bookings", {
  allowApiCrud: true
})
export class Booking extends IdEntity {
  @Fields.string({
    validate: Validators.required,
  })
  name: String;

  @Fields.string({
    validate: Validators.required,
  })
  email: String;

  @Fields.string({ validate: Validators.required })
  description: String;

  @Fields.string({
    validate: Validators.required,
  })
  date: String;

  @Fields.string({
    validate: Validators.required,
  })
  slotId: string;
}
Enter fullscreen mode Exit fullscreen mode

Now that both entities are defined, let's add them to theremultExpress middleware’s entities property. We can also seed initial data to the slots collection using the initApi property.

// src/server/api.ts

import { Slot } from "../shared/entities/Slot.entity";
import { Booking } from "../shared/entities/Booking.entity";

export const api = remultExpress({
  entities: [Slot, Booking],
  initApi: async (remult) => {
    const slotRepo = remult.repo(Slot);
    const shouldAddAvailablSlots = (await slotRepo.count()) === 0;

    if (shouldAddAvailablSlots) {
      const availableSlots = [10, 11, 12, 13, 14, 15, 16, 17].map((time) => ({
        startTime: `${time}:00`,
        endTime: `${time}:45`,
      }));

      await slotRepo.insert(availableSlots);
    }
  },
  dataProvider: async () => {
    // ...
  },
});
Enter fullscreen mode Exit fullscreen mode

Build and style the frontend

Let's begin working on the app’s frontend by building the form UI. Book Appointment Form To start, replace the default boilerplate code in the src/App.tsx file with the following code:

// src/App.tsx

import "./App.css";
import { BookingForm } from "./components/BookingForm";

function App() {
  return (
    <div className="App">
      <header className="hero hero-sm bg-primary ">
        <div className="hero-body text-center">
          <div className="container grid-md">
            <h1>Book an appointment</h1>
          </div>
        </div>
      </header>
      <BookingForm />
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Now, let’s add the Spectre.css library to make the user interface look presentable.

> npm i spectre.css
Enter fullscreen mode Exit fullscreen mode

You can refer to the following code for the BookingForm component:

// src/components/BookingForm.tsx

import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { remult } from "../common";
import { Booking } from "../shared/entities/Booking.entity";
import { Slot } from "../shared/entities/Slot.entity";

const bookingRepo = remult.repo(Booking);

export const BookingForm = () => {
  const {
    register,
    handleSubmit,
    setValue,
    watch,
    setError,
    clearErrors,
    reset,
    formState: { errors },
  } = useForm();

  const [availableDates, setAvailableDates] = useState<string[]>([]);
  const [availableSlots, setAvailableSlots] = useState<Slot[]>([]);

  const [isSubmitting, setSubmitting] = useState<boolean>(false);

  const bookingDate = watch("date");

  const onSubmit = async (values: Record<string, any>) => {
    try {
      setSubmitting(true);
      const data = await bookingRepo.save(values);
      console.log({ data });
      reset();
    } catch (error: any) {
      setError("formError", {
        message: error?.message,
      });
    } finally {
      setSubmitting(false);
    }
  };

  // JSX code
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <>...</>
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

Here, we use the react-hook-form library to manage the form state and input values.

To save the submitted values in the bookings collection, we need to create a repository object for the Booking entity.

const bookingRepo = remult.repo(Booking);
Enter fullscreen mode Exit fullscreen mode

Remult repository objects provide methods to perform CRUD operations on the entity. In this case, we’re using the save() repository method to insert data into the collection.

await bookingRepo.save(values);
Enter fullscreen mode Exit fullscreen mode

Add backend-only methods

At times, you might want to create custom APIs with additional logic, like sending an email, performing multiple database operations, or completing other sequential tasks.

Multiple DB operations must be only performed on the backend because having various entity-level functions on the frontend could impact the app performance.

One way to implement backend-only methods in Remult is to create a controller class and use the @BackendMethod decorator.

For our project’s booking form, let's create two backend methods. The first method, getAvailableDates(), will get the next five available working days. The second method, getAvailableSlots(), will get the available booking slots by date.

// src/shared/controllers/Booking.controller.ts

import { BackendMethod, Remult } from "remult";
import { Booking } from "../entities/Booking.entity";
import { Slot } from "../entities/Slot.entity";
import { addWeekDays, formattedDate } from "../utils/date";

export class BookingsController {
  @BackendMethod({ allowed: true })
  static async getAvailableDates() {
    const addDates = (date: Date, count = 0) =>
      formattedDate(addWeekDays(date, count));

    return Array.from({ length: 5 }).map((v, idx) => addDates(new Date(), idx));
  }

  @BackendMethod({ allowed: true })
  static async getAvailableSlots(date: string, remult?: Remult) {
    if (!remult) return [];
    const unavailableSlotIds = (
      await remult.repo(Booking).find({ where: { date } })
    ).map((booking) => booking.slotId);

    const availableSlots = await remult
      .repo(Slot)
      .find({ where: { id: { $ne: unavailableSlotIds } } });

    return availableSlots;
  }
}
Enter fullscreen mode Exit fullscreen mode

The allowed property in the @BackendMethod decorator defines whether the requesting user has access to the API. In this case, it's true because we want the API to be public.

You can have authorization rules that control the value of the allowed property. Backend methods also have access to the remult object in order to execute DB operations.

To use the backend methods, you don't have to make any API calls manually. Just import the controller in your frontend code and call the methods directly as you would for any other module.

Internally, Remult makes the API call for you using the HTTP client that was defined in the frontend code when you initialized Remult. This way, you’re guaranteed that the APIs are type-safe and easier to maintain.

// src/components/BookingForm.tsx

import { BookingsController } from "../shared/controllers/Booking.controller";

export const BookingForm = () => {
   // ...
  useEffect(() => {
    BookingsController.getAvailableDates().then(setAvailableDates);
  }, []);

  useEffect(() => {
    if (!availableDates.length) return;
    setValue("date", availableDates[0]);
    BookingsController.getAvailableSlots(availableDates[0]).then(
      setAvailableSlots
    );
  }, [availableDates]);

  useEffect(() => {
    BookingsController.getAvailableSlots(bookingDate).then(setAvailableSlots);
  }, [bookingDate]);

  useEffect(() => {
    setValue("slotId", availableSlots[0]?.id);
  }, [availableSlots]);
 // ...
}
Enter fullscreen mode Exit fullscreen mode

As shown below, the Date and Available dropdown form fields are now pre-filled by default. Date and Available Slots Filled Available Slots Dropdown

If we try to submit the form with incomplete values, the validation rules added in the Booking entity will fail and return an error. Name Field Error

To check out the complete code from this article, see the GitHub repo.

Conclusion

Remult is a great framework that allows you to build type-safe, full-stack apps quickly and easily. Its straightforward syntax makes Remult the perfect tool for any developer looking to start type-safe programming. You can check out the official documentation for more in-depth explanations of the methods covered in this guide.

So what are you waiting for? Give Remult a try today!


Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

Diagnose Issues In Your TypeScript Apps

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

Top comments (0)