DEV Community

Cover image for Serverless Architecture with Next.js 14 and Next.js Actions: A Practical Guide
saurabh kamble
saurabh kamble

Posted on

2

Serverless Architecture with Next.js 14 and Next.js Actions: A Practical Guide

With the release of Next.js 14, the framework brings significant upgrades that simplify modern web development. One of the standout features is Next.js Actions, which allows developers to handle server-side logic directly in the same file without the need for a dedicated backend or API routes. In this blog, we will explore how Next.js 14 and Next.js Actions can be leveraged to build a serverless architecture, using examples from a project hosted in my [GitHub repository.]

What is Serverless Architecture?

In a serverless architecture, developers no longer need to manage servers explicitly. The cloud provider dynamically allocates resources, and users are charged based on actual usage, not pre-purchased capacity. With Next.js 14, this concept becomes easier to implement as you can now perform server-side operations like form submissions, CRUD operations, and database queries directly within your components using Next.js Actions.

Next.js Actions Overview

Next.js Actions provide a streamlined approach to executing server-side code without the need for API routes. With actions, you can handle operations like form submissions, authentication, or even database manipulation right inside your Next.js pages or components.

Setting Up Serverless Functions with Next.js Actions

In the project on my GitHub, we built a full-stack application using Next.js 14 and MongoDB. Let’s break down how Next.js Actions are used to handle server-side logic seamlessly.

1. Form Submission Using Next.js Actions

One of the key parts of the application is the form handling functionality. Instead of setting up a separate API route, we use Next.js Actions to manage the form data submission directly.

Here’s a snippet of the form from the ask-question page:

"use client";
import React, { useRef, useState } from "react";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { QuestionSchema } from "@/lib/validation";
import { Editor } from "@tinymce/tinymce-react";
import { Badge } from "../ui/badge";
import Image from "next/image";
import { createQuestion, editQuestion } from "@/lib/actions/question.action";
import { useRouter, usePathname } from "next/navigation";
import { parse } from "path";
interface Props {
type?: string;
mongoUserId: string;
questionDetails?: string
}
const Question = ({ type, mongoUserId, questionDetails }: Props) => {
const editorRef = useRef(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const router = useRouter();
const pathname = usePathname();
const parsedQuestionDetails = questionDetails && JSON.parse(questionDetails || '');
console.log("parsedQuestionDetails",parsedQuestionDetails);
const groupedTags = parsedQuestionDetails?.tags.map((tag) => tag.name);
// const log = () => {
// if (editorRef.current) {
// console.log(editorRef.current.getContent());
// }
// };
const form = useForm<z.infer<typeof QuestionSchema>>({
resolver: zodResolver(QuestionSchema),
defaultValues: {
title: parsedQuestionDetails?.title || '',
explanation: parsedQuestionDetails?.content || '',
tags: groupedTags || [],
},
});
async function onSubmit(values: z.infer<typeof QuestionSchema>) {
setIsSubmitting(true);
try {
if (type === 'Edit') {
await editQuestion({
questionId:parsedQuestionDetails._id,
title: values.title,
content:values.explanation,
path:pathname
})
router.push(`/question/${parsedQuestionDetails._id}`);
} else {
await createQuestion({
title: values.title,
content: values.explanation,
tags: values.tags,
author: JSON.parse(mongoUserId),
path: pathname,
});
router.push("/");
}
} catch (error) {
} finally {
setIsSubmitting(false);
}
}
const handleInputKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
field: any
) => {
if (e.key === "Enter" && field.name === "tags") {
e.preventDefault();
const tagInput = e.target as HTMLInputElement;
const tagValue = tagInput.value.trim();
if (tagValue !== "") {
if (tagValue.length > 15) {
return form.setError("tags", {
type: "required",
message: "Tag must be les than 15 characters.",
});
}
if (!field.value.includes(tagValue as never)) {
form.setValue("tags", [...field.value, tagValue]);
tagInput.value = "";
form.clearErrors("tags");
}
} else {
form.trigger();
}
}
};
const handleTagRemove = (tag: string, field: any) => {
const newTags = field.value.filter((t: string) => t !== tag);
form.setValue("tags", newTags);
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-10"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="flex w-full flex-col ">
<FormLabel className="paragraph-semibold text-dark400_light800 ">
Question title
<span className="text-primary-500">*</span>
</FormLabel>
<FormControl className="mt-3.5">
<Input
placeholder=" Question title"
{...field}
className="no-focus paragraph-regular background-light700_dark100 light-border-2 text-dark300_light700 min-h-[56px] border"
/>
</FormControl>
<FormDescription>
Be specific and imagine you&apos;re asking a question to another
person
</FormDescription>
<FormMessage className="text-red-500" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="explanation"
render={({ field }) => (
<FormItem className="flex w-full flex-col ">
<FormLabel className="paragraph-semibold text-dark400_light800 ">
Detailed explanation of your problem
<span className="text-primary-500">*</span>
</FormLabel>
<FormControl className="mt-3.5">
<Editor
apiKey={process.env.NEXT_PUBLIC_TINY_EDITOR_API_KEY}
onInit={(_evt, editor) =>
// @ts-ignore
(editorRef.current = editor)
}
onBlur={field.onBlur}
onEditorChange={(content) => field.onChange(content)}
initialValue={parsedQuestionDetails?.content || ''}
init={{
height: 350,
menubar: false,
plugins: [
"advlist",
"autolink",
"lists",
"link",
"image",
"charmap",
"preview",
"anchor",
"searchreplace",
"visualblocks",
"codesample",
"fullscreen",
"insertdatetime",
"media",
"table",
"code",
"help",
"wordcount",
],
toolbar:
"undo redo | blocks | " +
"codesample | bold italic forecolor | alignleft aligncenter |" +
"alignright alignjustify | bullist numlist outdent indent | " +
"removeformat | help",
content_style: "body { font-family:Inter; font-size:16px }",
}}
/>
</FormControl>
<FormDescription>
Introduce the problem and expand on what you put in the title.
Minimum 20 characters.
</FormDescription>
<FormMessage className="text-red-500" />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem className="flex w-full flex-col ">
<FormLabel className="paragraph-semibold text-dark400_light800 ">
Tags
<span className="text-primary-500">*</span>
</FormLabel>
<FormControl className="mt-3.5">
<>
<Input
disabled={type === 'Edit'}
placeholder=" Add tags .."
onKeyDown={(e) => handleInputKeyDown(e, field)}
className="no-focus paragraph-regular background-light700_dark100 light-border-2 text-dark300_light700 min-h-[56px] border"
/>
{field.value.length > 0 && (
<div className="flex-start mt-2.5 gap-2.5">
{field.value.map((tag: any) => (
<Badge
key={tag}
className="subtle-medium background-light800_dark300 text-light400_light500 flex items-center justify-center gap-2 rounded-md border-none px-4 py-2 capitalize"
onClick={ () => type !== 'Edit' ? handleTagRemove(tag, field) : ()=> {}}>
{tag}
{type !== 'Edit' &&
(<Image
src="/assets/icons/close.svg"
alt="Close icon"
width={12}
height={12}
className="cursor-pointer object-contain invert-0 dark:invert"
/>)}
</Badge>
))}
</div>
)}
</>
</FormControl>
<FormDescription>
Add up to 3 tags to describe what your question is about. You
need to press enter to add a tag.
</FormDescription>
<FormMessage className="text-red-500" />
</FormItem>
)}
/>
<Button
type="submit"
className="primary-gradient !text-light-900 w-fit "
disabled={isSubmitting}
>
{isSubmitting ? (
<>{type === "Edit" ? "Editing ..." : "Posting ..."}</>
) : (
<>{type === "Edit" ? "Edit Question" : " Ask a Question"}</>
)}
</Button>
</form>
</Form>
);
};
export default Question;
view raw Question.tsx hosted with ❤ by GitHub

The action here handles the form submission. Instead of creating an API route, Next.js Actions can process this form directly within the page component.

2. Serverless MongoDB Integration Using Next.js Actions

We integrated MongoDB into the project to store questions and answers. The beauty of Next.js Actions is that you can query the database without setting up a separate API layer. Below is an example of how data is stored in MongoDB directly within the action:

import Question from "@/components/forms/Question";
import { getUserById } from "@/lib/actions/user.action";
import { auth } from '@clerk/nextjs/server';
import { redirect } from "next/navigation";
import React from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title:'Ask Question | Dev Overflow'
}
const page = async () => {
const { userId } = auth();
// const userId = "123456789";
if (!userId) redirect("/sign-in");
const mongoUser = await getUserById({ userId });
return (
<div>
<h1 className="h1-bold text-dark-100_light900"> Ask a Question </h1>
<div className="mt-9">
<Question mongoUserId={JSON.stringify(mongoUser._id)} />
</div>
</div>
);
};
export default page;
view raw page.tsx hosted with ❤ by GitHub

In this snippet, the ask-question action saves the form data directly into a MongoDB collection. By utilizing Next.js Actions, the entire operation stays serverless, and there’s no need to manage servers or manually scale APIs.

3. Fetching Data Server-Side with Next.js Actions

Another major advantage of using Next.js Actions is fetching data from the server. For example, in our project, we have a page where users can view all questions:

import LocalSearchbar from "@/components/shared/search/LocalSearchbar";
import { Button } from "@/components/ui/button";
import Filter from "@/components/shared/Filter";
import Link from "next/link";
import { HomePageFilters } from "@/constants/filter";
import HomeFilters from "@/components/home/HomeFilters";
import NoResult from "@/components/shared/NoResult";
import QuestionCard from "@/components/cards/QuestionsCard";
import { getQuestions, getRecommendedQuestions } from "@/lib/actions/question.action";
import { SearchParamsProps } from "@/types";
import Pagination from "@/components/shared/Pagination";
import Loading from "./loading";
import type { Metadata } from "next";
import { auth } from "@clerk/nextjs/server";
export const metadata: Metadata = {
title: "Home | DevOverflow",
description: "DevOverflow is a community for developers to ask and answer questions. Join us",
}
export default async function Home({ searchParams }: SearchParamsProps) {
const { userId} = auth();
let result;
if (searchParams?.filter === 'recommended') {
if (userId) {
result = await getRecommendedQuestions({
userId,
searchQuery: searchParams.q,
page: searchParams.page ? +searchParams.page : 1
});
}else{
result = {
question:[],
isNext:false
}
}
} else {
result = await getQuestions({
searchQuery: searchParams.q,
filter: searchParams.filter,
page: searchParams.page ? +searchParams.page : 1
});
}
// const isLoading = true;
// if(isLoading) return <Loading />
return (
<>
<div className="flex w-full flex-col-reverse justify-between gap-4 sm:flex-row sm:items-center">
<h1 className="h1-bold text-dark100_light900 "> All Questions</h1>
<Link href="/ask-question" className="flex justify-end max-sm:w-full">
<Button className="primary-gradient text-light-900 min-h-[46px] px-4 py-3">
Ask a Question
</Button>
</Link>
</div>
<div className="mt-11 flex justify-between gap-5 max-sm:flex-col sm:items-center">
<LocalSearchbar
route="/"
iconPosition="left"
imgSrc="/assets/icons/search.svg"
otherClasses="flex-1"
placeholder="Search for questions"
/>
<Filter
filters={HomePageFilters}
otherClasses="min-h-[56px] sm:min-w-[170px]"
containerClasses="hidden max-md:flex"
/>
</div>
<HomeFilters />
<div className="mt-10 flex w-full flex-col gap-6">
{result.questions.length > 0 ? (
result.questions.map((questions) => (
<QuestionCard
key={questions._id}
_id={questions._id}
title={questions.title}
tags={questions.tags}
author={questions.author}
upvotes={questions.upvotes}
views={questions.views}
answers={questions.answer}
createdAt={questions.createdAt}
/>
))
) : (
<NoResult
title="There's no question to show "
description="Be the first to break the silence ! Ask a Question and kickstart the discussion.our query could be the next big thing others learn from. Get involved !"
link="/ask-question"
linkTitle="Ask a Question"
/>
)}
</div>
<div className="mt-10">
<Pagination
pageNumber={searchParams?.page ? +searchParams.page : 1}
isNext={result.isNext}
/>
</div>
</>
);
}
view raw page.tsx hosted with ❤ by GitHub

This example fetches the list of questions from the database using a serverless action and renders them on the page. The API call is server-side, making it highly efficient for performance.

4. API Route for Fetching Questions

Here’s the corresponding action for fetching the data from MongoDB:

"use server";
import Question from "@/database/question.model";
import Tag from "@/database/tag.model";
import User from "@/database/user.model";
import { connectToDatabase } from "../mongoose";
import { CreateQuestionParams, DeleteQuestionParams, EditQuestionParams, GetQuestionByIdParams, GetQuestionsParams, QuestionVoteParams } from "./shared.types";
import { revalidatePath } from "next/cache";
import Answer from "@/database/answer.model";
import Interaction from "@/database/interaction.model";
import { FilterQuery } from "mongoose";
export async function getQuestions(params: GetQuestionsParams) {
try {
connectToDatabase();
const {searchQuery,filter, page =1 ,pageSize=20} = params;
//Calculate the number of posts to skip based on the current page and page size
const skipAmount = (page -1 ) * pageSize;
const query:FilterQuery<typeof Question> ={};
if(searchQuery){
query.$or = [
{title: { $regex: searchQuery, $options: 'i' }},
{content: { $regex: searchQuery, $options: 'i' }},
]
}
let sortOptions = {};
switch(filter){
case"newest":
sortOptions = { createdAt:-1}
break;
case"frequent":
sortOptions = { views:-1}
break;
case"unanswered":
query.answer = { $size:0}
break;
default:
break;
}
const questions = await Question.find(query)
.populate({ path: "tags", model: Tag })
.populate({ path: "author", model: User })
.skip(skipAmount)
.limit(pageSize)
.sort(sortOptions)
const totalQuestion = await Question.countDocuments(query);
const isNext = totalQuestion > skipAmount +questions.length;
return { questions,isNext };
} catch (error) {
console.log(error);
throw error;
}
}
export async function createQuestion(params: CreateQuestionParams) {
try {
connectToDatabase();
const { title, content, tags, author } = params;
const question = await Question.create({
title,
content,
author,
});
const tagDocuments = [];
for (const tag of tags) {
const existingTag = await Tag.findOneAndUpdate(
{ name: { $regex: new RegExp(`^${tag}$`, "i") } },
{ $setOnInsert: { name: tag }, $push: { question: question._id } },
{ upsert: true, new: true }
);
tagDocuments.push(existingTag._id);
}
await Question.findByIdAndUpdate(question._id, {
$push: { tags: { $each: tagDocuments } },
});
await Interaction.create({
user:author,
action:"ask_question",
question:question._id,
tags:tagDocuments
})
await User.findByIdAndUpdate(author, {
$inc: { reputation: 5 } });
} catch (error) {
console.log("error",error);
throw error;
}
}
export async function getQuestionById(params:GetQuestionByIdParams){
try{
connectToDatabase();
const { questionId} =params;
const question = await Question.findById(questionId)
.populate({path:'tags',model:Tag,select:'_id name'})
.populate({path:'author',model:User,select:'_id clerkId name picture'})
return question;
}catch(error){
console.log("error",error);
throw error;
}
}
export async function upvoteQuestion(params: QuestionVoteParams){
try {
connectToDatabase();
const {
questionId,userId,hasupVoted,hasdownVoted,path
} = params;
let updateQuery = {};
if(hasupVoted){
updateQuery = { $pull:{ upvotes:userId}};
} else if (hasdownVoted){
updateQuery = {
$pull:{ downvotes:userId},
$push:{upvotes:userId}
}
}else{
updateQuery = { $addToSet:{ upvotes:userId}}
}
const question = await Question.findByIdAndUpdate(questionId,updateQuery,{new:true});
if(!question){
throw new Error(`Question with id ${questionId} not found`);
}
// Increment author's reputation bu +1/1 for upvoting/revoking an upvote to the question
await User.findByIdAndUpdate(userId,{
$inc:{reputation:hasupVoted ? -1: 1}
})
// Increment author's reputation by +10/10 for recieving an upvote / downvote to the question
await User.findByIdAndUpdate(question.author,{
$inc:{reputation:hasupVoted ? -10: 10}
})
revalidatePath(path)
}catch(error){
console.log("Error",error)
throw error;
}
}
export async function downvoteQuestion(params: QuestionVoteParams){
try {
connectToDatabase();
const {
questionId,userId,hasupVoted,hasdownVoted,path
} = params;
let updateQuery = {};
if(hasdownVoted){
updateQuery = { $pull:{ downvote:userId}};
} else if (hasupVoted){
updateQuery = {
$pull:{ upvotes:userId},
$push:{downvotes:userId}
}
}else{
updateQuery = { $addToSet:{ downvotes:userId}}
}
const question = await Question.findByIdAndUpdate(questionId,updateQuery,{new:true});
if(!question){
throw new Error(`Question with id ${questionId} not found`);
}
await User.findByIdAndUpdate(userId,{
$inc:{reputation:hasdownVoted ? -2: 2}
})
await User.findByIdAndUpdate(question.author,{
$inc:{reputation:hasdownVoted ? -10: 10}
})
revalidatePath(path)
}catch(error){
console.log("Error",error)
throw error;
}
}
export async function deleteQuestion (params:DeleteQuestionParams){
try {
connectToDatabase();
const { questionId,path} = params;
await Question.deleteOne({_id:questionId});
await Answer.deleteMany({question:questionId});
await Interaction.deleteMany({question:questionId});
await Tag.updateMany({question:questionId},
{$pull:{
question:questionId
}});
revalidatePath(path);
}catch(error){
console.log("Error",error)
throw error;
}
}
export async function editQuestion (params:EditQuestionParams){
try {
connectToDatabase();
const { questionId,title,content,tags,path} = params;
const question = await Question.findById(questionId).populate("tags");
if(!question){
throw new Error("Question not found");
}
question.title = title;
question.content = content;
await question.save();
revalidatePath(path);
}catch(error){
console.log("Error",error)
throw error;
}
}
export async function getHotQuestions() {
try {
connectToDatabase();
const hotQuestions = await Question.find({})
.sort({ views: -1, upvotes: -1 })
.limit(5);
return hotQuestions;
} catch (error) {
console.log(error);
throw error;
}
}
export async function getRecommendedQuestions(params: RecommendedParams) {
try {
await connectToDatabase();
const { userId, page = 1, pageSize = 20, searchQuery } = params;
// find user
const user = await User.findOne({ clerkId: userId });
if (!user) {
throw new Error("user not found");
}
const skipAmount = (page - 1) * pageSize;
// Find the user's interactions
const userInteractions = await Interaction.find({ user: user._id })
.populate("tags")
.exec();
// Extract tags from user's interactions
const userTags = userInteractions.reduce((tags, interaction) => {
if (interaction.tags) {
tags = tags.concat(interaction.tags);
}
return tags;
}, []);
// Get distinct tag IDs from user's interactions
const distinctUserTagIds = [
// @ts-ignore
...new Set(userTags.map((tag: any) => tag._id)),
];
const query: FilterQuery<typeof Question> = {
$and: [
{ tags: { $in: distinctUserTagIds } }, // Questions with user's tags
{ author: { $ne: user._id } }, // Exclude user's own questions
],
};
if (searchQuery) {
query.$or = [
{ title: { $regex: searchQuery, $options: "i" } },
{ content: { $regex: searchQuery, $options: "i" } },
];
}
const totalQuestions = await Question.countDocuments(query);
const recommendedQuestions = await Question.find(query)
.populate({
path: "tags",
model: Tag,
})
.populate({
path: "author",
model: User,
})
.skip(skipAmount)
.limit(pageSize);
const isNext = totalQuestions > skipAmount + recommendedQuestions.length;
return { questions: recommendedQuestions, isNext };
} catch (error) {
console.error("Error getting recommended questions:", error);
throw error;
}
}

This API route retrieves all the questions stored in MongoDB and returns them to the client. Using Next.js Actions, we’ve kept the logic serverless and scalable without needing a traditional API backend.

Benefits of Serverless Architecture with Next.js 14 and Actions
Reduced Complexity:

  1. No need to manage separate API routes, simplifying the development process.
  2. Performance: Serverless functions are automatically scaled by cloud providers, reducing latency and improving speed.
  3. Cost Efficiency: You only pay for what you use, making serverless an affordable option.
  4. Security: With serverless functions and Next.js Actions, you can ensure that server-side code is only executed in a secure environment, reducing attack surfaces.

Conclusion

By combining Next.js 14 with Next.js Actions, you can create a powerful serverless architecture that is scalable, performant, and easier to maintain. This approach is particularly well-suited for modern web applications, allowing developers to focus more on business logic and less on infrastructure management.

If you’re looking to build a full-stack application without the hassle of managing servers, Next.js 14 and Actions provide a streamlined way to handle server-side logic, data fetching, and form submissions—all within the same framework.

You can explore the full codebase on my GitHub repository and try it out for yourself!

Feel free to reach out if you have any questions about this project or want to dive deeper into serverless architecture with Next.js 14 and Next.js Actions!

Heroku

Build apps, not infrastructure.

Dealing with servers, hardware, and infrastructure can take up your valuable time. Discover the benefits of Heroku, the PaaS of choice for developers since 2007.

Visit Site

Top comments (0)

Image of Docusign

🛠️ Bring your solution into Docusign. Reach over 1.6M customers.

Docusign is now extensible. Overcome challenges with disconnected products and inaccessible data by bringing your solutions into Docusign and publishing to 1.6M customers in the App Center.

Learn more