Writing a full stack monolith with Angular Universal + NestJS + PostgreSQL

Hello, Habr!


In this article, we will create a ready-made monolith template that can be used as a basis for a new fullstack application as a skeleton for hanging functionality.



This article will be helpful if you:



  • Beginner fullstack developer;
  • A startup who writes an MVP to test a hypothesis.


Why I chose such a stack:



  • Angular: I have a lot of experience with it, I love strict architecture and Typescript out of the box, comes from .NET
  • NestJS: the same language, the same architecture, fast writing REST API, the ability to switch to Serverless in the future (cheaper than a virtual machine)
  • PostgreSQL: I am going to host in Yandex.Cloud, at minimums it is 30% cheaper than MongoDB


Yandex price



Before writing an article, I looked on Habré for articles about a similar case, I found the following:





From this it does not describe "copied and pasted" or provides links to what else needs to be finalized.



Table of contents:



1. Create an Angular application and add the ng-zorro component library

2. Install NestJS and solve problems with SSR

3. Make an API in NestJS and connect it to the front

4. Connect the PostgreSQL database





1. Angular



Angular-CLI SPA- :



npm install -g @angular/cli


Angular :



ng new angular-habr-nestjs


, :



cd angular-habr-nestjs
ng serve --open


Angular Static SPA Application



. NG-Zorro:



ng add ng-zorro-antd


:



? Enable icon dynamic loading [ Detail: https://ng.ant.design/components/icon/en ] Yes
? Set up custom theme file [ Detail: https://ng.ant.design/docs/customize-theme/en ] No
? Choose your locale code: ru_RU
? Choose template to create project: sidemenu


app.component , :



Connected NG-Zorro



, src/app/pages/welcome, NG-Zorro:





// welcome.component.html
<nz-table #basicTable [nzData]="items$ | async">
  <thead>
  <tr>
    <th>Name</th>
    <th>Age</th>
    <th>Address</th>
  </tr>
  </thead>
  <tbody>
  <tr *ngFor="let data of basicTable.data">
    <td>{{ data.name }}</td>
    <td>{{ data.age }}</td>
    <td>{{ data.address }}</td>
  </tr>
  </tbody>
</nz-table>


// welcome.module.ts
import { NgModule } from '@angular/core';

import { WelcomeRoutingModule } from './welcome-routing.module';

import { WelcomeComponent } from './welcome.component';
import { NzTableModule } from 'ng-zorro-antd';
import { CommonModule } from '@angular/common';

@NgModule({
  imports: [
    WelcomeRoutingModule,
    NzTableModule, //   
    CommonModule //    async
  ],
  declarations: [WelcomeComponent],
  exports: [WelcomeComponent]
})
export class WelcomeModule {
}


// welcome.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { share } from 'rxjs/operators';

@Component({
  selector: 'app-welcome',
  templateUrl: './welcome.component.html',
  styleUrls: ['./welcome.component.scss']
})
export class WelcomeComponent implements OnInit {
  items$: Observable<Item[]> = of([
    {name: '', age: 24, address: ''},
    {name: '', age: 23, address: ''},
    {name: '', age: 21, address: ''},
    {name: '', age: 23, address: ''}
  ]);

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
  }

  //     ,  
  getItems(): Observable<Item[]> {
    return this.http.get<Item[]>('/api/items').pipe(share());
  }
}

interface Item {
  name: string;
  age: number;
  address: string;
}


:



Nameplate NG-Zorro





2. NestJS



NestJS , Angular Universal (Server Side Rendering) .



ng add @nestjs/ng-universal


, SSR :



npm run serve


:) :



TypeError: Cannot read property 'indexOf' of undefined
    at D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\utils\setup-universal.utils.js:35:43
    at D:\Projects\angular-habr-nestjs\dist\server\main.js:107572:13
    at View.engine (D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\utils\setup-universal.utils.js:30:11)
    at View.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\view.js:135:8)
    at tryRender (D:\Projects\angular-habr-nestjs\node_modules\express\lib\application.js:640:10)
    at Function.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\application.js:592:3)
    at ServerResponse.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\response.js:1012:7)
    at D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\angular-universal.module.js:60:66
    at Layer.handle [as handle_request] (D:\Projects\angular-habr-nestjs\node_modules\express\lib\router\layer.js:95:5)
    at next (D:\Projects\angular-habr-nestjs\node_modules\express\lib\router\route.js:137:13)


, server/app.module.ts liveReload false:



import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { join } from 'path';

@Module({
  imports: [
    AngularUniversalModule.forRoot({
      viewsPath: join(process.cwd(), 'dist/browser'),
      bundle: require('../server/main'),
      liveReload: false
    })
  ]
})
export class ApplicationModule {}


, - Ivy :



// tsconfig.server.json
{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "outDir": "./out-tsc/server",
    "target": "es2016",
    "types": [
      "node"
    ]
  },
  "files": [
    "src/main.server.ts"
  ],
  "angularCompilerOptions": {
    "enableIvy": false, //  
    "entryModule": "./src/app/app.server.module#AppServerModule"
  }
}


ng run serve SSR .



Angular SSR + NestJS



! SSR , devtools .



extractCss: true, styles.js, styles.css:



// angular.json
...
"architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist/browser",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "aot": true,
            "assets": [
              "src/favicon.ico",
              "src/assets",
              {
                "glob": "**/*",
                "input": "./node_modules/@ant-design/icons-angular/src/inline-svg/",
                "output": "/assets/"
              }
            ],
            "extractCss": true, //  
            "styles": [
              "./node_modules/ng-zorro-antd/ng-zorro-antd.min.css",
              "src/styles.scss"
            ],
            "scripts": []
          },
...


app.component.scss:



// app.component.scss
@import "~ng-zorro-antd/ng-zorro-antd.min.css"; //  

:host {
  display: flex;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.app-layout {
  height: 100vh;
}
...


, SSR , SSR, CSR (Client Side Rendering). :



import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: '/welcome' },
  { path: 'welcome', loadChildren: () => import('./pages/welcome/welcome.module').then(m => m.WelcomeModule) }
];

@NgModule({
  imports: [RouterModule.forRoot(routes, {initialNavigation: 'enabled', scrollPositionRestoration: 'enabled'})], //  initialNavigation, scrollPositionRestoration
  exports: [RouterModule]
})
export class AppRoutingModule { }


  • initialNavigation: 'enabled' , SSR
  • scrollPositionRestoration: 'enabled' .



    3. NestJS



server items:



cd server
nest g module items
nest g controller items --no-spec


// items.module.ts
import { Module } from '@nestjs/common';
import { ItemsController } from './items.controller';

@Module({
  controllers: [ItemsController]
})
export class ItemsModule {
}


// items.controller.ts
import { Controller } from '@nestjs/common';

@Controller('items')
export class ItemsController {}


. items :



// server/src/items/items.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';

class Item {
  name: string;
  age: number;
  address: string;
}

@Controller('items')
export class ItemsController {

  //      Angular 
  private items: Item[] = [
    {name: '', age: 24, address: ''},
    {name: '', age: 23, address: ''},
    {name: '', age: 21, address: ''},
    {name: '', age: 23, address: ''}
  ];

  @Get()
  getAll(): Item[] {
    return this.items;
  }

  @Post()
  create(@Body() newItem: Item): void {
    this.items.push(newItem);
  }
}


GET Postman:



GET requests for the NestJS apish



, ! , GET items api, server/main.ts NestJS:



// server/main.ts
import { NestFactory } from '@nestjs/core';
import { ApplicationModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule);
  app.setGlobalPrefix('api'); //  
  await app.listen(4200);
}
bootstrap();


. welcome.component.ts :



// welcome.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { share } from 'rxjs/operators';

@Component({
  selector: 'app-welcome',
  templateUrl: './welcome.component.html',
  styleUrls: ['./welcome.component.scss']
})
export class WelcomeComponent implements OnInit {
  items$: Observable<Item[]> = this.getItems(); //   

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
  }

  getItems(): Observable<Item[]> {
    return this.http.get<Item[]>('/api/items').pipe(share());
  }
}

interface Item {
  name: string;
  age: number;
  address: string;
}


, SSR, :



Jerking apiha in SSR



SSR :



// welcome.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable, of } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { share } from 'rxjs/operators';

@Component({
  selector: 'app-welcome',
  templateUrl: './welcome.component.html',
  styleUrls: ['./welcome.component.scss']
})
export class WelcomeComponent implements OnInit {
  items$: Observable<Item[]> = this.getItems(); //   

  constructor(private http: HttpClient) {
  }

  ngOnInit() {
  }

  getItems(): Observable<Item[]> {
    return this.http.get<Item[]>('http://localhost:4200/api/items').pipe(share()); //       SSR  
  }
}

interface Item {
  name: string;
  age: number;
  address: string;
}


( SSR, ), :



  • @nguniversal/common:


npm i @nguniversal/common


  • app/app.module.ts SSR:


// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { IconsProviderModule } from './icons-provider.module';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { NzMenuModule } from 'ng-zorro-antd/menu';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NZ_I18N } from 'ng-zorro-antd/i18n';
import { ru_RU } from 'ng-zorro-antd/i18n';
import { registerLocaleData } from '@angular/common';
import ru from '@angular/common/locales/ru';
import {TransferHttpCacheModule} from '@nguniversal/common';

registerLocaleData(ru);

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'serverApp' }),
    TransferHttpCacheModule, // 
    AppRoutingModule,
    IconsProviderModule,
    NzLayoutModule,
    NzMenuModule,
    FormsModule,
    HttpClientModule,
    BrowserAnimationsModule
  ],
  providers: [{ provide: NZ_I18N, useValue: ru_RU }],
  bootstrap: [AppComponent]
})
export class AppModule { }


app.server.module.ts:



// app.server.module.ts
import { NgModule } from '@angular/core';
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    ServerTransferStateModule, // 
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}


. SSR, , .



No request, data available!





4. PostgreSQL



PostgreSQL, TypeORM :



npm i pg typeorm @nestjs/typeorm


: PostgreSQL .



server/app.module.ts:



// server/app.module.ts
import { Module } from '@nestjs/common';
import { AngularUniversalModule } from '@nestjs/ng-universal';
import { join } from 'path';
import { ItemsController } from './src/items/items.controller';
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    AngularUniversalModule.forRoot({
      viewsPath: join(process.cwd(), 'dist/browser'),
      bundle: require('../server/main'),
      liveReload: false
    }),
    TypeOrmModule.forRoot({ //    
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'postgres',
      password: 'admin',
      database: 'postgres',
      entities: ['dist/**/*.entity{.ts,.js}'],
      synchronize: true
    })
  ],
  controllers: [ItemsController]
})
export class ApplicationModule {}


:



  • type: ,
  • host port:
  • username password:
  • database:
  • entities: ,


, Item :



// server/src/items/item.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm/index';

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

  @CreateDateColumn()
  createDate: string;

  @Column()
  name: string;

  @Column()
  age: number;

  @Column()
  address: string;
}




// items.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ItemEntity } from './item.entity';
import { ItemsController } from './items.controller';

@Module({
  imports: [
    TypeOrmModule.forFeature([ItemEntity]) //  -    
  ],
  controllers: [ItemsController]
})
export class ItemsModule {
}


, , :



// items.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
import { ItemEntity } from './item.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm/index';

interface Item {
  name: string;
  age: number;
  address: string;
}

@Controller('items')
export class ItemsController {

  constructor(@InjectRepository(ItemEntity)
              private readonly itemsRepository: Repository<ItemEntity>) { //  
  }

  @Get()
  getAll(): Promise<Item[]> {
    return this.itemsRepository.find();
  }

  @Post()
  create(@Body() newItem: Item): Promise<Item> {
    const item = this.itemsRepository.create(newItem);
    return this.itemsRepository.save(item);
  }
}


Postman:



POST to apiha with base



. , DBeaver:



Records in the database



! , :



Working fullstack application



! fullstack , .



P.S. :





:






All Articles