SETUP A NODE/EXPRESS API LIKE A PRO
INSTALL REQUIRED PACKAGES
mkdir server
cd server
pnpm init
pnpm add express @types/express @types/node eslint prettier helmet cors @types/cors bcrypt @types/bcrypt jsonwebtoken @types/jsonwebtoken dotenv zod sequelize sequelize-cli mysql2 http-status-codes googleapis express-session @types/express-session
cookie-parser @types/cookie-parser imapflow @types/imapflow
#logging using pino
pnpm add pino pino-pretty pino-http
#rate limit
pnpm add express-rate-limit
#mail
pnpm add mailparser @types/mailparser
# auto load
pnpm add -D tsx typescript
package.json
{
"type": "module",
"scripts": {
"dev": "tsx watch index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
src/app.ts
import express from "express";
const app = express();
export default app;
src/index.ts
import app from './app';
app.listen(Environment.PORT, () => {
logger.info("Server started on port:" + `http://localhost:${Environment.PORT}`);
});
SETUP COMMON API RESPONSE
server/src/utils/api.response.ts
import { Response } from 'express';
interface ResponseParams<T> {
res: Response;
data: T;
message?: string;
status?: number;
}
export const apiResponse = <T>({
res,
data,
message = 'Success',
status = 200,
}: ResponseParams<T>) => {
return res.status(status).json({
success: true,
message,
data,
});
};
use it
apiResponse({
res,
data: user,
message: 'User created successfully',
status: 201,
});
SETUP GLOBAL ERROR HANDLER MIDDLEWARE
server/src/errors/AppError.ts
export class AppError extends Error {
public statusCode: number;
public isOperational: boolean;
constructor(message: string, statusCode: number, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
server/src/errors/ValidationError.ts
import { AppError } from './AppError';
export class ValidationError extends AppError {
public details: unknown;
constructor(message: string, details: unknown) {
super(message, 400);
this.details = details;
}
}
server/src/middlewares/error.middleware.ts
export default function globalErrorHandler(
err: Error | AppError,
req: Request,
res: Response,
_next: NextFunction
) {
logger.error(
{
err,
path: req.originalUrl,
method: req.method,
},
err.message
);
if (err instanceof AppError) {
return apiResponse({
res,
data: null,
message: err.message,
status: err.statusCode,
});
}
return apiResponse({
res,
data: null,
message: err.message,
status: 500,
});
}
use it in the serve/app/index after all the routes
import { globalErrorHandler } from "./middlewares/globalErrorHandler";
...
// Runs only if no route matched
app.use((req, res, next) => {
next(new AppError(`Route ${req.originalUrl} not found`, 404));
});
// Global error handler
app.use(globalErrorHandler);
usage example
throw new ValidationError('Invalid user data', []);
SETUP LOGGER MIDDLEWARE
server/src/utils/logger.ts
import pino from 'pino';
import path from 'path';
import fs from 'fs';
import Environment from '@/config/env.config';
const isProduction = Environment.NODE_ENV === 'production';
const logsDir = path.join(process.cwd(), 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
export const logger = pino({
level: isProduction ? 'info' : 'debug',
transport: isProduction
? undefined
: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'yyyy-mm-dd HH:MM:ss',
ignore: 'pid,hostname',
},
},
});
export const httpLogger = pino({
level: 'info',
transport: {
target: 'pino/file',
options: {
destination: path.join(logsDir, 'http.log'),
mkdir: true,
},
},
});
server/src/middlewares/logger.middleware.ts
import pinoHttp from 'pino-http';
import { logger } from '../utils/logger';
export const requestLogger = pinoHttp({
logger,
customLogLevel: (req, res, err) => {
if (res.statusCode >= 500 || err) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
});
use it in the serve/app/index before routes
import { requestLogger } from "./middlewares/requestLogger";
...
app.use(cookieParser());
// Logger middleware
app.use(requestLogger);
SETUP ENVIRONMENT SCHEMA AND TYPE USING ZOD
server/src/config/env.config.ts
import dotenv from 'dotenv';
import { z } from 'zod';
import { logger } from '../utils/logger';
dotenv.config();
const envSchema = z.object({
PORT: z.string().min(1, "PORT is required"),
FRONTEND_URL: z.string().url("FRONTEND_URL must be a valid URL"),
DB_HOST: z.string().min(1, "DB_HOST is required"),
DB_USER: z.string().min(1, "DB_USER is required"),
DB_PASSWORD: z.string().optional(),
DB_NAME: z.string().min(1, "DB_NAME is required"),
DB_PORT: z.string().min(1, "DB_PORT is required"),
});
const result = envSchema.safeParse(process.env);
if (!result.success) {
result.error.issues.forEach(issue =>
console.error(` - ${issue.path.join('.')}: ${issue.message}`)
);
console.error("Environment configuration issue");
process.exit(1);
}
const Environment = result.data;
console.table(Environment)
//Zod's type inference to automatically derive a TypeScript type from our schema
export type EnvironmentType = z.infer<typeof envSchema>;
export default Environment;
SETUP SEQUELIZE MIGRATIONS
pnpm add sequelize sequelize-cli mysql2
create .sequelizerc in the root path
const path = require('path');
module.exports = {
config: path.resolve('src', 'config', 'config.json'),
'models-path': path.resolve('src', 'models'),
'seeders-path': path.resolve('src', 'seeders'),
'migrations-path': path.resolve('src', 'migrations'),
};
create config,models,seeders,migrations directories in the src
add config/config.json
{
"development": {
"username": "root",
"password": null,
"database": "database_development",
"host": "127.0.0.1",
"dialect": "mysql"
},
}
commands
# Creating the first Model (and Migration)
npx sequelize-cli model:generate --name User --attributes firstName:string,lastName:string,email:string
# you need to keep the migraion as js file, dont add "type":"module" in the package.json ts can compile
example migration
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('Users', {
id: { primaryKey: true, autoIncrement: false, type: Sequelize.STRING },
name: Sequelize.STRING,
email: Sequelize.STRING,
createdAt: Sequelize.DATE,
updatedAt: Sequelize.DATE,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('Users');
}
};
example model (you can keep/rename model to .ts)
import {
Model,
InferAttributes,
InferCreationAttributes,
CreationOptional,
DataTypes,
Sequelize,
NonAttribute,
Association,
} from 'sequelize';
import type { Post } from './post';
export class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
declare id: CreationOptional<string>;
declare name: string;
declare email: string;
declare readonly createdAt: CreationOptional<Date>;
declare readonly updatedAt: CreationOptional<Date>;
// Associations
declare posts?: NonAttribute<Post[]>;
declare static associations: {
posts: Association<User, Post>;
};
static associate(models: { Post: typeof Post }) {
User.hasMany(models.Post, { foreignKey: 'userId', as: 'posts' });
}
}
export const initUserModel = (sequelize: Sequelize): typeof User => {
User.init(
{
id: { type: DataTypes.STRING, autoIncrement: false, primaryKey: true },
name: { type: DataTypes.STRING },
email: { type: DataTypes.STRING, allowNull: false, unique: true },
createdAt: DataTypes.DATE,
updatedAt: DataTypes.DATE,
},
},
{
sequelize,
modelName: 'User',
tableName: 'Users',
indexes: [
{
name: 'idx_user_email',
fields: ['email'],
}
],
}
);
return User;
};
db.config.ts
const sequelize = new Sequelize(Environment.DB_NAME, Environment.DB_USER, Environment.DB_PASSWORD, {
host: Environment.DB_HOST,
port: Number(Environment.DB_PORT),
dialect: 'mysql',
logging: false,
});
export const connetDB = async () => {
try {
await sequelize.authenticate();
logger.info('Database connected');
await sequelize.sync({ force: false });
logger.info('Database synchronized');
} catch (error) {
logger.error(`Unable to connect to the database:' ${error}`);
}
};
// Initialize models
export const User = initUserModel(sequelize);
export const Post = initPostModel(sequelize);
// Set up associations
Post.associate({ User});
User.associate({ Post });
export default sequelize;
update server.ts
await connetDB();
app.listen(Environment.PORT, () => {
logger.info("Server started on port:" + `http://localhost:${Environment.PORT}`);
});
update package.json scripts
"scripts": {
"migrate": "sequelize-cli db:migrate",
"migrate:undo": "sequelize-cli db:migrate:undo",
"migrate:undo:all": "sequelize-cli db:migrate:undo:all",
"seed": "sequelize-cli db:seed:all",
"seed:undo": "sequelize-cli db:seed:undo",
"seed:undo:all": "sequelize-cli db:seed:undo:all"
},
SETUP ZOD REQUEST VALIDATOR MIDDLEWARE
server/src/dtos/user.dto.ts
import { z } from 'zod';
export const createUserSchema = z.object({
id: z.string().min(1, { message: "ID is required" }),
name: z.string().min(1, { message: "Name is required" }),
email: z.string().email({ message: "Invalid email address" }),
picture: z.string().url().optional(),
googleId: z.string().optional(),
refreshToken: z.string().nullable().optional()
});
export type CreateUserDTO = z.infer<typeof createUserSchema>;
server/src/middlewares/validate.middliware.ts
import { ZodSchema } from 'zod';
import { Request, Response, NextFunction } from 'express';
import { BadRequestError } from '@/errors/BadRequestError';
export const validateBody = (schema: ZodSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
throw new BadRequestError(`${result.error.issues.map((e) => e.message).join(', ')}`);
}
next();
};
};
export const validateParams = (schema: ZodSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.params);
if (!result.success) {
throw new BadRequestError(`${result.error.issues.map((e) => e.message).join(', ')}`);
}
next();
};
};
export const validateQuery = (schema: ZodSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.query);
if (!result.success) {
throw new BadRequestError(`${result.error.issues.map((e) => e.message).join(', ')}`);
}
next();
};
};
use it
router.post('/test-route', validate(createUserSchema), testController.testMethod);
SETUP AUTH MIDDLEWARE
server/src/middlewares/auth.middleware.ts
export interface AuthenticatedRequest extends Request {
user: User;
}
export default function jwtAuth(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void {
const authHeader = req.header('Authorization');
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined;
if (!token) {
throw new UnauthorizedError();
}
try {
const { userId, email } = jwt.verify(token, Environment.JWT_SECRET) as JwtPayload;
req.user = { userId, email };
next();
} catch {
throw new UnauthorizedError();
}
}
SETUP eslint and PRETTIER
server/eslint.config.js
export default tseslint.config(
{
ignores: ['dist', 'node_modules', 'src/migrations'],
},
js.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
eslintPluginPrettier,
{
files: ['**/*.ts'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.node,
},
rules: {
'prettier/prettier': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
},
}
);
server/prettier.config.mjs
/** @type {import("prettier").Config} */
export default {
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'es5',
printWidth: 100,
bracketSpacing: true,
jsxSingleQuote: false,
arrowParens: 'always',
endOfLine: 'lf',
};
server/.prettierignore
dist
node_modules
pnpm-lock.yaml
*.min.js
SETUP @ IMPORTS
pnpm add -D tsc-alias
pnpm add -D tsc-alias
server/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
package.json
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --check .",
"format:fix": "prettier --write ."
},
FINAL app.ts and server.ts SETUP
src/app.ts
//import this top of all other imports, that guarantee the config is loaded first before anything else
//so we can validate the scema and decide if we can load the appor not
import Environment from './config/env.config';
//other imports
const app = express();
app.use(express.json()); // to parse application/json, otherwise req.body will be undefined
// Required for cross-origin browser requests
app.use(
cors({
origin: Environment.FRONTEND_URL,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'Access-Control-Allow-Credentials',
'X-CSRF-Token',
],
})
);
app.use(
session({
secret: Environment.SESSION_SECRET,
resave: false,
saveUninitialized: true,
cookie: {
secure: Environment.NODE_ENV === 'production',
httpOnly: true,
sameSite: 'lax',
},
})
);
app.use(cookieParser());
// Logger middleware
app.use(requestLogger);
app.use('/api/v1', rateLimiter, routerV1);
// Runs only if no route matched
app.use((req) => {
throw new NotFoundError(`Route not found - ${req.originalUrl}`);
});
// Global error handler
app.use(globalErrorHandler);
export default app;
server/src/index.ts
async function bootstrap() {
try {
await connetDB();
app.listen(Environment.PORT, () => {
logger.info('Server started on port:' + `http://localhost:${Environment.PORT}`);
});
} catch (error) {
logger.fatal({ error }, 'Failed to start server:');
process.exit(1);
}
}
bootstrap();
package.json
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "tsx watch src/index.ts",
"build": "tsc && tsc-alias",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"migrate": "sequelize-cli db:migrate",
"migrate:undo": "sequelize-cli db:migrate:undo",
"migrate:undo:all": "sequelize-cli db:migrate:undo:all",
"lint": "eslint .",
"lint:fix": "eslint . --fix .",
"format": "prettier --check .",
"format:fix": "prettier --write ."
}
SETUP A REACT CLIENT LIKE A PRO
INSTALL REQUIRED PACKAGES
mkdir client
cd client
pnpm create vite react-fe --template react-ts
pnpm add axios react-router-dom @tanstack/react-query lucide-react react-error-boundary
sonar nuqs
pnpm run dev
INSTALL TAILWIND
# Reference : https://tailwindcss.com/docs/installation/using-vite
pnpm add -D tailwindcss postcss autoprefixer
pnpm add -D @types/node @types/react @types/react-dom
cd client
then import "./App.css" in App.tsx
SETUP eslint AND PRETTIER
pnpm add eslint prettier
pnpm add -D @eslint/js globals typescript-eslint eslint-config-prettier eslint-plugin-prettier
eslint.config.js
export default tseslint.config(
{
ignores: ['dist', 'node_modules'],
},
js.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
eslintPluginPrettier,
{
files: ['**/*.ts'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.node,
},
rules: {
'prettier/prettier': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
);
prettier.config.mjs
/** @type {import("prettier").Config} */
export default {
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'es5',
printWidth: 100,
bracketSpacing: true,
jsxSingleQuote: false,
arrowParens: 'always',
endOfLine: 'lf',
};
.prettierignore
dist
node_modules
pnpm-lock.yaml
*.min.js
package.json
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"preview": "vite preview"
},
SETUP @ IMPORTS
vite.config.ts
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
tsconfig.json
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
tsconfig.app.json
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
FINAL app.tsx
function App() {
return (
// NuqsProvider enables type-safe URL query string state management across the app
<NuqsProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Toaster position="top-right" richColors />
<RouterProvider router={router} />
</AuthProvider>
</QueryClientProvider>
</NuqsProvider>
);
}
SETUP REACT-QUERY
src/lib/react-query.ts
import { QueryClient, QueryCache, MutationCache } from '@tanstack/react-query';
import { toast } from 'sonner';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
toast.error('Error', { description: error.message });
},
}),
mutationCache: new MutationCache({
onError: (error) => {
toast.error('Error', { description: error.message || 'Failed to update data' });
},
}),
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000,
},
},
});
export default queryClient;
//use it
const { data, isLoading, refetch } = useEmails({...});
//custome hook
//src/features/emails/hooks/use-emails-hook.ts
export const useEmails = (params: GetEmailsParams) => {
return useQuery({
queryKey: [QUERY_KEYS.EMAILS, params],
queryFn: () => getEmails(params),
refetchInterval: 30 * 1000, // fetch every 30 seconds, only for dev testing
refetchOnWindowFocus: true,
});
};
//src/features/emails/api/index.ts
export const getEmails = async (params: GetEmailsParams): Promise<EmailsResponse> => {
const { data } = await api.get<ApiResponse<EmailsResponse>>(API_URLS.EMAILS, {
params,
});
return data.data;
};
SETUP ROUTES WITH LAYOUT
src/lib/routes.tsx
export const router = createBrowserRouter([
{
element: <AuthLayout />,
children: [
{
path: '/emails',
element: <EmailsPage />,
},
...
],
},
{
path: '/',
element: <Login />,
},
...
]);
//src/components/layouts/auth-layout.tsx
export default function AuthLayout() {
const { isLoading, error, refetch } = useMe();
if (isLoading) return <LoadingSkeleton />;
if (error) return <Error error={error as Error} onRetry={() => refetch()} />;
return (
<main className="h-[100dvh] bg-white">
<Outlet />
</main>
);
}
SETUP AXIOS INTERCEPTER FOR REFRESH TOKEN SUPPORT
src/api.ts
import axios from 'axios';
let isRefreshing = false;
let failedQueue: { resolve: (token: string) => void; reject: (err: unknown) => void }[] = [];
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1',
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
});
export const setApiToken = (token: string | null) => {
if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} else {
delete api.defaults.headers.common['Authorization'];
}
};
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token!);
}
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (
error.response?.status === 401 &&
!originalRequest._retry &&
!originalRequest.url?.includes('/auth/refresh')
) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string) => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
resolve(api(originalRequest));
},
reject: (err: unknown) => reject(err),
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post(
`${import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1'}/auth/refresh`,
{},
{ withCredentials: true }
);
const accessToken = data.data.accessToken;
setApiToken(accessToken);
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
processQueue(null, accessToken);
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
setApiToken(null);
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
SETUP AUTH-CONTEXT
src/providers/auth-context.tsx
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState } from 'react';
import type { AuthContextType } from '@/types/auth-context-type';
import type { User } from '@/types/user-type';
import api, { setApiToken } from '@/api';
const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const logout = async () => {
try {
await api.post('/auth/logout');
setUser(null);
return true;
} catch (error) {
console.error('Logout failed:', error);
return false;
} finally {
setApiToken(null);
setUser(null);
window.location.href = '/login';
}
};
const value: AuthContextType = {
user,
setUser,
loading,
setLoading,
logout,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
//use it
const { user, logout } = useAuth();
package.json
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"preview": "vite preview"
},