EventJoin us at AWS re:Invent 2024! Learn how to use MongoDB for AI use cases. Learn more >>

How to Use the MEAN Stack: Build a Web Application From Scratch

What is the MEAN stack?

MEAN is a technology stack used for building full-stack applications. It's a combination of the following technologies:

  • MongoDB — a document database
  • Express — a Node.js framework for building APIs
  • Angular — a front-end application framework
  • Node.js — a server-side JavaScript runtime environment

Applications built with the MEAN stack follow the client-server architecture. The client, built with Angular, can be a web application, a native mobile application, or a desktop application. The client communicates with the server through an API, which is built with Express. The server then manages the requests with the MongoDB database.

Client-Server Architecture

Client-Server Architecture

If you are a visual learner, check out the video version of this tutorial.

What will this tutorial cover?

In this tutorial, we'll build a RESTful API that implements the CRUD (Create, Read, Update, Delete) operations for an employee management application. For data persistence, we'll be using a MongoDB Atlas cluster.

We'll be building an employee management web application. The interface will have the following pages:

  • View all employees
  • Add new employees
  • Update existing employees

Here's what our finished application looks like:

gif of angular app demonstration

Getting started

You'll need Node.js and a MongoDB Atlas cluster to follow this tutorial.

  1. Visit https://nodejs.org/ to download and install the current version of Node.js. This tutorial is tested with Node.js version 20.11.1. To make sure you're using the correct Node.js version, execute the following command in your terminal:
 node --version
  1. Follow the Get Started with Atlas guide to set up your free MongoDB Atlas cluster. Make sure you complete all the steps and locate your cluster's connection string. You'll need it later when we're connecting to the database.

Building the server-side Node.js and Express application

Let's start by creating a directory that will host our project and its files. We'll name it mean-stack-example.

mkdir mean-stack-example
cd mean-stack-example

We'll be using shell commands to create directories and files throughout the tutorial. However, you're more than welcome to use any other method.

Now, let's create the directories and files that will host our server-side application — server and server/src — and also initialize a package.json file for it.

mkdir server && mkdir server/src
cd server
npm init -y

touch tsconfig.json .env
(cd src && touch database.ts employee.routes.ts employee.ts server.ts)

Installing dependencies

We'll need a few external packages to build the RESTful API and to connect to our MongoDB Atlas cluster. Let's install them using the npm install command.

npm install cors dotenv express mongodb

We'll also be using TypeScript for our server application. We'll install the TypeScript compiler and the supporting @types packages as development dependencies using the --save-dev flag. These packages are only used during development and shouldn't be included in the final production application.

npm install --save-dev typescript @types/cors @types/express @types/node ts-node

Finally, we'll paste the following into the tsconfig.json configuration file that TypeScript will use to compile our code.

mean-stack-example/server/tsconfig.json

{
  "include": ["src/**/*"],
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,  
    "forceConsistentCasingInFileNames": true,
    "strict": true,    
    "skipLibCheck": true,
    "outDir": "./dist"
  }
}

Create an employee interface on the server side

Since we’re building an employee management app, the main data unit is the employee. Let's create an Employee interface that will be used to define the structure of the employee object.

mean-stack-example/server/src/employee.ts

import * as mongodb from "mongodb";

export interface Employee {
    name: string;
    position: string;
    level: "junior" | "mid" | "senior";
    _id?: mongodb.ObjectId;
}

Our employees should have a name, position, and level. The _id field is optional because it's generated by MongoDB. So, when we're creating a new employee, we don't need to specify it. However, when we get an employee object from the database, it will have the _id field populated.

Connect to the database

Let's implement the following function to connect to our database:

mean-stack-example/server/src/database.ts

import * as mongodb from "mongodb";
import { Employee } from "./employee";

export const collections: {
    employees?: mongodb.Collection<Employee>;
} = {};

export async function connectToDatabase(uri: string) {
    const client = new mongodb.MongoClient(uri);
    await client.connect();

    const db = client.db("meanStackExample");
    await applySchemaValidation(db);

    const employeesCollection = db.collection<Employee>("employees");
    collections.employees = employeesCollection;
}

// Update our existing collection with JSON schema validation so we know our documents will always match the shape of our Employee model, even if added elsewhere.
// For more information about schema validation, see this blog series: https://mongodb.prakticum-team.ru/blog/post/json-schema-validation--locking-down-your-model-the-smart-way
async function applySchemaValidation(db: mongodb.Db) {
    const jsonSchema = {
        $jsonSchema: {
            bsonType: "object",
            required: ["name", "position", "level"],
            additionalProperties: false,
            properties: {
                _id: {},
                name: {
                    bsonType: "string",
                    description: "'name' is required and is a string",
                },
                position: {
                    bsonType: "string",
                    description: "'position' is required and is a string",
                    minLength: 5
                },
                level: {
                    bsonType: "string",
                    description: "'level' is required and is one of 'junior', 'mid', or 'senior'",
                    enum: ["junior", "mid", "senior"],
                },
            },
        },
    };

    // Try applying the modification to the collection, if the collection doesn't exist, create it
   await db.command({
        collMod: "employees",
        validator: jsonSchema
    }).catch(async (error: mongodb.MongoServerError) => {
        if (error.codeName === "NamespaceNotFound") {
            await db.createCollection("employees", {validator: jsonSchema});
        }
    });
}

We're using the MongoDB Node.js driver distributed as the mongodb NPM package. First, we create a new MongoClient object with the provided connection string. Then, we connect to the database. We need to use await since connect is an asynchronous function. After that, we get the db object from the client object. We use this object to get the employees collection. Note that the variable employees is cast to the Employee interface. This will provide type checking to any query we send to the database. Finally, we assign the employees collection to the collections object which is exported from this file. That way, we can access the employees collection from other files such as the employee.routes.ts file which will implement our RESTful API.

We're also using JSON schema validation to ensure that all of our documents follow the shape of our Employee interface. This is a good practice to ensure that we don't accidentally store data that doesn't match the shape of our model. To learn more about JSON schema validation, check out JSON Schema Validation - Locking down your model the smart way.

We'll be persisting our data to a MongoDB Atlas cluster. To connect to our cluster, we'll need to set an ATLAS_URI environment variable that contains the connection string. Add it to the server/.env file. Make sure you replace the username, password, and cluster placeholders with your credentials.

mean-stack-example/server/.env

ATLAS_URI=mongodb+srv://<username>:<password>@<your-cluster>.mongodb.net/meanStackExample?retryWrites=true&w=majority

Now, we can load the environment variables using the dotenv package. We can do that in any file but it's a good practice to do it as early as possible. Since the server.ts file will be our entry point, let's load the environment variables from it, connect to the database, and start the server:

mean-stack-example/server/src/server.ts

import * as dotenv from "dotenv";
import express from "express";
import cors from "cors";
import { connectToDatabase } from "./database";

// Load environment variables from the .env file, where the ATLAS_URI is configured
dotenv.config();

const { ATLAS_URI } = process.env;

if (!ATLAS_URI) {
  console.error(
    "No ATLAS_URI environment variable has been defined in config.env"
  );
  process.exit(1);
}

connectToDatabase(ATLAS_URI)
  .then(() => {
    const app = express();
    app.use(cors());

    // start the Express server
    app.listen(5200, () => {
      console.log(`Server running at http://localhost:5200...`);
    });
  })
  .catch((error) => console.error(error));

Let's run the app and see if everything works:

npx ts-node src/server.ts

You should see the following output:

Server running at http://localhost:5200...

Good job! Now, we’re ready to build the RESTful API for our employees.

Build the RESTful API

In this section, we'll implement a GET, POST, PUT, and DELETE endpoint for our employees. As you can notice, these HTTP methods correspond to the CRUD operations — Create, Read, Update, and Delete — we'll be performing on our employees in the database. The only caveat is that we'll have two GET endpoints: one for getting all employees and one for getting a single employee by ID.

To implement the endpoints, we'll use the router provided by Express. The file we'll be working in is src/employee.routes.ts.

GET /employees

Let's start by implementing the GET /employees endpoint which will allow us to get all the employees in the database.

mean-stack-example/server/src/employee.routes.ts

import * as express from "express";
import { ObjectId } from "mongodb";
import { collections } from "./database";

export const employeeRouter = express.Router();
employeeRouter.use(express.json());

employeeRouter.get("/", async (_req, res) => {
    try {
        const employees = await collections?.employees?.find({}).toArray();
        res.status(200).send(employees);
    } catch (error) {
        res.status(500).send(error instanceof Error ? error.message : "Unknown error");
    }
});

We're using the find() method. Because we're passing in an empty object — {} — we'll get all the employees in the database. We'll then use the toArray() method to convert the cursor to an array. Finally, we'll send the array of employees to the client.

You may notice that the route is /. This is because we'll register all endpoints from this file under the /employees route.

GET /employees/:id

Next, we'll implement the GET /employees/:id endpoint which will allow us to get a single employee by ID. Append the following to the bottom of employee.routes.ts:

mean-stack-example/server/src/employee.routes.ts

employeeRouter.get("/:id", async (req, res) => {
    try {
        const id = req?.params?.id;
        const query = { _id: new ObjectId(id) };
        const employee = await collections?.employees?.findOne(query);

        if (employee) {
            res.status(200).send(employee);
        } else {
            res.status(404).send(`Failed to find an employee: ID ${id}`);
        }
    } catch (error) {
        res.status(404).send(`Failed to find an employee: ID ${req?.params?.id}`);
    }
});

The ID of the employee is provided as a parameter. We use the ObjectId method to convert the string ID to a MongoDB ObjectId object. We then use the findOne() method to find the employee with the given ID. If the employee is found, we'll send it to the client. Otherwise, we'll send a "404 Not Found" error.

If you're wondering what the “?” symbol represents in some of the expressions above, it's optional chaining operator. It enables you to read the value of a nested property without throwing an error if the property doesn't exist. Instead of throwing an error, the expression evaluates to undefined.

POST /employees

The POST /employees endpoint will allow us to create a new employee. Append the following to the bottom of employee.routes.ts:

mean-stack-example/server/src/employee.routes.ts

employeeRouter.post("/", async (req, res) => {
    try {
        const employee = req.body;
        const result = await collections?.employees?.insertOne(employee);

        if (result?.acknowledged) {
            res.status(201).send(`Created a new employee: ID ${result.insertedId}.`);
        } else {
            res.status(500).send("Failed to create a new employee.");
        }
    } catch (error) {
        console.error(error);
        res.status(400).send(error instanceof Error ? error.message : "Unknown error");
    }
});

We receive the employee object from the client in the request body. We'll use the insertOne() method to insert the employee into the database. If the insertion is successful, we'll send a "201 Created" response with the ID of the employee. Otherwise, we'll send a "500 Internal Server Error" response.

PUT /employees/:id

The PUT /employees/:id endpoint will allow us to update an existing employee. Append the following to the bottom of employee.routes.ts:

mean-stack-example/server/src/employee.routes.ts

employeeRouter.put("/:id", async (req, res) => {
    try {
        const id = req?.params?.id;
        const employee = req.body;
        const query = { _id: new ObjectId(id) };
        const result = await collections?.employees?.updateOne(query, { $set: employee });

        if (result && result.matchedCount) {
            res.status(200).send(`Updated an employee: ID ${id}.`);
        } else if (!result?.matchedCount) {
            res.status(404).send(`Failed to find an employee: ID ${id}`);
        } else {
            res.status(304).send(`Failed to update an employee: ID ${id}`);
        }
    } catch (error) {
        const message = error instanceof Error ? error.message : "Unknown error";
        console.error(message);
        res.status(400).send(message);
    }
});

Here, the ID of the employee is provided as a parameter whereas the employee object is provided in the request body. We use the ObjectId method to convert the string ID to a MongoDB ObjectId object. We then use the updateOne() method to update the employee with the given ID. If the update is successful, we'll send a "200 OK" response. Otherwise, we'll send a "304 Not Modified" response.

DELETE /employees/:id

Finally, the DELETE /employees/:id endpoint will allow us to delete an existing employee. Append the following to the bottom of employee.routes.ts:

mean-stack-example/server/src/employee.routes.ts

employeeRouter.delete("/:id", async (req, res) => {
    try {
        const id = req?.params?.id;
        const query = { _id: new ObjectId(id) };
        const result = await collections?.employees?.deleteOne(query);

        if (result && result.deletedCount) {
            res.status(202).send(`Removed an employee: ID ${id}`);
        } else if (!result) {
            res.status(400).send(`Failed to remove an employee: ID ${id}`);
        } else if (!result.deletedCount) {
            res.status(404).send(`Failed to find an employee: ID ${id}`);
        }
    } catch (error) {
        const message = error instanceof Error ? error.message : "Unknown error";
        console.error(message);
        res.status(400).send(message);
    }
});

Similar to the previous endpoints, we send a query to the database based on the ID passed as a parameter. We use the deleteOne() method to delete the employee. If the deletion is successful, we'll send a "202 Accepted" response. Otherwise, we'll send a "400 Bad Request" response. If the employee is not found (result.deletedCount is 0), we'll send a "404 Not Found" response.

Register the routes

Now, we need to instruct the Express server to use the routes we've defined. First, import the employeesRouter at the beginning of src/server.ts:

mean-stack-example/server/src/server.ts

import { employeeRouter } from "./employee.routes";

Then, add the following right before the app.listen() call:

mean-stack-example/server/src/server.ts

app.use("/employees", employeeRouter);

Finally, restart the server by stopping the shell process with Ctrl-C and running it again.

npx ts-node src/server.ts

You should see the same message as before in the console.

Server running at http://localhost:5200...

Well done! We've built a simple RESTful API for our employees. Now, let's build an Angular web application to interact with it!

Building the client-side Angular web application

The next step is to build a client-side Angular web application that will interact with our RESTful API. We'll use the Angular CLI to scaffold the application. To install it, open a new terminal tab and run the following command:

npm install -g @angular/cli

It's important to keep the server running while you're working on the client-side application. To do this, you'll need to open a new tab in your terminal where you will execute commands for the client-side application. Also, make sure you navigate to the mean-stack-example directory.

After the installation is finished, navigate to the root directory of the project and run the following command to scaffold a new Angular application:

ng new client --inline-template --inline-style --minimal --routing --style=css

This will create a new Angular application in a directory called client. The --inline-template and --inline-style flags specify that we will include the HTML template and CSS styles directly in the component file instead of breaking those out into separate files. The --minimal flag will skip any testing configuration as we won't be covering that in this tutorial. The --routing flag will generate a routing module. The --style=css flag will enable the CSS preprocessor.

Installing the dependencies may take a while. After the installation is finished, navigate to the new application and start it by running the following commands:

cd client
ng serve -o

After the application is built, you should see a new tab in your browser window with the application running. It should say "Welcome to client!"

Finally, for styling, we'll use Angular Material. To install it, run the following command from a new terminal in the client directory. Select a color theme along with the default options when prompted:

ng add @angular/material

Create an employee interface on the client side

Similar to our server-side application, we'll create an Angular interface for our employees. We'll use the Employee interface to define the properties of our employee objects. Open a new terminal window and run the following command to scaffold the interface:

ng generate interface employee

Then, open the newly created src/app/employee.ts file in your editor and add the following properties:

mean-stack-example/client/src/app/employee.ts

export interface Employee {
  name: string;
  position: string;
  level: 'junior' | 'mid' | 'senior';
  _id?: string;
}

Creating an employee service

Angular recommends separating your business logic from your presentation logic. That's why we'll create a service that handles all communication with the /employee endpoint of the API. The service will be used by the components in the application. To generate the service, run the following command:

ng generate service employee

The ng generate service command creates a new service in the src/app/employee.service.ts file. Replace the content of this file with the following:

mean-stack-example/client/src/app/employee.service.ts

import { Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Employee } from './employee';

@Injectable({
  providedIn: 'root'
})
export class EmployeeService {
  private url = 'http://localhost:5200';
  employees$ = signal<Employee[]>([]);
  employee$ = signal<Employee>({} as Employee);
 
  constructor(private httpClient: HttpClient) { }

  private refreshEmployees() {
    this.httpClient.get<Employee[]>(`${this.url}/employees`)
      .subscribe(employees => {
        this.employees$.set(employees);
      });
  }

  getEmployees() {
    this.refreshEmployees();
    return this.employees$();
  }

  getEmployee(id: string) {
    this.httpClient.get<Employee>(`${this.url}/employees/${id}`).subscribe(employee => {
      this.employee$.set(employee);
      return this.employee$();
    });
  }

  createEmployee(employee: Employee) {
    return this.httpClient.post(`${this.url}/employees`, employee, { responseType: 'text' });
  }

  updateEmployee(id: string, employee: Employee) {
    return this.httpClient.put(`${this.url}/employees/${id}`, employee, { responseType: 'text' });
  }

  deleteEmployee(id: string) {
    return this.httpClient.delete(`${this.url}/employees/${id}`, { responseType: 'text' });
  }
}

We're using the HttpClient service to make HTTP requests to our API. The refreshEmployees() method is used to fetch the full list of employees and saves this to a signal called employees$ which will be accessible to the rest of the application.

The HttpClient service is provided by Angular through the provideHttpClient function. It's not part of the application by default — we need to import it in the app.config.ts file. First, add the following import to the top of the app.config.ts file:

mean-stack-example/client/src/app/app.config.ts

import { provideHttpClient, withFetch } from '@angular/common/http';

Then, add the function to the list of providers of the ApplicationConfig variable:

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withFetch()),
    // ...
  ],
};

The withFetch function is used to configure the HttpClient service to use the fetch API.

Next, we'll create a new page that displays a table with our employees.

Create an employees list component

Let's create a new page for our table with employees. In Angular, a component is a reusable piece of code that can be used to display a view. We'll create a new component called EmployeesList and then also register it as the /employees route in the application.

To generate the component, run the following command:

ng generate component employees-list

The Angular CLI generated a new component named EmployeesListComponent in the src/app/employees-list.component.ts file. Replace the contents of src/app/employees-list.component.ts with the following:

mean-stack-example/client/src/app/employees-list/employees-list.component.ts

import { Component, OnInit, WritableSignal } from '@angular/core';
import { Employee } from '../employee';
import { EmployeeService } from '../employee.service';
import { RouterModule } from '@angular/router';
import { MatTableModule } from '@angular/material/table';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';

@Component({
  selector: 'app-employees-list',
  standalone: true,
  imports: [RouterModule, MatTableModule, MatButtonModule, MatCardModule],
  styles: [
    `
      table {
        width: 100%;

        button:first-of-type {
          margin-right: 1rem;
        }
      }
    `,
  ],
  template: `
    <mat-card>
      <mat-card-header>
        <mat-card-title>Employees List</mat-card-title>
      </mat-card-header>
      <mat-card-content>
        <table mat-table [dataSource]="employees$()">
          <ng-container matColumnDef="col-name">
            <th mat-header-cell *matHeaderCellDef>Name</th>
            <td mat-cell *matCellDef="let element">{{ element.name }}</td>
          </ng-container>
          <ng-container matColumnDef="col-position">
            <th mat-header-cell *matHeaderCellDef>Position</th>
            <td mat-cell *matCellDef="let element">{{ element.position }}</td>
          </ng-container>
          <ng-container matColumnDef="col-level">
            <th mat-header-cell *matHeaderCellDef>Level</th>
            <td mat-cell *matCellDef="let element">{{ element.level }}</td>
          </ng-container>
          <ng-container matColumnDef="col-action">
            <th mat-header-cell *matHeaderCellDef>Action</th>
            <td mat-cell *matCellDef="let element">
              <button mat-raised-button [routerLink]="['edit/', element._id]">
                Edit
              </button>
              <button
                mat-raised-button
                color="warn"
                (click)="deleteEmployee(element._id || '')"
              >
                Delete
              </button>
            </td>
          </ng-container>

          <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
          <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
        </table>
      </mat-card-content>
      <mat-card-actions>
        <button mat-raised-button color="primary" [routerLink]="['new']">
          Add a New Employee
        </button>
      </mat-card-actions>
    </mat-card>
  `,
})
export class EmployeesListComponent implements OnInit {
  employees$ = {} as WritableSignal<Employee[]>;
  displayedColumns: string[] = [
    'col-name',
    'col-position',
    'col-level',
    'col-action',
  ];

  constructor(private employeesService: EmployeeService) {}

  ngOnInit() {
    this.fetchEmployees();
  }

  deleteEmployee(id: string): void {
    this.employeesService.deleteEmployee(id).subscribe({
      next: () => this.fetchEmployees(),
    });
  }

  private fetchEmployees(): void {
    this.employees$ = this.employeesService.employees$;
    this.employeesService.getEmployees();
  }
}

As you can notice, EmployeesListComponent is a TypeScript class that is decorated with the @Component decorator. The @Component decorator is used to indicate that this class is a component. The selector property is used to specify the HTML tag that will be used to display this component. Spoiler alert: We won't use this selector at all. Instead, we'll register the component as a route. The template property is used to specify the HTML template that will be used to display this component.

The implementation of the class contains the logic for the component. The ngOnInit() method is called when the component is rendered on the page. It's a good place to fetch the list of employees. For that, we're using the EmployeeService we created earlier. The getEmployees() method returns our signal containing all employees. This will automatically render the list of employees as soon as the data is available. We're using Angular Material to render a table displaying the employees.

There are also a few actions in the template — for editing, deleting, and adding new employees. The [routerLink] attribute is used to navigate to the /employees/edit/:id route, which we'll implement later in the tutorial. The (click) event is used to call the deleteEmployee() method. This method, implemented in the class, uses the EmployeeService to delete an employee.

Now that we've created our component, we need to register it as a route in the app.routes.ts file. Replace the contents of the file with the following:

mean-stack-example/client/src/app/app.routes.ts

import { Routes } from '@angular/router';
import { EmployeesListComponent } from './employees-list/employees-list.component';

export const routes: Routes = [
  { path: '', component: EmployeesListComponent, title: 'Employees List' },
];

Then, go to the src/app/app.component.ts file and replace the contents with the following:

mean-stack-example/client/src/app/app.component.ts

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { EmployeesListComponent } from './employees-list/employees-list.component';
import { MatToolbarModule } from '@angular/material/toolbar';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, EmployeesListComponent, MatToolbarModule],
  styles: [
    `
      main {
        display: flex;
        justify-content: center;
        padding: 2rem 4rem;
      }
    `,
  ],
  template: `
    <mat-toolbar>
      <span>Employees Management System</span>
    </mat-toolbar>
    <main>
      <router-outlet />
    </main>
  `,
})
export class AppComponent {
  title = 'client';
}

Let's refresh the browser and see if everything is working.

Employees list table We have a table but we don't see any employees yet. Let's create a new page for adding employees.

Creating a page for adding employees

We need a form for filling in name, position, and level, to create a new employee. For editing existing employees, we'll need a similar form. Let's create an EmployeeForm component and reuse it for both adding and editing.

ng g c employee-form

The g is a shorthand for generate and c is a shorthand for component.

Now, we can use Angular's ReactiveFormsModule and FormBuilder to create a reactive form:

mean-stack-example/client/src/app/employee-form/employee-form.component.ts

import { Component, effect, EventEmitter, input, Output } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatRadioModule } from '@angular/material/radio';
import { MatButtonModule } from '@angular/material/button';
import { Employee } from '../employee';

@Component({
  selector: 'app-employee-form',
  standalone: true,
  imports: [
    ReactiveFormsModule,
    MatFormFieldModule,
    MatInputModule,
    MatRadioModule,
    MatButtonModule,
  ],
  styles: `
    .employee-form {
      display: flex;
      flex-direction: column;
      align-items: flex-start;
      padding: 2rem;
    }
    .mat-mdc-radio-button ~ .mat-mdc-radio-button {
      margin-left: 16px;
    }
    .mat-mdc-form-field {
      width: 100%;
    }
  `,
  template: `
    <form
      class="employee-form"
      autocomplete="off"
      [formGroup]="employeeForm"
      (submit)="submitForm()"
    >
      <mat-form-field>
        <mat-label>Name</mat-label>
        <input matInput placeholder="Name" formControlName="name" required />
        @if (name.invalid) {
        <mat-error>Name must be at least 3 characters long.</mat-error>
        }
      </mat-form-field>

      <mat-form-field>
        <mat-label>Position</mat-label>
        <input
          matInput
          placeholder="Position"
          formControlName="position"
          required
        />
        @if (position.invalid) {
        <mat-error>Position must be at least 5 characters long.</mat-error>
        }
      </mat-form-field>

      <mat-radio-group formControlName="level" aria-label="Select an option">
        <mat-radio-button name="level" value="junior" required
          >Junior</mat-radio-button
        >
        <mat-radio-button name="level" value="mid"
          >Mid</mat-radio-button
        >
        <mat-radio-button name="level" value="senior"
          >Senior</mat-radio-button
        >
      </mat-radio-group>
      <br />
      <button
        mat-raised-button
        color="primary"
        type="submit"
        [disabled]="employeeForm.invalid"
      >
        Add
      </button>
    </form>
  `,
})
export class EmployeeFormComponent {
  initialState = input<Employee>();

  @Output()
  formValuesChanged = new EventEmitter<Employee>();

  @Output()
  formSubmitted = new EventEmitter<Employee>();

  employeeForm = this.formBuilder.group({
    name: ['', [Validators.required, Validators.minLength(3)]],
    position: ['', [Validators.required, Validators.minLength(5)]],
    level: ['junior', [Validators.required]],
  });

  constructor(private formBuilder: FormBuilder) {
    effect(() => {
      this.employeeForm.setValue({
        name: this.initialState()?.name || '',
        position: this.initialState()?.position || '',
        level: this.initialState()?.level || 'junior',
      });
    });
  }

  get name() {
    return this.employeeForm.get('name')!;
  }
  get position() {
    return this.employeeForm.get('position')!;
  }
  get level() {
    return this.employeeForm.get('level')!;
  }

  submitForm() {
    this.formSubmitted.emit(this.employeeForm.value as Employee);
  }
}

There's a lot of code here but there isn't anything groundbreaking to it. We're just using the FormBuilder to create a reactive form with three fields and we're also adding validation to the form. The template is displaying the error messages in case there's a validation error. We're using an input() to pass in the initial state of the form from the parent component. The type of the input() is InputSignal<Employee> because we might pass async data into the form.

For example, the parent component might fetch the employee data from an API and pass it into the form. The child component will get notified when new data is available. The @Output() is an event emitter that will emit the form values whenever the form is submitted. The parent will handle the submission and send an API call.

The next step is to implement the AddEmployeeComponent:

ng generate component add-employee

Replace the content of the newly created file with the following:

mean-stack-example/client/src/app/add-employee/add-employee.component.ts

import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { EmployeeFormComponent } from '../employee-form/employee-form.component';
import { Employee } from '../employee';
import { EmployeeService } from '../employee.service';
import { MatCardModule } from '@angular/material/card';

@Component({
  selector: 'app-add-employee',
  standalone: true,
  imports: [EmployeeFormComponent, MatCardModule],
  template: `
    <mat-card>
      <mat-card-header>
        <mat-card-title>Add a New Employee</mat-card-title>
      </mat-card-header>
      <mat-card-content>
        <app-employee-form
          (formSubmitted)="addEmployee($event)"
        ></app-employee-form>
      </mat-card-content>
    </mat-card>
  `,
  styles: ``,
})
export class AddEmployeeComponent {
  constructor(
    private router: Router,
    private employeeService: EmployeeService
  ) {}

  addEmployee(employee: Employee) {
    this.employeeService.createEmployee(employee).subscribe({
      next: () => {
        this.router.navigate(['/']);
      },
      error: (error) => {
        alert('Failed to create employee');
        console.error(error);
      },
    });
    this.employeeService.getEmployees();
  }
}

We're using the EmployeeFormComponent, and whenever the AddEmployeeComponent receives a form submission, it will call the EmployeeService to create the employee. The EmployeeService will emit an event when the employee is created and the AddEmployeeComponent will navigate back to the table of employees.

While we're at it, let's implement the component for editing an employee:

ng generate component edit-employee

Replace the content of the newly created file with the following:

mean-stack-example/client/src/app/edit-employee/edit-employee.component.ts

import { Component, OnInit, WritableSignal } from '@angular/core';
import { EmployeeFormComponent } from '../employee-form/employee-form.component';
import { ActivatedRoute, Router } from '@angular/router';
import { Employee } from '../employee';
import { EmployeeService } from '../employee.service';
import { MatCardModule } from '@angular/material/card';

@Component({
  selector: 'app-edit-employee',
  standalone: true,
  imports: [EmployeeFormComponent, MatCardModule],
  template: `
    <mat-card>
      <mat-card-header>
        <mat-card-title>Edit an Employee</mat-card-title>
      </mat-card-header>
      <mat-card-content>
        <app-employee-form
          [initialState]="employee()"
          (formSubmitted)="editEmployee($event)"
        ></app-employee-form>
      </mat-card-content>
    </mat-card>
  `,
  styles: ``,
})
export class EditEmployeeComponent implements OnInit {
  employee = {} as WritableSignal<Employee>;

  constructor(
    private router: Router,
    private route: ActivatedRoute,
    private employeeService: EmployeeService
  ) {}

  ngOnInit() {
    const id = this.route.snapshot.paramMap.get('id');
    if (!id) {
      alert('No id provided');
    }

    this.employeeService.getEmployee(id!);
    this.employee = this.employeeService.employee$;
  }

  editEmployee(employee: Employee) {
    this.employeeService
      .updateEmployee(this.employee()._id || '', employee)
      .subscribe({
        next: () => {
          this.router.navigate(['/']);
        },
        error: (error) => {
          alert('Failed to update employee');
          console.error(error);
        },
      });
  }
}

The only notable difference from the AddEmployeeComponent is that we're getting the employee ID from the URL, fetching the employee from the API, and then passing it to the form in the ngOnInit() method.

Finally, let's add navigation to our new pages:

mean-stack-example/client/src/app/app-routing.module.ts

import { Routes } from '@angular/router';
import { EmployeesListComponent } from './employees-list/employees-list.component';
import { AddEmployeeComponent } from './add-employee/add-employee.component'; // <-- add this line
import { EditEmployeeComponent } from './edit-employee/edit-employee.component'; // <-- add this line

export const routes: Routes = [
  { path: '', component: EmployeesListComponent, title: 'Employees List' },
  { path: 'new', component: AddEmployeeComponent }, // <-- add this line
  { path: 'edit/:id', component: EditEmployeeComponent }, // <-- add this line
];

Alright! Let's test it out! Go ahead and try to create a new employee. After filling in the details, click the Submit button. You should see the new employee on the list. Then, you can try editing it by clicking the Edit button. You should see the form filled with the employee's details. When you submit the form, the employee will be updated in the table. Finally, you can delete it by clicking the Delete button.


gif of angular app demonstration If something doesn't add up, you can check out the finished project in the mean-stack-example repository.

Conclusion

Thank you for joining me on this journey and following along! I hope you enjoyed it and learned a lot. The MEAN stack makes it easy to get started and build scalable applications. You're using the same language throughout the stack: JavaScript. Additionally, MongoDB's document model makes it natural to map data to objects, and MongoDB Atlas's forever-free cluster makes it easy to host your data without worrying about costs.