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:
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
Before writing an article, I looked on Habré for articles about a similar case, I found the following:
- Angular and SEO: how to make them friends?
- Firebase + Angular Universal = the impossible is possible
- Making Angular friends with Google (Angular Universal)
- Amazing Angular
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
. 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 , :
, 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;
}
:
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 .
! 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 { }
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 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, :
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, , .
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:
. , DBeaver:
! , :
! fullstack , .
P.S. :
- Ng-Zorro , Angular Material. - ;
- , , . , "" MVP , ;
- http://localhost:4200/api