Getting started with Next.js 14 Server Actions
This post describes what Next.js's Server Actions are and how to use them to create a todo application using the Xata serverless database platform.
Demola Malomo
Jan 03 2024
7 min read
Next.js, Nuxt, SvelteKit, and others consistently innovate their solutions based on the server-side rendering paradigm. This paradigm generates web content on the server side for each request, leading to improved web application performance, SEO, and user experience.
Beyond simply outputting and generating content on the web page, a notable addition in the Next.js 14 release is the support for Server Actions and Mutations. This feature allows us to define functions that run securely on the server and can be called directly from a client or server component for submitting forms and data mutations. This is a game-changer because it eliminates the need to define a directory for the API, create an endpoint, and then consume it in a component.
In this post, we will learn how to use Next.js’s Server Actions to create a basic todo application using the Xata serverless database platform. The project repository can be found here.
Prerequisites
To follow along in this tutorial, the following are required:
Project setup
In this project, we'll use a prebuilt UI to expedite development. To get started, let’s clone the project by navigating to a desired directory and running the command below:
1git clone https://github.com/Mr-Malomz/server-actions.git && cd server-actions
Running the project
Next, we’ll need to install the project dependencies by running the command below:
1npm i
Then, run the application:
npm run dev
The components in the codebase follow the normal Next.js/React pattern of creating components. Notably, the src/components/ui/Button.tsx
and src/components/ui/DeleteButton.tsx
components have been modified. They have the useFormStatus hook for managing form submissions (disabling and enabling clicks).
Setup the database on Xata
To get started, log into the Xata workspace and create a todo
database. Inside the todo
database, create a Todo
table and add a description
column of type String
.
Get the Database URL and set up the API Key
To securely connect to the database, Xata provides a unique and secure URL for accessing it. To get the database URL, click the Get code snippet button and copy the URL. Then click the API Key link, add a new key, save and copy the API key.
Setup environment variable
Next, we must add our database URL and API key as an environment variable. To do this, create .env.local
file in the root directory and add the copied URL and API key.
1XATA_DATABASE_URL= <REPLACE WITH THE COPIED DATABASE URL> 2XATA_API_KEY=<REPLACE WITH THE COPIED API KEY>
Integrate Xata with Next.js
To seamlessly integrate Xata with Next.js, Xata provides a CLI for installing required dependencies and generating a fully type-safe API client. To get started, we need to run the command below:
1xata init
On running the command, we’ll have to answer a few questions. Answer them as shown below:
1Generate code and types from your Xata database <TypeScript> 2Choose the output path for the generated code <PRESS ENTER>
With that, we should see a xata.ts
file in the root directory.
A best practice is not to modify the generated code but to create a helper function to use it. To do this, create a utils/xataClient.ts
file inside the src
folder and insert the snippet below:
1import { XataClient } from '@/xata'; 2 3export const xataClient = () => { 4 const xata = new XataClient({ 5 databaseURL: process.env.XATA_DATABASE_URL, 6 apiKey: process.env.XATA_API_KEY, 7 branch: 'main', 8 }); 9 return xata; 10};
The snippet above imports the XataClient
class from the generated code and configures the client with the required parameters.
Finally, we must create utils/types.ts
to manage our application from the state.
1export type FormResponseType = 2 | { type: 'initial' } 3 | { type: 'error'; message: string } 4 | { type: 'success'; message: string };
Building the todo application
In our todo applications, we will use Server Actions to do the following:
- Create a todo
- Update a todo
- Delete a todo
Create a todo
To create a todo, we need to create an actions/createTodo.ts
inside the app
folder and insert the snippet below:
1'use server'; 2 3import { FormResponseType } from '@/utils/types'; 4import { revalidatePath } from 'next/cache'; 5import { xataClient } from '@/utils/xataClient'; 6 7export const createTodo = async ( 8 _: FormResponseType, 9 formData: FormData 10): Promise<FormResponseType> => { 11 const xata = xataClient(); 12 const description = String(formData.get('description')); 13 14 const response = await xata.db.Todo.create({ description }); 15 16 if (response.description) { 17 revalidatePath('/'); 18 return { 19 type: 'success', 20 message: 'Todo created successfully!', 21 }; 22 } else { 23 return { 24 type: 'error', 25 message: 'Error creating todo!', 26 }; 27 } 28};
The snippet above does the following:
- Uses the
use server
directive to mark the function as a Server Action - Imports the required dependencies
- Creates a
createTodo
function that extracts the required information and uses thexataClient
to create a todo. The function also uses therevalidatePath
function to refresh the homepage when a todo is created successfully
Update a todo
To update a todo, we need to create an actions/updateTodo.ts
and insert the snippet below:
1'use server'; 2 3import { FormResponseType } from '@/utils/types'; 4import { xataClient } from '@/utils/xataClient'; 5import { redirect } from 'next/navigation'; 6 7export const updateTodo = async ( 8 _: FormResponseType, 9 formData: FormData 10): Promise<FormResponseType> => { 11 const xata = xataClient(); 12 const description = String(formData.get('description')); 13 const id = String(formData.get('id')); 14 15 const response = await xata.db.Todo.update(id, { description }); 16 17 if (response?.description) { 18 redirect('/'); 19 return { 20 type: 'success', 21 message: 'Todo updated successfully!', 22 }; 23 } else { 24 return { 25 type: 'error', 26 message: 'Error updating todo!', 27 }; 28 } 29};
The snippet above performs an action similar to the create todo functionality but updates the todo by searching for the corresponding todo and updating it. Then, redirect users to the homepage when it is successful.
Delete a todo
To delete a todo, we need to create an actions/deleteTodo.ts
and insert the snippet below:
1'use server'; 2 3import { xataClient } from '@/utils/xataClient'; 4import { revalidatePath } from 'next/cache'; 5 6export const deleteTodo = async (formData: FormData) => { 7 const xata = xataClient(); 8 const id = String(formData.get('id')); 9 10 await xata.db.Todo.delete(id); 11 revalidatePath('/'); 12};
The snippet above also uses the xataClient
to delete the corresponding todo and refreshes the homepage when a todo deletion is successful.
Putting it all together!
With that done, we can start using the actions in the UI.
Update the create todo component
To do this, we need to modify the src/components/TodoForm.tsx
file as shown below:
1'use client'; 2 3import { Button } from './ui/Button'; 4import { useFormState } from 'react-dom'; //add 5import { createTodo } from '@/app/actions/createTodo'; //add 6 7export const TodoForm = () => { 8 const [formState, action] = useFormState(createTodo, { type: 'initial' }); //add 9 return ( 10 <form action={action}> 11 {formState.type === 'error' && ( 12 <p className='mb-6 text-center text-red-600'> 13 {formState.message} 14 </p> 15 )} 16 <textarea 17 name='description' 18 cols={30} 19 rows={2} 20 className='w-full border rounded-lg mb-2 p-4' 21 placeholder='Input todo details' 22 required 23 /> 24 <div className='flex justify-end'> 25 <div> 26 <Button title='Create' /> 27 </div> 28 </div> 29 </form> 30 ); 31};
The snippet above uses the useFormState
hook to update the form state based on the createTodo
action. Then use the action
and formState
to submit the form and manage the UI state.
Update the edit todo component
First, we must modify the src/components/EditTodoForm.tsx
file to update a todo as shown below:
1'use client'; 2 3import { Button } from './ui/Button'; 4import { useFormState } from 'react-dom'; 5import { updateTodo } from '@/app/actions/updateTodo'; 6import { TodoRecord } from '@/xata'; 7 8export const EditTodoForm = ({ todo }: { todo: TodoRecord }) => { 9 const [formState, action] = useFormState(updateTodo, { type: 'initial' }); //add 10 return ( 11 <form action={action}> 12 {formState.type === 'error' && ( 13 <p className='mb-6 text-center text-red-600'> 14 {formState.message} 15 </p> 16 )} 17 <textarea 18 name='description' 19 cols={30} 20 rows={2} 21 className='w-full border rounded-lg mb-2 p-4' 22 placeholder='Input todo details' 23 required 24 defaultValue={todo.description!} 25 /> 26 <input type='hidden' name='id' value={todo.id} /> 27 <div className='flex justify-end'> 28 <div> 29 <Button title='Update' /> 30 </div> 31 </div> 32 </form> 33 ); 34};
The snippet above does the following:
- Imports the required dependencies
- Modify the
EditTodoForm
component to accept atodo
prop - Uses the
useFormState
hook to update the form state based on theupdateTodo
action. Then use theaction
andformState
to submit the form and manage the UI state. - Uses the
todo
prop to display the required information
With this, we will get an error about props. We will solve it in the next step.
Lastly, we need to modify the src/app/[edit]/page.tsx
file to get the value of a matching todo and pass in the required prop to the EditTodoForm
component.
1//other imports goes here 2import { xataClient } from '@/utils/xataClient'; 3 4const xata = xataClient(); 5 6export default async function Page({ params }: { params: { edit: string } }) { 7 const todo = await xata.db.Todo.read(params.edit); 8 9 return ( 10 <div className={`relative z-10 open-nav `}> 11 <div className='fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity'></div> 12 <div className='fixed inset-0 z-10 w-screen overflow-y-auto'> 13 <div className='flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0'> 14 <div className='relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg'> 15 <div className='bg-white px-4 pb-4 pt-5 sm:p-6 sm:pb-4'> 16 <Link href='/' className='flex justify-end mb-2'> 17 <X className='cursor-pointer' /> 18 </Link> 19 <EditTodoForm todo={todo!} /> 20 </div> 21 </div> 22 </div> 23 </div> 24 </div> 25 ); 26}
Update the homepage to get the list of todos and delete a todo
To do this, we first need to modify the src/components/TodoComp.tsx
file as shown below:
1//other imports 2import { deleteTodo } from '@/app/actions/deleteTodo'; 3import { TodoRecord } from '@/xata'; 4 5export const TodoComp = ({ todo }: { todo: TodoRecord }) => { 6 return ( 7 <div className='flex border p-2 rounded-lg mb-2'> 8 <div className='ml-4'> 9 <header className='flex items-center mb-2'> 10 <h5 className='font-medium'>Todo item {todo.id}</h5> 11 <p className='mx-1 font-light'>|</p> 12 <p className='text-sm'> 13 {todo.xata.createdAt.toDateString()} 14 </p> 15 </header> 16 <p className='text-sm text-zinc-500 mb-2'>{todo.description}</p> 17 <div className='flex gap-4 items-center'> 18 <Link 19 href={todo.id} 20 className='flex items-center border py-1 px-2 rounded-lg hover:bg-zinc-300' 21 > 22 <Pencil className='h-4 w-4' /> 23 <p className='ml-2 text-sm'>Edit</p> 24 </Link> 25 <form action={deleteTodo}> 26 <input type='hidden' name='id' value={todo.id} /> 27 <DeleteButton /> 28 </form> 29 </div> 30 </div> 31 </div> 32 ); 33};
The snippet above does the following:
- Imports the required dependencies
- Modify the
TodoComp
component to accept atodo
prop - Uses the prop to display the required information
- Uses the
deleteTodo
action and thetodo.id
to handle the deletion form.
Lastly, we need to update the src/app/page.tsx
file as shown below:
1//other imports 2import { xataClient } from '@/utils/xataClient'; 3 4const xata = xataClient(); 5 6export default async function Home() { 7 const todos = await xata.db.Todo.getAll(); 8 return ( 9 <main className='min-h-screen w-full bg-[#fafafa]'> 10 <Nav /> 11 <div className='w-full mt-6 flex justify-center'> 12 <div className='w-full lg:w-1/2'> 13 <TodoForm /> 14 <section className='border-t border-t-zinc-200 mt-6 px-2 py-4'> 15 {todos.length < 1 ? ( 16 <p className='text-sm text-zinc-500 text-center'> 17 No todo yet! 18 </p> 19 ) : ( 20 todos.map((todo) => ( 21 <TodoComp todo={todo} key={todo.id} /> 22 )) 23 )} 24 </section> 25 </div> 26 </div> 27 </main> 28 ); 29}
The snippet above gets the list of todos, maps it through the TodoComp
, and passes in the required prop.
With that done, we can test our application by running the following command:
1npm run dev
Check out the demo below:
Conclusion
This post discusses Next.js 14 Server Actions for form submissions and data mutation on client and server components. Server Actions eliminate the need for users to manually create API routes for running applications securely on the server. Instead, they can simply define a function.
Check out these resources to learn more: