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
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-Controlheaders- 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}`);
});