Build a Great Nest.js Blog: Upload Image
Daniel Hayes
Full-Stack Engineer · Leapcell

In the previous tutorial, we implemented a comment reply feature for our blog.
Now that the article comment function is quite complete, it makes the articles themselves seem a bit plain—after all, they only support plain text.
In the following tutorial, we will enable articles to support image insertion, enriching their expressiveness.
The principle behind inserting images is as follows:
- The user selects an image and uploads it to the backend.
- The backend stores the image somewhere and returns a URL to access the image resource.
- The frontend inserts the image URL into the article content.
- The article content is finally rendered as a web page, and the image is displayed by fetching the corresponding data based on the image URL.
Step 1: Prepare S3-Compatible Object Storage
Before we start coding, we need a place to store the uploaded images. Storing images directly on the server's disk is one method, but in modern applications, it is more recommended to use an object storage service (like AWS S3) due to its advantages such as high availability, easy scalability, and low cost.
For convenience, we will continue to use Leapcell, which not only provides a database and backend hosting but also offers an S3-compatible Object Storage service.
- Create a Bucket: Log in to the Leapcell console, go to the "Object Storage" page, and click "Create Bucket". Enter a globally unique Bucket name (e.g.,
my-nest-blog-images
) and select a region. - Obtain Access Credentials: In the Bucket details page or your account settings, find your Access Key ID and Secret Access Key. Also, note down your Endpoint address.
This information (Bucket name, Endpoint, Access Key, Secret Key) is crucial, and we will use it in our backend configuration later.
Step 2: Implement the Image Upload API on the Backend
Now, let's build the Nest.js backend to handle file uploads.
1. Install Dependencies
We need aws-sdk
(the new version is @aws-sdk/client-s3
) to interact with S3-compatible storage services, and @nestjs/platform-multer
to handle multipart/form-data
requests.
npm install @aws-sdk/client-s3 npm install @nestjs/platform-multer
2. Configure Environment Variables
For security reasons, do not hardcode your S3 credentials in your code. Create a .env
file in the project's root directory (if you don't have one already) and add the following content:
# .env S3_ENDPOINT=https://objects.leapcell.io S3_ACCESS_KEY_ID=YOUR_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY=YOUR_SECRET_ACCESS_KEY S3_BUCKET_NAME=my-nest-blog-images
Be sure to replace the values above with your own information.
To allow Nest.js to read the .env
file, we need to install the config module:
npm install @nestjs/config
Then, import ConfigModule
in app.module.ts
:
// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; // ... other imports @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), // Set as a global module // ... other modules ], // ... }) export class AppModule {}
3. Create the Uploads Module
We will create a separate module for our file upload functionality.
nest generate module uploads nest generate controller uploads nest generate service uploads
Write uploads.service.ts
This service is responsible for the core logic of communicating with S3.
// src/uploads/uploads.service.ts import { Injectable } from '@nestjs/common'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { ConfigService } from '@nestjs/config'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class UploadsService { private readonly s3: S3Client; constructor(private readonly configService: ConfigService) { this.s3 = new S3Client({ endpoint: this.configService.get<string>('S3_ENDPOINT'), region: 'us-east-1', // For S3-compatible storage, region is often formal credentials: { accessKeyId: this.configService.get<string>('S3_ACCESS_KEY_ID'), secretAccessKey: this.configService.get<string>('S3_SECRET_ACCESS_KEY'), }, }); } async uploadFile(file: Express.Multer.File): Promise<string> { const bucket = this.configService.get<string>('S3_BUCKET_NAME'); const endpoint = this.configService.get<string>('S3_ENDPOINT'); const uniqueFileName = `${uuidv4()}-${file.originalname}`; const command = new PutObjectCommand({ Bucket: bucket, Key: uniqueFileName, Body: file.buffer, ContentType: file.mimetype, ACL: 'public-read', // Set the file to be publicly readable }); try { await this.s3.send(command); // Return the public URL of the file return `${endpoint}/${bucket}/${uniqueFileName}`; } catch (error) { console.error('Error uploading to S3:', error); throw new Error('File upload failed.'); } } }
Write uploads.controller.ts
This controller defines the API route and uses FileInterceptor
to receive the uploaded file.
// src/uploads/uploads.controller.ts import { Controller, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { UploadsService } from './uploads.service'; import { AuthenticatedGuard } from '../auth/authenticated.guard'; @Controller('uploads') export class UploadsController { constructor(private readonly uploadsService: UploadsService) {} @UseGuards(AuthenticatedGuard) // Only logged-in users can upload @Post('image') @UseInterceptors(FileInterceptor('file')) // 'file' is the name of the file field in the form async uploadImage(@UploadedFile() file: Express.Multer.File) { const url = await this.uploadsService.uploadFile(file); return { url }; } }
Finally, import UploadsModule
into app.module.ts
so the application can recognize this new module.
Step 3: Frontend Integration with FilePicker API
Now that the backend is ready, let's modify the frontend page new-post.ejs
to add the upload functionality.
FilePicker API vs. Traditional <input type="file">
Before we start, let's briefly compare two frontend file selection methods:
-
Traditional Method:
<input type="file">
- Pros: Excellent compatibility, supported by all browsers, simple to implement.
- Cons: The UI style is determined by the browser and cannot be customized, leading to a rather primitive and outdated user experience.
-
Modern Method: File System Access API (
window.showOpenFilePicker
)- Pros: Provides a modern system file picker, more powerful API (e.g., can get file handles, remember the last opened directory), and a user experience closer to native applications.
- Cons:
- Compatibility Issues: Currently mainly supported in Chromium-based browsers (Chrome, Edge). Firefox and Safari do not yet support it.
- Security Restrictions: Must be run in a secure context (HTTPS).
Considering our blog is a modern project, we will prioritize using showOpenFilePicker
and can provide a fallback solution if needed.
1. Update new-post.ejs
In views/new-post.ejs
, let's add an "Upload Image" button next to the <textarea>
.
<%- include('_header', { title: 'New Post' }) %> <form action="/posts" method="POST" class="post-form"> <div class="form-group"> <label for="title">Title</label> <input type="text" id="title" name="title" required /> </div> <div class="form-group"> <label for="content">Content</label> <div class="toolbar"> <button type="button" id="upload-image-btn">Upload Image</button> </div> <textarea id="content" name="content" rows="15" required></textarea> </div> <button type="submit">Submit</button> </form> <script> document.addEventListener('DOMContentLoaded', () => { const uploadBtn = document.getElementById('upload-image-btn'); const contentTextarea = document.getElementById('content'); uploadBtn.addEventListener('click', async () => { // Check if the browser supports the FilePicker API if (window.showOpenFilePicker) { try { const [fileHandle] = await window.showOpenFilePicker({ types: [{ description: 'Images', accept: { 'image/*': ['.png', '.jpeg', '.jpg', '.gif', '.webp'] }, }], }); const file = await fileHandle.getFile(); uploadFile(file); } catch (error) { // An AbortError is thrown when the user cancels file selection, we ignore it if (error.name !== 'AbortError') { console.error('FilePicker Error:', error); } } } else { // Fallback: For unsupported browsers, we can create a hidden input to trigger alert('Your browser does not support the modern FilePicker API.'); // const input = document.createElement('input'); // input.type = 'file'; // input.accept = 'image/*'; // input.onchange = (e) => uploadFile(e.target.files[0]); // input.click(); } }); function uploadFile(file) { if (!file) return; const formData = new FormData(); formData.append('file', file); // Display a simple loading indicator uploadBtn.disabled = true; uploadBtn.innerText = 'Uploading...'; fetch('/uploads/image', { method: 'POST', body: formData, // Note: No need to manually set the Content-Type header when using FormData }) .then(response => response.json()) .then(data => { if (data.url) { // Insert the returned image URL into the textarea const markdownImage = ``; insertAtCursor(contentTextarea, markdownImage); } else { alert('Upload failed. Please try again.'); } }) .catch(error => { console.error('Upload Error:', error); alert('An error occurred during upload.'); }) .finally(() => { uploadBtn.disabled = false; uploadBtn.innerText = 'Upload Image'; }); } // Helper function to insert text at the cursor position function insertAtCursor(myField, myValue) { if (myField.selectionStart || myField.selectionStart === 0) { var startPos = myField.selectionStart; var endPos = myField.selectionEnd; myField.value = myField.value.substring(0, startPos) + myValue + myField.value.substring(endPos, myField.value.length); myField.selectionStart = startPos + myValue.length; myField.selectionEnd = startPos + myValue.length; } else { myField.value += myValue; } } }); </script> <%- include('_footer') %>
Step 4: Render Articles with Images
We have successfully inserted the image's Markdown link into the database, but it still displays as a string of text. We need to convert the Markdown format to HTML on the article detail page, post.ejs
.
1. Install a Markdown Parsing Library
We will use marked
, a popular and efficient library, to handle parsing on the backend.
npm install marked npm install -D @types/marked
2. Parse Markdown in the Controller
Modify the findOne
method in src/posts/posts.controller.ts
. Before passing the post data to the template, parse its content with marked
.
// src/posts/posts.controller.ts import { Controller, Get, Render, Param, Request } from '@nestjs/common'; import { PostsService } from './posts.service'; import { CommentsService } from '../comments/comments.service'; import { marked } from 'marked'; // Import marked @Controller('posts') export class PostsController { constructor( private readonly postsService: PostsService, private readonly commentsService: CommentsService ) {} // ... other methods @Get(':id') @Render('post') async findOne(@Param('id') id: string, @Request() req) { const post = await this.postsService.findOne(id); const comments = await this.commentsService.findByPostId(id); // Parse Markdown content if (post) { post.content = marked.parse(post.content) as string; } return { post, user: req.user, comments }; } }
3. Update the post.ejs
View
Finally, modify views/post.ejs
to ensure it correctly renders the parsed HTML. In the previous version, we used <%- post.content.replace(/\n/g, '<br />') %>
to handle line breaks. Now, since the content is already HTML, we can output it directly.
<%- include('_header', { title: post.title }) %> <article class="post-detail"> <h1><%= post.title %></h1> <small><%= new Date(post.createdAt).toLocaleDateString() %></small> <div class="post-content"><%- post.content %></div> </article> <a href="/" class="back-link">← Back to Home</a> <%- include('_footer') %>
Note that we use <%-
instead of <%=
. The former outputs raw HTML, while the latter escapes it.
Run and Test
Now, restart your application with npm run start:dev
, and then:
- Log in and go to the "New Post" page.
- Click the "Upload Image" button, and a modern file picker will pop up.
- Select an image. After the upload is complete, the image's Markdown link will be automatically inserted into the text area.
- Publish the article.
- Go to the article's detail page, and you will see the image successfully rendered.
Congratulations, your blog now supports image uploads! From now on, your blog will surely be much more exciting.