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'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; |
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; |
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> | |
</> | |
); | |
} |
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:
- No need to manage separate API routes, simplifying the development process.
- Performance: Serverless functions are automatically scaled by cloud providers, reducing latency and improving speed.
- Cost Efficiency: You only pay for what you use, making serverless an affordable option.
- 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!
Top comments (0)