Cloudflare's Images service provides a powerful Transform API that allows you to optimize and manipulate images. In this tutorial, we will try to implement our own basic version using TypeScript.

What We're Building

transformation API that lets you:

  • Resize images by width and height
  • Convert between formats (PNG, JPG, WebP)
  • Adjust image quality

Our API will follow a URL-based transformation pattern similar to Cloudflare's Image API: /transform/[options]/[image-path]

Prerequisites

  • Node.js and npm installed
  • Basic knowledge of TypeScript and Express
  • An Upstash account for Redis (used for rate limiting)

Project Setup

Let's start by creating our project structure and installing dependencies:

mkdir picform
cd picform
npm init -y

Now install the required dependencies:

npm install express sharp zod @upstash/ratelimit
npm install -D tsx tsconfig-paths tsup @types/express typescript

Let's look at what each package does:

  • express: Our web server framework
  • sharp: High-performance image processing library
  • zod: TypeScript-first schema validation
  • @upstash/ratelimit: Rate limiting with Upstash Redis
  • tsx: TypeScript execution environment
  • tsconfig-paths: For path aliases in TypeScript
  • tsup: TypeScript bundler for building our application

Project Structure

Our project follows a feature-based structure:

/
├── uploads/           # Directory for uploaded images
├── src/
│   ├── features/      # Feature modules
│   │   └── transform/ # Image transformation feature
│   ├── lib/           # Shared utilities
│   └── server.ts      # Main application entry
├── .env               # Environment variables
└── tsconfig.json      # TypeScript configuration

Configuration Files

Let's set up our TypeScript configuration first:

tsconfig.json

{
  "compilerOptions": {
    "esModuleInterop": true,
    "skipLibCheck": true,
    "target": "es2022",
    "allowJs": true,
    "resolveJsonModule": true,
    "moduleResolution": "bundler",
    "module": "ESNext",
    "noEmit": true,
    "moduleDetection": "force",
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "outDir": "dist",
    "sourceMap": true,
    "lib": ["es2022"],
    "paths": {
      "@lib/*": ["./src/lib/*"],
      "@features/*": ["./src/features/*"]
    }
  }
}

.env

UPSTASH_REDIS_REST_URL=""
UPSTASH_REDIS_REST_TOKEN=""

Copy this file to .env and fill in your Upstash credentials.

package.json Scripts

Update your package.json with the following scripts:

{
  "scripts": {
    "dev": "tsx --watch --env-file=.env src/server.ts",
    "build": "tsup src/",
    "start": "node --env-file=.env dist/server.js"
  }
}
  • dev: Runs the development server with hot reloading
  • build: Bundles the TypeScript code into JavaScript using tsup
  • start: Runs the production build

Building the Core Libraries

Let's create some utility functions first:

src/lib/constants.ts

export const UPLOAD_PATH = "/uploads";

This constant defines where our images will be stored.

src/lib/errors.ts

export class AppError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "AppError";
  }
}

This custom error class helps us distinguish application-specific errors from system errors.

src/lib/safe.ts

This utility provides a convenient way to handle errors in our application:

export type Safe<T> =
  | {
      success: true;
      data: T;
    }
  | {
      success: false;
      error: unknown;
    };

export function safe<T>(promise: Promise<T>, err?: unknown): Promise<Safe<T>>;
export function safe<T>(func: () => T, err?: unknown): Safe<T>;
export function safe<T>(
  promiseOrFunc: Promise<T> | (() => T),
  err?: unknown,
): Promise<Safe<T>> | Safe<T> {
  if (promiseOrFunc instanceof Promise) {
    return safeAsync(promiseOrFunc, err);
  }
  return safeSync(promiseOrFunc, err);
}

export async function safeAsync<T>(
  promise: Promise<T>,
  err?: unknown,
): Promise<Safe<T>> {
  try {
    const data = await promise;
    return { data, success: true };
  } catch (e) {
    if (err !== undefined) {
      return { success: false, error: err };
    }
    return { success: false, error: e };
  }
}

export function safeSync<T>(func: () => T, err?: unknown): Safe<T> {
  try {
    const data = func();
    return { data, success: true };
  } catch (e) {
    console.error(e);
    if (err !== undefined) {
      return { success: false, error: err };
    }
    if (e instanceof Error) {
      return { success: false, error: e.message };
    }
    return { success: false, error: e };
  }
}

src/lib/upstash.ts

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

export const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(2, "3 m"),
  analytics: true,
});

This configures rate limiting to allow 2 requests per IP per image path in a 3-minute sliding window.

Type Definitions

Create a type definition file to extend Express's Request interface:

type.d.ts

import { Transform } from "@features/transform/schema";

declare global {
  namespace Express {
    interface Request {
      transformOptions?: Transform["options"];
      transformPath?: Transform["path"];
    }
  }
}

This adds our custom properties to the Express Request type.

Building the Transform Feature

Now let's create the core transformation feature:

src/features/transform/schema.ts

import { z } from "zod";

const format = z.enum(["png", "jpg", "webp"]);

export const transformSchema = z.object({
  path: z.custom<`${string}.${z.infer<typeof format>}`>(
    (val) => {
      if (typeof val !== "string") return false;
      return /\.(png|jpg|webp)$/i.test(val);
    },
    {
      message: "Path must end with .png, .jpg, or .webp",
    }
  ),
  options: z.object({
    width: z.coerce.number().min(100).max(1920).optional(),
    height: z.coerce.number().min(100).max(1080).optional(),
    quality: z.coerce.number().min(1).max(100).optional(),
    format: format.optional(),
  }),
});

export type Transform = z.infer<typeof transformSchema>;

This Zod schema defines our API's validation rules:

  • Images must have valid extensions (.png, .jpg, or .webp)
  • Width can be between 100-1920px
  • Height can be between 100-1080px
  • Quality can be between 1-100

src/features/transform/service.ts

This is where the image transformation happens using Sharp:

import { AppError } from "@lib/errors";
import { type Transform } from "./schema";
import fs from "fs";
import path from "path";
import sharp from "sharp";
import { UPLOAD_PATH } from "@lib/constants";

export const transformService = async (data: Transform) => {
  // Construct the full file path
  const _path = path.join(process.cwd(), UPLOAD_PATH, data.path);

  // Check if the image exists
  const imageExists = fs.existsSync(_path);
  if (!imageExists) {
    throw new AppError("Image does not exist");
  }

  // Extract file extension from path and use as default format
  const fileExt = path
    .extname(_path)
    .split(".")[1] as Transform["options"]["format"];
  const format = data.options.format ?? fileExt;

  // Read the image file
  const image = fs.readFileSync(_path);

  // Initialize Sharp and resize the image
  const resize = sharp(image).resize({
    height: data.options.height,
    width: data.options.width,
  });

  // Apply format-specific transformations
  switch (format) {
    case "png":
      resize.png({ quality: data.options.quality });
      break;
    case "jpg":
      resize.jpeg({ quality: data.options.quality });
      break;
    case "webp":
      resize.webp({ quality: data.options.quality });
      break;

    default:
      throw new AppError("Invalid format");
  }

  // Generate the transformed image buffer with metadata
  const result = await resize.toBuffer({ resolveWithObject: true });
  return result;
};

src/features/transform/middleware.ts

Here we handle request parsing and rate limiting:

import { type NextFunction, type Request, type Response } from "express";
import { transformSchema } from "./schema";
import { ratelimit } from "@lib/upstach";

export const parseTransformRequest = (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  // Extract options and path from URL parameters
  const _options = req.params[0];
  const path = req.params[1];

  if (!_options || !path) {
    res.status(400).send("params missing");
    return;
  }

  // Parse comma-separated options into key-value object
  let options = _options
    .split(",")
    .reduce<Record<string, unknown>>((acc, current) => {
      const [key, value] = current.split("=");
      acc[key!] = value;
      return acc;
    }, {});

  // Validate input using Zod schema
  const validated = transformSchema.safeParse({
    path,
    options,
  });

  if (!validated.success) {
    res.status(400).send({
      success: false,
      message: "invalid request",
      errors: validated.error.errors.map((i) => ({
        path: Array.isArray(i.path) ? i.path[1] : i.path,
        message: i.message,
      })),
    });
    return;
  }

  // Store validated data in request object for next middleware
  req.transformOptions = validated.data.options;
  req.transformPath = validated.data.path;

  next();
};

export const rateLimit = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const path = req.transformPath;

  if (!path) {
    res.status(400).send("middleware missing");
    return;
  }

  // Create unique key for rate limiting (IP + image path)
  const key = `${req.ip}-${path}`;

  // Apply rate limit
  const limit = await ratelimit.limit(key);
  if (!limit.success) {
    // Calculate retry time and send appropriate headers
    const retry = Math.max(0, Math.floor((limit.reset - Date.now()) / 1000));
    res.setHeader("Retry-After", retry);
    res.status(429).send({
      success: false,
      message: "too many requests",
    });
    return;
  }

  return next();
};

src/features/transform/controller.ts

import { type Response, type Request } from "express";
import { transformService } from "./service";
import { safe } from "@lib/safe";
import { AppError } from "@lib/errors";

export const transformController = async (req: Request, res: Response) => {
  const path = req.transformPath;
  const options = req.transformOptions;

  if (!options || !path) {
    res.status(400).send("middleware missing");
    return;
  }

  // Process the image transformation with error handling
  const data = await safe(transformService({ path, options }));
  if (!data.success) {
    // Handle application-level errors with 400 status
    if (data.error instanceof AppError) {
      res.status(400).send({
        success: false,
        message: data.error.message,
      });
      return;
    }
    // Handle system errors with 500 status
    res.status(500).send({
      success: false,
      message: "Something went wrong",
    });
    return;
  }

  // Set appropriate headers for the image response
  res.setHeader(
    "Content-Disposition",
    `inline; filename=transformed.${data.data.info.format}`
  );
  res.setHeader("Content-Type", `image/${data.data.info.format}`);
  res.setHeader("Cache-Control", "public, max-age=31536000, immutable");

  // Send the transformed image
  res.send(data.data.data);
};

here we are Setting the cache headres so hopefuly the CDN takes care of caching and serving the images that were allready transformed

src/features/transform/route.ts

import { Router } from "express";
import { transformController } from "./controller";
import { parseTransformRequest, rateLimit } from "./middleware";

const transformRouter = Router();

// Define the route using regex pattern to capture parameters
transformRouter.get(
  /^\/transform\/([a-zA-Z0-9=,]+)\/(.+)$/,
  parseTransformRequest,
  rateLimit,
  transformController
);

export default transformRouter;

Main Server File

Finally, let's set up our main server file:

src/server.ts

import transformRouter from "@features/transform/route";
import { UPLOAD_PATH } from "@lib/constants";
import express from "express";
import fs from "fs";
import path from "path";

const app = express();

// Create uploads directory if it doesn't exist
const uploadPath = path.join(process.cwd(), UPLOAD_PATH);
if (!fs.existsSync(uploadPath)) {
  fs.mkdirSync(uploadPath);
}

// Register our transform router
app.use(transformRouter);

// Start the server
app.listen(3000, () => {
  console.log("Server is running on port 3000");
});

Using the API

With everything set up, you can now use the API:

http://localhost:3000/transform/width=500,height=300,quality=80,format=webp/example-image.png

The format is:

/transform/[options]/[image-path]

Where:

  • options are comma-separated key=value pairs
  • image-path is the relative path to the image in the uploads directory

Building for Production

When you're ready to deploy:

  1. Run npm run build to compile the TypeScript code
  2. Use npm start to run the production build

Conclusion

In this tutorial, we've built a basic image transformation API inspired by Cloudflare's Image API. The implementation uses TypeScript, Express, Sharp, and Upstash to provide a clean, well-structured service with:

  • Type-safe request handling
  • Input validation with Zod
  • Rate limiting with Upstash
  • High-performance image processing with Sharp
  • Clear separation of concerns through a feature-based architecture

Github Repo Link (⭐ if u like it)