Production-Ready Stack — Part 5: Push Notifications with Firebase Cloud Messaging
In Part 04, we built the product endpoints and the shopping cart, including a POST /api/orders/checkout route that creates an order from…
Production-Ready Stack — Part 5: Push Notifications with Firebase Cloud Messaging

In Part 04, we built the product endpoints and the shopping cart, including a POST /api/orders/checkout route that creates an order from the user’s cart and clears it. The order is persisted in the database, but the user has no idea it was processed until they next open the app and check manually.
In this part, we fix that. We will add push notifications that fire the moment a checkout completes. To do that, we need four things:
- A way to store device tokens: each logged-in device registers its FCM token with the server.
- A notification record: every push we send is also saved to the database. So the client can display an inbox.
- A push service: a thin wrapper around Firebase Admin SDK that sends a multicast message and cleans up stale tokens automatically.
- Four new endpoints: register token, unregister token, list notifications, and mark a notification read.
By the end of this part, the API will have four additional working endpoints:
| Endpoint | Method | Auth required |
|-----------------------------|--------|---------------|
| /api/device-tokens | POST | Yes |
| /api/device-tokens | DELETE | Yes |
| /api/notifications | GET | Yes |
| /api/notifications/:id/read | PATCH | Yes |
The existing POST /api/orders/checkout endpoint is also updated to fire a push and create a notification record on every successful order.
Step 1: Create a Firebase project
Go to the Firebase console and create a new project (or reuse an existing one).
Once the project is ready:
- Open
Project settings → Service accounts. - Click
Generate new private keyand download the JSON file. - Copy the file into the
api-server/root directory. Name it something recognisable. For examplefirebase-service-account.json. - Add it to
.gitignoreimmediately. This file contains private credentials and must never be committed.
Step 2: Install firebase-admin
npm install firebase-admin
The Firebase Admin SDK is the server-side library. It authenticates with your service account and exposes FCM’s messaging API.
Step 3: The DeviceTokens migration
Mobile apps generate a unique FCM registration token per device and per app installation. We store these in aDeviceTokens table so the server always knows where to send pushes for a given user.
Create src/migrations/20260503000000-device-tokens-table.js:
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('DeviceTokens', {
id: {
allowNull: false,
primaryKey: true,
type: Sequelize.UUID,
defaultValue: Sequelize.literal('uuid_generate_v4()'),
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {model: 'Users', key: 'id'},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
token: {
type: Sequelize.STRING(512),
allowNull: false,
unique: true,
},
platform: {
type: Sequelize.ENUM('ios', 'android'),
allowNull: false,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
await queryInterface.addIndex('DeviceTokens', ['userId']);
},
async down(queryInterface) {
await queryInterface.dropTable('DeviceTokens');
await queryInterface.sequelize.query('DROP TYPE IF EXISTS "enum_DeviceTokens_platform";');
},
};
Step 4: The Notifications migration
Every push we fire is also written to the Notifications table. This gives the mobile app an inbox it can fetch at any time, even if the device was offline when the push was sent.
Create src/migrations/20260407000002-notifications-table.js:
'use strict';
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('Notifications', {
id: {
allowNull: false,
primaryKey: true,
type: Sequelize.UUID,
defaultValue: Sequelize.literal('uuid_generate_v4()'),
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {model: 'Users', key: 'id'},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
title: {
type: Sequelize.STRING(255),
allowNull: false,
},
body: {
type: Sequelize.STRING(500),
allowNull: false,
},
type: {
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: 'order',
},
orderId: {
type: Sequelize.UUID,
allowNull: true,
references: {model: 'Orders', key: 'id'},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
isRead: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
},
createdAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
},
async down(queryInterface) {
await queryInterface.dropTable('Notifications');
},
};
orderId is nullable and uses ON DELETE SET NULL. If an order is deleted, the notification is kept, but its link to the order is cleared. This is intentional. The user’s inbox should remain intact even if the underlying order record is removed.
Run both migrations:
npm run db:migrate
Step 5: The DeviceToken model
Create src/models/deviceToken.js:
'use strict';
const {Model, DataTypes} = require('sequelize');
module.exports = (sequelize) => {
class DeviceToken extends Model {
static associate(models) {
DeviceToken.belongsTo(models.User, {foreignKey: 'userId', as: 'user'});
}
}
DeviceToken.init(
{
userId: {
type: DataTypes.UUID,
allowNull: false,
},
token: {
type: DataTypes.STRING(512),
allowNull: false,
unique: true,
},
platform: {
type: DataTypes.ENUM('ios', 'android'),
allowNull: false,
},
},
{
sequelize,
modelName: 'DeviceToken',
tableName: 'DeviceTokens',
timestamps: true,
}
);
return DeviceToken;
};
Add the hasMany side of the association to src/models/user.js inside static associate:
module.exports = (sequelize) => {
class User extends Model {
static associate(models) {
User.hasMany(models.DeviceToken, {foreignKey: 'userId', as: 'deviceTokens'});
//...
}
//...
}
}
Step 6: The Notification model
Create src/models/notification.js:
'use strict';
const {Model, DataTypes} = require('sequelize');
module.exports = (sequelize) => {
class Notification extends Model {
static associate(models) {
Notification.belongsTo(models.User, {foreignKey: 'userId', as: 'user'});
Notification.belongsTo(models.Order, {foreignKey: 'orderId', as: 'order'});
}
}
Notification.init(
{
userId: {
type: DataTypes.UUID,
allowNull: false,
},
title: {
type: DataTypes.STRING(255),
allowNull: false,
},
body: {
type: DataTypes.STRING(500),
allowNull: false,
},
type: {
type: DataTypes.STRING(50),
allowNull: false,
defaultValue: 'order',
},
orderId: {
type: DataTypes.UUID,
allowNull: true,
},
isRead: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
},
{
sequelize,
modelName: 'Notification',
tableName: 'Notifications',
timestamps: true,
}
);
return Notification;
};
Add the corresponding association to src/models/user.js:
module.exports = (sequelize) => {
class User extends Model {
static associate(models) {
User.hasMany(models.DeviceToken, {foreignKey: 'userId', as: 'deviceTokens'});
User.hasMany(models.Notification, {foreignKey: 'userId', as: 'notifications'});
//...
}
//...
}
}
Step 7: The push notification service
Create src/services/pushNotification.js:
'use strict';
const admin = require('firebase-admin');
const {DeviceToken} = require('../models');
let fcmClient = null;
const initFirebase = () => {
if (fcmClient) return fcmClient;
try {
const serviceAccount = require('../../firebase-service-account.json');
if (!admin.apps.length) {
admin.initializeApp({credential: admin.credential.cert(serviceAccount)});
}
fcmClient = admin.messaging();
console.log('[push] Firebase Admin initialised');
return fcmClient;
} catch (err) {
console.error('[push] Failed to initialise Firebase Admin:', err.message);
return null;
}
};
const FCM_STALE_ERRORS = new Set([
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
'messaging/invalid-argument',
]);
const sendViaFCM = async (tokens, title, body, data) => {
const messaging = initFirebase();
if (!messaging || !tokens.length) return;
const stringData = Object.fromEntries(
Object.entries(data).map(([k, v]) => [k, String(v)])
);
const message = {
tokens,
notification: {title, body},
data: stringData,
android: {
priority: 'high',
notification: {sound: 'default', clickAction: 'OPEN_DEEP_LINK'},
},
apns: {
payload: {aps: {sound: 'default'}},
},
};
try {
const response = await messaging.sendEachForMulticast(message);
console.log(`[push/fcm] sent=${response.successCount} failed=${response.failureCount}`);
const stale = [];
response.responses.forEach((r, i) => {
if (r.error && FCM_STALE_ERRORS.has(r.error.code)) stale.push(tokens[i]);
});
if (stale.length) {
await DeviceToken.destroy({where: {token: stale}});
console.log(`[push/fcm] Removed ${stale.length} stale token(s)`);
}
} catch (err) {
console.error('[push/fcm] sendEachForMulticast error:', err.message);
}
};
const buildDeepLink = ({orderId} = {}) =>
orderId ? `app://notifications/${orderId}` : 'app://notifications';
const sendPushToUser = async (userId, title, body, data = {}) => {
const deviceTokens = await DeviceToken.findAll({where: {userId}});
if (!deviceTokens.length) return;
const payload = {...data, deepLink: buildDeepLink(data)};
const allTokens = deviceTokens.map((d) => d.token);
await sendViaFCM(allTokens, title, body, payload);
};
module.exports = {sendPushToUser};
Step 8: The device token controller
Create src/controllers/deviceToken.js:
'use strict';
const {DeviceToken} = require('../models');
const {sendSuccess, sendServerError, sendValidationErrors} = require('../helpers/response');
const {validate} = require('../helpers/validate');
/**
* POST /api/device-tokens
*/
const registerToken = async (req, res) => {
try {
const errors = validate(req.body, {
token: {presence: {allowEmpty: false}},
platform: {
presence: {allowEmpty: false},
inclusion: {within: ['ios', 'android'], message: 'must be "ios" or "android"'},
},
});
if (errors) return sendValidationErrors({res, errors});
const {token, platform} = req.body;
const existing = await DeviceToken.findOne({where: {token}});
if (existing) {
// Reassign to current user if the same device token re-registers under a different account.
await existing.update({userId: req.user.id, platform});
return sendSuccess({res, message: 'Device token registered', data: existing});
}
const record = await DeviceToken.create({userId: req.user.id, token, platform});
return sendSuccess({res, message: 'Device token registered', data: record});
} catch (e) {
return sendServerError({res, message: e.message});
}
};
/**
* DELETE /api/device-tokens
*/
const unregisterToken = async (req, res) => {
try {
const errors = validate(req.body, {
token: {presence: {allowEmpty: false}},
});
if (errors) return sendValidationErrors({res, errors});
const {token} = req.body;
const deleted = await DeviceToken.destroy({where: {token, userId: req.user.id}});
return sendSuccess({res, message: deleted ? 'Device token removed' : 'Token not found', data: null});
} catch (e) {
return sendServerError({res, message: e.message});
}
};
module.exports = {registerToken, unregisterToken};
Step 9: The notification controller
Create src/controllers/notification.js:
'use strict';
const {Notification} = require('../models');
const {sendSuccess, sendServerError, sendNotFoundError, sendValidationErrors} = require('../helpers/response');
const {validate} = require('../helpers/validate');
/**
* GET /api/notifications
* Returns all notifications for the authenticated user, newest first.
*/
const getNotifications = async (req, res) => {
try {
const notifications = await Notification.findAll({
where: {userId: req.user.id},
order: [['createdAt', 'DESC']],
});
return sendSuccess({res, message: 'Notifications fetched successfully', data: notifications});
} catch (e) {
return sendServerError({res, message: e.message});
}
};
/**
* PATCH /api/notifications/:id/read
* Marks a single notification as read.
*/
const markRead = async (req, res) => {
try {
const errors = validate({id: req.params.id}, {
id: {presence: {allowEmpty: false}, uuidv4: true},
});
if (errors) return sendValidationErrors({res, errors});
const notification = await Notification.findOne({
where: {id: req.params.id, userId: req.user.id},
});
if (!notification) return sendNotFoundError({res, message: 'Notification not found'});
notification.isRead = true;
await notification.save();
return sendSuccess({res, message: 'Notification marked as read', data: notification});
} catch (e) {
return sendServerError({res, message: e.message});
}
};
module.exports = {getNotifications, markRead};
Step 10: Routes
Create src/routes/deviceTokenRoutes.js:
const express = require('express');
const router = express.Router();
const authMiddleware = require('../middleware/auth');
const {registerToken, unregisterToken} = require('../controllers/deviceToken');
router.post('/', authMiddleware, registerToken);
router.delete('/', authMiddleware, unregisterToken);
module.exports = router;
Create src/routes/notificationRoutes.js:
const express = require('express');
const router = express.Router();
const authMiddleware = require('../middleware/auth');
const {getNotifications, markRead} = require('../controllers/notification');
router.get('/', authMiddleware, getNotifications);
router.patch('/:id/read', authMiddleware, markRead);
module.exports = router;
Mount both in src/index.js:
const notificationRoutes = require('./routes/notificationRoutes');
const deviceTokenRoutes = require('./routes/deviceTokenRoutes');
app.use('/api/notifications', notificationRoutes);
app.use('/api/device-tokens', deviceTokenRoutes);
Step 11: Wire checkout to send a push
Open src/controllers/order.js add the two new imports at the top:
const {Cart, Product, Order, OrderItem, Notification} = require('../models');
const {sendPushToUser} = require('../services/pushNotification');
Then, after the cart is cleared in checkout, add:
await Cart.destroy({where: {userId: req.user.id}});
const notificationTitle = 'Order placed';
const notificationBody = `Your order ${orderNumber} has been placed successfully.`;
await Notification.create({
userId: req.user.id,
title: notificationTitle,
body: notificationBody,
type: 'order',
orderId: order.id,
});
sendPushToUser(req.user.id, notificationTitle, notificationBody, {orderId: order.id}).catch(() => {
});
The push is intentionally fire-and-forget ( .catch(() => {}) silences the rejection). A checkout that succeeds but fails to send a push is still a completed checkout. Rolling it back because FCM is temporarily down would be the wrong tradeoff. The Notification record is written before the push fires, if FCM is down, the client can still poll GET /api/notifications and see the inbox entry.
The full checkout function now looks like this:
const checkout = async (req, res) => {
try {
const cartItems = await Cart.findAll({
where: {userId: req.user.id},
include: [{model: Product, as: 'product', attributes: ['id', 'name', 'image', 'price']}],
});
if (!cartItems.length) {
return sendNotFoundError({res, message: 'Cart is empty'});
}
const total = cartItems.reduce((sum, item) => {
return sum + parseFloat(item.product.price) * item.quantity;
}, 0);
const orderNumber = await generateOrderNumber();
const order = await Order.create({
userId: req.user.id,
orderNumber,
status: 'processing',
total: parseFloat(total.toFixed(2)),
});
await OrderItem.bulkCreate(
cartItems.map((item) => ({
orderId: order.id,
productId: item.productId,
productName: item.product.name,
productImage: item.product.image,
productPrice: parseFloat(item.product.price),
quantity: item.quantity,
}))
);
await Cart.destroy({where: {userId: req.user.id}});
const notificationTitle = 'Order placed';
const notificationBody = `Your order ${orderNumber} has been placed successfully.`;
await Notification.create({
userId: req.user.id,
title: notificationTitle,
body: notificationBody,
type: 'order',
orderId: order.id,
});
sendPushToUser(req.user.id, notificationTitle, notificationBody, {orderId: order.id}).catch(() => {
});
const orderWithItems = await Order.findByPk(order.id, {
include: [{model: OrderItem, as: 'items'}],
});
return sendSuccess({res, message: 'Order placed successfully', data: orderWithItems});
} catch (e) {
return sendServerError({res, message: e.message});
}
};
Here is the full endpoint list across all five parts:
| Endpoint | Method | Auth |
|-----------------------------|--------|------|
| /api/auth/sign-up | POST | No |
| /api/auth/login | POST | No |
| /api/auth/refresh | POST | No |
| /api/auth/logout | POST | No |
| /api/user/profile | GET | Yes |
| /api/products | GET | No |
| /api/products/:id | GET | No |
| /api/product-categories | GET | No |
| /api/cart | GET | Yes |
| /api/cart | POST | Yes |
| /api/cart/:productId | PATCH | Yes |
| /api/cart/:productId | DELETE | Yes |
| /api/orders/checkout | POST | Yes |
| /api/orders | GET | Yes |
| /api/orders/:orderId | GET | Yes |
| /api/device-tokens | POST | Yes |
| /api/device-tokens | DELETE | Yes |
| /api/notifications | GET | Yes |
| /api/notifications/:id/read | PATCH | Yes |
In the next season, we will build the iOS client that consumes this entire API, registering for push notifications on launch, displaying the notification inbox, and routing tapped notifications directly to the correct order detail screen (deep links).
메타데이터
- post_id
- 9e9bae2ee262
- slug
- production-ready-stack-part-5-push-notifications-with-firebase-cloud-messaging-9e9bae2ee262
- url
- https://medium.com/@russelrajitha/production-ready-stack-part-5-push-notifications-with-firebase-cloud-messaging-9e9bae2ee262
- canonical_url
- https://medium.com/@russelrajitha/production-ready-stack-part-5-push-notifications-with-firebase-cloud-messaging-9e9bae2ee262
- author_url
- https://medium.com/@russelrajitha
- status
- ok
- fetched_at
- 2026-06-09 15:37:30