Node Express Backend for a Dynamic Photo Gallery

by Kuligaposten Oct 23 2024

This post explains how I built a Node/Express server while keeping the frontend fully static.

A Minimal Node + Express Backend for a Dynamic Photo Gallery

See demo

The frontend of a photo gallery can be fully static: masonry grid, lazy loading, lightbox, slideshow. But one thing cannot be done in the browser alone:

Reading folders and listing images dynamically.

For that, we need a small backend.

This post explains how I built a minimal Node + Express API that exposes albums and images as JSON — without databases, frameworks, or CMSs.

Just folders and files.


Why Node + Express?

I already had a VPS and wanted:

  • Full control over the server
  • Image metadata (dimensions, dates)
  • A JSON API tailored to my frontend
  • A backend that updates automatically when I upload images

Node + Express is a good fit when:

  • You want async filesystem access
  • You already use JavaScript everywhere
  • You want more flexibility than static hosting allows

Folder-Driven Architecture

The backend mirrors the filesystem directly:

public/
├── albums/
│   ├── moon/
│   │   ├── moon_001.webp
│   │   ├── moon_001_thumb.webp
│   │   └── moon_002.webp
│   ├── plazademayo/
│   └── purina/

Uploading images is the CMS.

There’s no database. No config files. No rebuild.


API Design

The API exposes two endpoints:

List albums

GET /api/albums
{
  "albums": [
    {
      "name": "moon",
      "url": "https://images.example.com/api/albums/moon"
    }
  ]
}

List images in an album

GET /api/albums/moon
{
  "images": [
    {
      "src": "https://images.example.com/albums/moon/moon_001.webp",
      "thumb": "https://images.example.com/albums/moon/moon_001_thumb.webp",
      "alt": "Gallery: moon",
      "date": "June 12, 2024",
      "bigWidth": 2048,
      "bigHeight": 1365,
      "thumbWidth": 500,
      "thumbHeight": 333
    }
  ]
}

This structure feeds directly into my frontend worker, masonry grid, and lightbox.


The Express Server

The entire backend is a single Express app.

import express from "express";
import cors from "cors";
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
import { fileURLToPath } from "url";

const app = express();
const port = 3023;

app.set("trust proxy", true);
app.use(cors());
app.use(express.static("public"));

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

Why trust proxy matters

When running behind Cloudflare or Nginx, Express otherwise reports URLs as http.

This ensures correct https:// URLs in JSON responses.


Listing Albums

app.get("/api/albums", async (req, res) => {
  try {
    const albumsDir = path.join(__dirname, "public", "albums");
    const entries = await fs.readdir(albumsDir, { withFileTypes: true });

    const albums = entries
      .filter((e) => e.isDirectory())
      .map((e) => ({
        name: e.name,
        url: `${req.protocol}://${req.get("host")}/api/albums/${e.name}`,
      }));

    res.json({ albums });
  } catch (err) {
    res.status(500).json({ error: "Could not read albums" });
  }
});

This endpoint is usually fetched once at startup and cached on the frontend.


Reading Images From an Album

app.get('/api/albums/:album', async (req, res) => {
  const { album } = req.params;
  const alt = req.query.alt || `Gallery: ${album}`;
  const dir = path.join(__dirname, 'public', 'albums', album);
  const baseUrl = `${req.protocol}://${req.get('host')}`;

  try {
    await fs.access(dir);
    const files = await fs.readdir(dir);

    const images = [];
    const thumbs = new Map();

Metadata with Sharp

Instead of guessing dimensions, I use sharp:

const meta = await sharp(filePath).metadata();
const stats = await fs.stat(filePath);

This gives:

  • Width / height
  • File modification date
  • Zero image decoding on the frontend

Thumbnail Pairing Logic

Thumbnails follow a naming convention:

image.webp
image_thumb.webp

The backend pairs them automatically:

if (file.includes("thumb")) {
  thumbs.set(baseName, thumbData);
} else {
  images.push(imageData);
}

No manifests. No manual linking.


Final JSON Response

res.json({
  images: images.map((img) => ({
    src: `${baseUrl}/albums/${album}/${img.file}`,
    thumb: img.thumb ? `${baseUrl}/albums/${album}/${img.thumb}` : null,
    alt,
    date: img.date,
    bigWidth: img.width,
    bigHeight: img.height,
    thumbWidth: img.thumbWidth ?? 500,
    thumbHeight: img.thumbHeight ?? 400,
  })),
});

The frontend doesn’t need to know anything about the filesystem.


Performance Characteristics

This backend is:

  • I/O bound, not CPU bound
  • Very fast with SSD storage
  • Ideal for CDN caching
  • Stateless and safe to scale

For production, you can add:

  • Cache-Control headers
  • Cloudflare caching rules
  • ETag support

When Node Makes Sense Here

Node + Express is a great choice if:

  • You already run a VPS
  • You want advanced image handling
  • You prefer JavaScript end-to-end
  • You may add features later (auth, uploads, admin)

If your backend will never grow, PHP or Workers may be simpler — but Node gives you room.


Final Thoughts

This backend proves you don’t need:

  • A CMS
  • A database
  • A framework-heavy stack

Folders are the data model.

The backend simply exposes them as JSON.

Clean, predictable, and easy to reason about.

here is the full script

import express from "express";
import cors from "cors";
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
import { fileURLToPath } from "url";

const app = express();
const port = 3023;
app.set("trust proxy", true);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

app.use(cors());
app.use(express.static("public"));

const IMAGE_REGEX = /\.(jpg|jpeg|png|gif|webp)$/i;

app.get("/api/albums", async (req, res) => {
  try {
    const albumsDir = path.join(__dirname, "public", "albums");

    const entries = await fs.readdir(albumsDir, { withFileTypes: true });

    const albums = entries
      .filter((entry) => entry.isDirectory())
      .map((entry) => ({
        name: entry.name,
        url: `${req.protocol}://${req.headers.host}/api/albums/${entry.name}`,
      }));
    res.set(
      "Cache-Control",
      "public, max-age=3600, stale-while-revalidate=86400",
    );

    res.json({ albums });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Could not read albums" });
  }
});

app.get("/api/albums/:album", async (req, res) => {
  const { album } = req.params;
  let alt = `Gallery: ${album}`;
  if (req.query.alt) {
    try {
      alt = decodeURIComponent(req.query.alt);
    } catch {}
  }

  const dir = path.join(__dirname, "public", "albums", album);
  const baseUrl = `${req.protocol}://${req.get("host")}`;

  try {
    await fs.access(dir);

    const entries = await fs.readdir(dir, { withFileTypes: true });
    const files = entries
      .filter((e) => e.isFile() && IMAGE_REGEX.test(e.name))
      .map((e) => e.name);

    const imageData = await Promise.all(
      files.map(async (file) => {
        const filePath = path.join(dir, file);
        const [meta, stats] = await Promise.all([
          sharp(filePath).metadata(),
          fs.stat(filePath),
        ]);

        return {
          file,
          isThumb: file.includes("thumb"),
          width: meta.width,
          height: meta.height,
          date: new Date(stats.mtime).toLocaleDateString("en-US", {
            year: "numeric",
            month: "long",
            day: "numeric",
          }),
        };
      }),
    );

    const thumbs = new Map();
    const images = [];

    imageData.forEach((img) => {
      if (img.isThumb) {
        const base = img.file.replace(/thumb/i, "");
        thumbs.set(base, img);
      }
    });

    imageData.forEach((img) => {
      if (!img.isThumb) {
        const thumb = thumbs.get(img.file);
        images.push({
          src: `${baseUrl}/albums/${album}/${img.file}`,
          thumb: thumb ? `${baseUrl}/albums/${album}/${thumb.file}` : null,
          alt,
          date: img.date,
          bigWidth: img.width,
          bigHeight: img.height,
          thumbWidth: thumb?.width ?? 500,
          thumbHeight: thumb?.height ?? 400,
        });
      }
    });
    res.set(
      "Cache-Control",
      "public, max-age=3600, stale-while-revalidate=86400",
    );

    res.json({ images });
  } catch (err) {
    if (err.code === "ENOENT") {
      return res.status(404).json({ error: "Album not found" });
    }
    console.error(err);
    res.status(500).json({ error: "Server error" });
  }
});

app.use((req, res) => {
  res.status(404).json({ error: "Not found" });
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});
Back to Home