DEV Community

Ivan Golubic
Ivan Golubic

Posted on

Refine & Nest.js boilerplate

We were in search of a solution that bridged the gap between BaaS platforms (such as Appwrite or Supabase) and building from the ground up. We needed a system that would offer the flexibility to implement BI, data management, IoT, automation, and similar applications, while also incorporating features like authentication, RBAC, social login, a frontend equipped with CRUD operations, testing, and more.

And this is how Refine & Nest.js boilerplate was born.

Source:

Refine boilerplate: https://github.com/poliath/poliath-refine-boilerplate
Nest.JS boilerplate: https://github.com/poliath/nestjs-poliath-boilerplate
Quick start guide: https://github.com/poliath/nestjs-poliath-boilerplate/blob/master/QUICK_START_GUIDE.md

And here is the Quick start guide

Refine + Nest.JS Boilerplate

Last edited by: Ivan Golubic
Last edited time: August 16, 2023 3:27 PM

This document describes the procedure to create new project using Poliath Refine and Nest.JS templates.

Requirements

Node.js: Minimum version is 16, but 18 is highly recommended

npm: installed and updated

Docker: installed, configured and running

IDE: WebStorm is recommended, although VS Code will make it

Backend

In order to start our Nest.JS backend, we have to create our new project, based on the template

git clone --depth 1 https://github.com/poliath/nestjs-poliath-boilerplate.git my-app
cd my-app/
cp env-example .env
Enter fullscreen mode Exit fullscreen mode

Quick run

In order to start the project without development (for a quick test), from your app directory run the command:

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

This command will:

  1. Wait for Postgres database container to spin up
  2. Run migration (create needed tables in the database)
  3. Run seeder (create 2 users and few random articles)
  4. Run application in production mode

Frontend

In order to see data from your backend service we have to run our frontend application (refine based React application). Clone the poliath-refine-boilerplate repository in preferred directory:

git clone --depth 1 https://github.com/poliath/poliath-refine-boilerplate.git frontend
Enter fullscreen mode Exit fullscreen mode

Now, head to your directory and install dependencies:

cd frontend/
npm install
Enter fullscreen mode Exit fullscreen mode

After installing dependencies, we have to build our project and run preview mode (basically a local server that will serve our static content.

npm run build
npm run preview
Enter fullscreen mode Exit fullscreen mode

This command will show you where your application is running, e.g.:

Local:   http://localhost:4173/
Enter fullscreen mode Exit fullscreen mode

head to the location and login. You should see demo users and articles.

RBAC explained

Both backend and frontend have RBAC implemented. Admin user can do all operations on Users object, while ‘User’ user can only see articles and manage articles. Later in this documentation this feature will be explained in detail, but in order to experience it login with:

  1. admin@example.com - Admin
  2. john.doe@example.com - User

*****************************************************Password for both users is:***************************************************** secret

Inferencer explained

One of the important features of refine.dev is Inferencer (in short, it generates CRUD dashboard based on data from the database, and it provides you the code). If you click on ***************Articles*************** you will see the pop-up. This pop-up is not visible on *******Users******* since raw code (generated by Inferencer) was already implemented in this boilerplate, while in Articles it is not, for showcasing the purpose. You can read more about Inferencer here: https://refine.dev/docs/packages/documentation/inferencer/


If everything works, then we can proceed with development setup and develop our application.

Backend - development

*****If Docker containers from Quick run are still running, shut them down in order to avoid any collisions during development phase!***** Head to your backend directory (e.g. my-app) and run:

docker compose down
Enter fullscreen mode Exit fullscreen mode

***********************************************************************************************************************************************************************************************************************************************************************************************************If frontend is running it also might create some confusion, so simple exit the preview server by pressing (Ctrl+C or appropriate shortcut for your OS).***********************************************************************************************************************************************************************************************************************************************************************************************************

In this step we will assume that you already cloned nestjs-poliath-boilerplate project. Open your backend directory in your IDE.

In order to start development, we need to update two environment variables (since we will run Nest.JS project on our local machine using NPM, but our database and some development tools will be in Docker. Open your .env file and;

  1. Change DATABASE_HOST=postgres to DATABASE_HOST=localhost
  2. Change MAIL_HOST=maildev to MAIL_HOST=localhost

Run Docker containers:

docker compose up -d postgres adminer maildev
Enter fullscreen mode Exit fullscreen mode

Notice that this command will not start our api container.

Running Nest.JS project locally

Since we are running our project on our local machine, we have to install dependencies, run seeder and start in development environment.

npm install

npm run migration:run

npm run seed:run

npm run start:dev
Enter fullscreen mode Exit fullscreen mode

This will start our backend, and you should see logs in the terminal.

Frontend - development

Running frontend in development mode is extremely simple. Open your frontend directory in your IDE. And in the terminal, run the following command:

npm run dev
Enter fullscreen mode Exit fullscreen mode

This will start the development server and it will show you on which port it is currently running. If everything is good, you should be able to log in and see your application up and running.

Backend - adding new resource

Now we will add new resource to our backend. For this purpose it will be a simple “Task”, it will have: id, title, done, created at, updated at, author, assignee.

We will use Nest CLI for most of our work, so let’s install it:

npm install -g @nestjs/cli
Enter fullscreen mode Exit fullscreen mode

Nest provides CRUD generator as part of its CLI tool. It generates starting point for your resource. Let’s generate our Tasks resource:

nest g resource
Enter fullscreen mode Exit fullscreen mode

This will start a simple wizard:

? What name would you like to use for this resource (plural, e.g., "users")? tasks
Enter fullscreen mode Exit fullscreen mode

Next, select “REST API” for transport layer.

Select Yes for generating CRUD entry points.

****************************This will generate all needed files for our “Tasks” resource under tasks directory. Nest will not create DTO by default, it will create only a simple class that we should populate with our data.****************************

Note: The database table is not created for each model but only for those models which are declared as entities. To declare a model as an entity, we just need to add the ‘@Entity()‘ decorator before the declaration of the Class defining our model.

import {
  Column,
  CreateDateColumn,
  Entity,
  ManyToOne,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';

@Entity()
export class Task {
  @PrimaryGeneratedColumn()
  id: number;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt?: Date;

  @Column({ type: String, nullable: false })
  title: string;

  @Column({ default: false })
  done: boolean;

  @ManyToOne(() => User, (user) => user.tasks)
  author: User;

  @ManyToOne(() => User, (user) => user.tasks)
  assignee: User;
}
Enter fullscreen mode Exit fullscreen mode

In the above’s code we created our task model. Nest provides multiple decorators like *@CreateDateColumn()* that will automatically add date when the entity is created. Additionally *********************************PrimaryGeneratedColumn********************************* is used as primary key. We can pass various *********Column********* options like type or default.

Additionally, we have ***************ManyToOne*************** relationship since User can be author to many Tasks, and User can have many tasks where he is assignee (in our case Task can have only one assignee.

Additionally, since we have foreign relationships for User, we have to add it to the *************user.entity.ts:*************

@OneToMany(() => Task, (task) => task.author)
  tasks?: Task[];

@OneToMany(() => Task, (task) => task.assignee)
assignedTasks?: Task[];
Enter fullscreen mode Exit fullscreen mode

Next, we have to create a migration to be able to apply our changes to the database tables.

npm run migration:generate -- src/database/migrations/CreateTasks
Enter fullscreen mode Exit fullscreen mode

This command will generate new migration file under migrations. Name will be <timestamp-CreateTasks.ts

Next, we have to run our migration:

npm run migration:run
Enter fullscreen mode Exit fullscreen mode

If something is wrong with our previous migration, we can simply revert it:

npm run migration:revert
Enter fullscreen mode Exit fullscreen mode

Above our Tasks Controller class, we have to add the following:

@Controller({
  path: 'tasks',
  version: '1',
})
@UseGuards(AuthGuard('jwt'))
@ApiTags('Tasks')
@ApiBearerAuth()
Enter fullscreen mode Exit fullscreen mode

Controller decorator will define that that class acts as a controller, which is called though “tasks” path, and version of this API is 1. ***************************************************************This will be also visible ins Swagger***************************************************************.

UseGuards decorator will define the guards, in this case only jwt (so only authenticated users will be able to access this route. This boilerplate also supports *****************RolesGuard***************** - which is used in conjunction with *******Roles******* decorator, in order to restrict user access for a specific group of users.

ApiTags defines how this route set will be called in swagger.

ApiBearerAuth defines that this controller needs Bearer token in order to be accessed.

Methods in Controller

Since we selected Yes for generating CRUD entry points during resource creation, each method is defined in the controller and has its own decorator (Post, Get, Delete, Patch).

Calling ***************findAll()*************** method. In this case we can use Postman to call http://localhost:3000/api/v1/tasks and it should return the message: “This action returns all tasks”, since our ***************************Service is not implemented yet!***************************

If it returns error 401, that means that you did not provide bearer token, which you will get by calling a POST request to the http://localhost:3000/api/v1/auth/email/login with the following JSON body:

{
    "email": "admin@example.com",
    "password" : "secret"
}
Enter fullscreen mode Exit fullscreen mode

Then add your Bearer token to the Authorization field in Postman.

If you want to restrict a specific route to a specific (or multiple) role, then you have to add the following decorator above the method:

@Roles(RoleEnum.user)
@UseGuards(AuthGuard('jwt'), RolesGuard)
Enter fullscreen mode Exit fullscreen mode

This will restrict access only to logged in user of type user. And if you try to call GET method to the previous route (while logged in with Admin account), you will get the following error:

{
    "message": "Forbidden resource",
    "error": "Forbidden",
    "statusCode": 403
}
Enter fullscreen mode Exit fullscreen mode

DTO’s

Data Transfer Object basically transforms our data between our service and database. In TypeORM it can define the structure and validation of our entity, in this case a Task.

Two main DTO’s are generated by default, “create” and “update”, in some cases they will be the same so “update will only partially extend the “create”, and in some cases it will be different. In our case it will extend our “create: DTO:

export class UpdateTaskDto extends PartialType(CreateTaskDto) {}
Enter fullscreen mode Exit fullscreen mode

You can see that this is already generated by default.

Back to “crate” DTO.

According to our task entity, we must provide title, author (id) and assignee (id).

For simplicity our Task DTO will look like this:

import { IsNotEmpty } from 'class-validator';

export class CreateTaskDto {
  @IsNotEmpty()
  title: string;
  @IsNotEmpty()
  author: number;
  @IsNotEmpty()
  assignee: number;
}
Enter fullscreen mode Exit fullscreen mode

It basically defines only that one of the fields cannot be empty. There are multiple decorators and validators that can be used.

Services

In order to actually do something with our data, we must create some logic. Logic is handled in tasks.service.ts file. First we will write a code for creating new task.

At.the very beginning we must inject the repository in the constructor of our service class:

constructor(
    @InjectRepository(Task)
    private tasksRepository: Repository<Task>,
  ) {}
Enter fullscreen mode Exit fullscreen mode

Repository is a part of TypeORM and it is basically used for our interaction with the database.

In order to insert author of our Task, we have to pass current user to the service, so our create method will look like this:

create(createTaskDto: CreateTaskDto, user: User) {
    createTaskDto.author = user;
    const newTask = this.tasksRepository.save(
      this.tasksRepository.create(createTaskDto),
    );

    return newTask;
  }
Enter fullscreen mode Exit fullscreen mode

*****************************************************************************************************************************************************But this will not work out of the box, since we need to pass User object to our create method. This boilerplate contains CurrentUser decorator, which returns currently logged in user.*****************************************************************************************************************************************************

So, edit “tasks.controller.ts”:

@Post()
  create(@Body() createTaskDto: CreateTaskDto, @CurrentUser() user: User) {
    return this.tasksService.create(createTaskDto, user);
  }
Enter fullscreen mode Exit fullscreen mode

Now we can send POST request to the http://localhost:3000/api/v1/tasks route with the following JSON body:

{
    "title": "This is a task 4",
    "assignee": 2
}
Enter fullscreen mode Exit fullscreen mode

This will assign this task to our user with id = 2 (John Doe), and author will be our signed in user (Admin).

*****************************************************************************************************Next, we will create service for fetching all tasks.*****************************************************************************************************

In order to do that, we have to edit *****findAll()***** method in tasks.service.ts:

findAll() {
    return this.tasksRepository.find();
  }
Enter fullscreen mode Exit fullscreen mode

This is a simple method that calls *********find()*********, which is basically like SELECT * FROM TASKS; Now we can call GET method on the http://localhost:3000/api/v1/tasks endpoint.

This will return JSON Array of our tasks:

[
    {
        "id": 1,
        "createdAt": "2023-08-15T10:18:14.437Z",
        "updatedAt": "2023-08-15T10:18:14.437Z",
        "title": "This is a task 1",
        "done": false
    },
    {
        "id": 2,
        "createdAt": "2023-08-15T16:45:51.630Z",
        "updatedAt": "2023-08-15T16:45:51.630Z",
        "title": "This is a task 2",
        "done": false
    }
]
Enter fullscreen mode Exit fullscreen mode

Next, we will implement *************findOne()************* method in order to fetch data for specific task:

@Get(':id')
  findOne(@Param('id') id: string) {
    return this.tasksService.findOne({ id: +id });
  }
Enter fullscreen mode Exit fullscreen mode

First we will update our findOne method in controller to pass {id: +id} since we will use ***********EntityCondition*********** to pass fields. Finally our findOne method in service will look like this:

findOne(fields: EntityCondition<Task>): Promise<NullableType<Task>> {
    return this.tasksRepository.findOne({ where: fields });
  }
Enter fullscreen mode Exit fullscreen mode

*************************************Next we will write our update method in service:*************************************

update(id: Task['id'], payload: DeepPartial<Task>): Promise<Task> {
    return this.tasksRepository.save(
      this.tasksRepository.create({
        id,
        ...payload,
      }),
    );
  }
Enter fullscreen mode Exit fullscreen mode

As you can see, we are accepting task id and payload, which is basically Partial DTO, and this method returns Task that we updated.

*************Next, we will implement delete service*************

This is pretty simple method:

remove(id: number) {
    return this.articlesRepository.delete(id);
  }
Enter fullscreen mode Exit fullscreen mode

Please note that there are various implementations of these services, I provided the simple ones, there are various ways of updating and handling errors, responses, etc. But this can be implemented based on a specific use-case.

Frontend - adding new resource

Once we have our backend up and running, we can implement our frontend.

We will use *************refine CLI************* for creating new resources. Refine CLI is installed when project is created with refine create command, but in this boilerplate is already available.

Position your terminal to your frontend directory (e.g. my-app) and run the following command:

npm run refine create-resource
Enter fullscreen mode Exit fullscreen mode

This wizard will ask you to define your resource name (’tasks’ in our case), and with leave all pages selected. This will generate *******tasks******* directory with all pages needed. Also, it will update App.tsx with new resource data.

Please note that this command will NOT generate routes, and it will only use Inferencer in generated pages.

***********************************************************************First we will add routes for our tasks***********************************************************************

<Route path="/tasks">
                  <Route index element={<TasksList />} />
                  <Route path="create" element={<TasksCreate />} />
                  <Route path="edit/:id" element={<TasksEdit />} />
                  <Route path="show/:id" element={<TasksShow />} />
                </Route>
Enter fullscreen mode Exit fullscreen mode

These routes are directly related to our pages, but they won’t be visible immediately due to RBAC restrictions.

We have to edit *********casbin********* access control. Open src/casbin/accessControl.ts and add the following to the adapter:

p, 1, tasks, (list)|(create)|(edit)|(show)|(delete)
p, 2, tasks, (list)|(create)|(edit)|(show)|(delete)
Enter fullscreen mode Exit fullscreen mode

This will enable both users and admins to make all CRUD operations.

Now if you refresh your frontend on localhost, and click on Tasks you should see the list of tasks.

***************************************************If not added automatically, add the following to the tasks resource in App.tsx:***************************************************

meta: {
           canDelete: true,
      },
Enter fullscreen mode Exit fullscreen mode

All pages are generated using Inferencer, a part from refine.dev: https://refine.dev/docs/packages/documentation/inferencer

If you click on the Inferencer pop up you will see the generated code for a list of tasks, copy this code and paste it in tasks/list.tsx (replace the current code). Pop up will disappear and you will have the flexibility to customize the page.

If there is an error in your console or your page is blank, probably page export is wrong (TaskList instead of TasksList), check for this kind of issues.

i18n - Translations

After exporting, we can edit translation for our page to display real values instead of object fields. Open public/locales/en/common.json

Add the following object to the same level as “users”:

"tasks": {
    "tasks": "Tasks",
    "fields": {
      "id": "ID",
      "title": "Title",
      "done": "Done",
      "createdAt": "Created at",
      "updatedAt": "Updated at"
    },
    "titles": {
      "create": "Create task",
      "edit": "Edit task",
      "list": "Tasks",
      "show": "Show task"
    }
  },
Enter fullscreen mode Exit fullscreen mode

Additionally, under documentTitle, on the same level as users add the following:

"tasks": {
      "list": "Tasks | Poliath Manager",
      "show": "#{{id}} Show task | Poliath Manager",
      "edit": "#{{id}} Edit task | Poliath Manager",
      "create": "Create new task | Poliath Manager",
      "clone": "#{{id}} Clone task | Poliath Manager"
    },
Enter fullscreen mode Exit fullscreen mode

The above’s is used for page titles.

Add the same fields for other languages.

When you refresh the page you should see how fields now have names.

Creating new task through our frontend

This is pretty straightforward, just click on Create button and fill out the fields. *Of course, you can copy the Inferencer code* to your create.tsx file and for example remove Created at and Updated at fields “since those are handled on our backend”.

When you click save - you will get an error 422 - Unprocessable entity, this is because we are not passing assignee parameter. But how to get it?

We can use *************useList*************, although it is a rough option since it loads all users into the memory and we are doing parsing on clients side. Instead we should use useMany, that will call our backend method and retrieve only filtered users that we actually need.

const { data: data, isLoading } = useList<IUser>({
        resource: 'users'
    });
Enter fullscreen mode Exit fullscreen mode

Swizzle

Refine provides some solutions out of the box, e.g. data providers, but this code sometimes does not meet our needs. That is is why swizzle exists, it basically generates the code that can be customized, based on the existing predefined code.

npm run refine swizzle
Enter fullscreen mode Exit fullscreen mode

This is how we can edit our components, auth pages, auth providers, etc.

https://refine.dev/docs/tutorial/understanding-dataprovider/swizzle/#what-is-swizzle

Sending an email

This boilerplate has implemented email service, and usage is implemented in src/mail/mail.service.ts which can be used as a reference for customization if needed.

Additional tools and info

Backend comes with handy tools for development, such as:

  1. Swagger - full API documentation (http://localhost:3000/docs)
  2. Adminer - client for database (http://localhost:8080)
  3. Maildev - SMTP server (http://localhost:1080)

Please note that these services should be disabled in production!

Running in production

Once you finish your backend logic, you can run it in production using Docker. You should disable above-mentioned development tools.

Please note that if you were running this boilerplate previously, in order to “catch” latest updates of your code, migrations, etc. You first have to rebuild docker image!

docker compose build --no-cache
Enter fullscreen mode Exit fullscreen mode

Logging is implemented by default and logs are available in the ./logs directory.

Conclusion

Please note that there can be a lot of situations where you will have to edit default code (e.g. change querying methods, update current DTO’s, etc.) But this is a great starting point for any project.

Additionally, this documentation will be updated or fixed as needed, with new features, fixes and improvements.

Issues and errors

Please be aware that this documentation is primarily designed to expedite your onboarding process. While it may offer some "shortcut" solutions, these are intended purely for illustrative purposes to help clarify the workings of this boilerplate. Should you encounter any issues with the boilerplate, whether on the frontend or backend, we encourage you to raise an issue on GitHub:

Top comments (0)