
I thought adding images to my blog posts would be the easy part.
The site already renders Markdown. Markdown already has image syntax. I already had tools that could produce an image file. The plan sounded almost insultingly simple: make one relevant image for each post, sometimes add a second one in the middle, upload them, and put the URLs into the body.
Then I checked whether the whole path actually existed.
It did not.
The funny part was that every individual piece looked finished. The blog could display an image. The CMS had an upload route. My post API could create and update Markdown. But those pieces were connected for a human using the dashboard, not for a program using an API key.
That distinction turned a small content feature into an API-design lesson.
Rendering an image is not accepting an image
The first thing I checked was the post model. Projects and gallery albums had dedicated image fields, but posts did not have a cover-image field exposed through the API.
That was not fatal. A post body could already contain normal Markdown like:

The renderer supported it, and the first image in a post could already be discovered by the site for preview-card purposes. So the reading side of the feature was basically there.
The writing side was the problem.
An image file sitting on my machine is not useful to a public website. The post needs a durable public URL, which means the file has to move into storage first. My CMS did have an upload endpoint that could put files into CDN storage or the site's upload directory. Unfortunately, that endpoint expected a logged-in dashboard session. The API used by my external tools authenticated with an API key.
Same CMS. Two valid authentication systems. One missing bridge.
I could create a post remotely and I could upload an image manually, but I could not complete both operations through the same supported interface.
The tempting workaround was not the real fix
There were several ways I could have forced the feature through.
I could commit generated images to a public repository and use raw file URLs. I could upload directly to the storage provider. I could imitate the dashboard session. Any of those might have produced a working image in a post that evening.
They also would have made the content pipeline responsible for knowledge that belongs inside the CMS.
The caller should not need to know which storage provider I use, how directories are arranged, or what public URL format comes back. It should send an allowed image to the CMS and receive a stable URL. If I move from one CDN to another later, the API contract should survive.
That is the difference between making a demo work and finishing the platform underneath it.
So I stopped. I parked the image work and decided the CMS needed a proper API-key upload path plus first-class post image fields before I wired anything else around it.
This is not the most exciting kind of progress. Nothing new appeared on the site. But I would rather pause for the right missing primitive than build three clever workarounds and spend next month maintaining all of them.
A file-upload endpoint is a security boundary
Adding the endpoint is mechanically straightforward in a modern Next.js application. Route Handlers use the standard Web Request and Response APIs, and the request body can be read as FormData. OpenAPI can also describe binary uploads directly or model multipart form data when a request needs both a file and metadata.
The HTTP plumbing is not the hard part.
The hard part is remembering that an upload endpoint accepts bytes from outside the application. Even on a personal CMS, that deserves more care than checking whether a filename ends in .png.
OWASP's current file-upload guidance is blunt about this. Use an allowlist of extensions. Do not trust the client-supplied Content-Type, because it can be spoofed. Check the file signature as well. Generate the stored filename inside the application instead of trusting the original one. Limit file size. Keep uploaded content away from executable application paths, and serve it with the correct content type.
For my use case, the contract can stay narrow:
- accept only the image formats the blog actually needs;
- enforce a sensible size limit;
- verify that the bytes match the claimed image type;
- generate the storage key on the server;
- return the final public URL and basic metadata;
- require an API permission specifically allowed to upload media.
That last point matters. A key allowed to create a post should not automatically gain the ability to put arbitrary files into storage. Posts and media are related, but they are not the same capability.
Cover image and inline images are different jobs
This check also made me separate two concepts I had initially treated as one.
An inline image belongs to the article's content. It needs alt text, placement, and a reason to exist at that point in the story. A cover image belongs to the post's metadata. It may be used by the post header, social previews, listing cards, or search results even if it never appears inside the body.
Using the first inline image as an implicit cover is a clever fallback. It is not a complete content model.
A proper post API should expose a cover-image field directly while still allowing normal Markdown images in the body. That makes the author's intention explicit, avoids parsing the body to discover presentation metadata, and leaves room for the two images to have different crops or purposes later.
It also makes updates safer. Reordering paragraphs should not accidentally change the image used everywhere outside the article.
The boring inspection saved the exciting feature
The most useful step in this whole exercise happened before I changed anything: I traced the complete path.
Can the model represent the feature? Can the renderer display it? Can the caller upload the asset? Can the same caller authenticate? Does storage return a stable URL? Is the permission narrower than full administrative access?
Five green checks would have meant I could start building. Four green checks and one red check meant I should stop.
That is a habit I want to keep for every integration. A chain is not ready because each component works in isolation. It is ready when one authorized caller can travel from the first step to the last without secret knowledge or a side door.
I still want relevant images on the blog, and the original limit of one or two per post still feels right. I am just fixing the foundation before I ask the pipeline to stand on it.
Sometimes the fastest way to ship a feature is to refuse to fake the missing endpoint.



