building-saas-with-next-js · 7 min read

Implementing Server Actions in Next.js

In this series, we'll guide you through building a SaaS application using Next.js, Prisma, and Supabase.

Fortan Pireva · 28 June 2024

Implementing Server Actions in Next.js

Welcome to the third installment of our series on building a SaaS application using Next.js, Prisma, and Supabase! In this post, we will focus on implementing server actions in Next.js. Server actions allow you to handle server-side logic within your application efficiently. By the end of this post, you’ll know how to create server actions for common tasks and follow best practices for server-side logic.

What are Server Actions?

Server actions in Next.js are essentially API routes that enable you to handle server-side operations such as data fetching, form handling, and more. These actions are defined within the pages/api directory, and each file in this directory maps to an API endpoint.

Setting Up API Routes

Let s start by setting up a basic API route in our Next.js application.

  1. Create a New API Route (pages/api/hello.ts)
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  res.status(200).json({ message: 'Hello, World!' })
}

In this example, we created a simple API route that responds with a "Hello, World!" message. You can access this route by navigating to /api/hello in your browser.

Implementing CRUD Operations with Prisma

Now, let’s implement server actions for CRUD (Create, Read, Update, Delete) operations using Prisma. We'll assume you have a User model defined in your Prisma schema.

  1. Prisma Schema (prisma/schema.prisma)
// prisma/schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
  1. Create User API Route (pages/api/users/create.ts)
// pages/api/users/create.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method === 'POST') {
    const { email, name } = req.body

    try {
      const user = await prisma.user.create({
        data: {
          email,
          name,
        },
      })
      res.status(201).json(user)
    } catch (error) {
      res.status(500).json({ error: 'Error creating user' })
    }
  } else {
    res.setHeader('Allow', ['POST'])
    res.status(405).end(`Method ${req.method} Not Allowed`)
  }
}
  1. Read Users API Route (pages/api/users/index.ts)
// pages/api/users/index.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method === 'GET') {
    try {
      const users = await prisma.user.findMany()
      res.status(200).json(users)
    } catch (error) {
      res.status(500).json({ error: 'Error fetching users' })
    }
  } else {
    res.setHeader('Allow', ['GET'])
    res.status(405).end(`Method ${req.method} Not Allowed`)
  }
}
  1. Update User API Route (pages/api/users/[id].ts)
// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { id } = req.query

  if (req.method === 'PUT') {
    const { email, name } = req.body

    try {
      const user = await prisma.user.update({
        where: { id: Number(id) },
        data: { email, name },
      })
      res.status(200).json(user)
    } catch (error) {
      res.status(500).json({ error: 'Error updating user' })
    }
  } else {
    res.setHeader('Allow', ['PUT'])
    res.status(405).end(`Method ${req.method} Not Allowed`)
  }
}
  1. Delete User API Route (pages/api/users/[id].ts)
// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { id } = req.query

  if (req.method === 'DELETE') {
    try {
      await prisma.user.delete({
        where: { id: Number(id) },
      })
      res.status(204).end()
    } catch (error) {
      res.status(500).json({ error: 'Error deleting user' })
    }
  } else {
    res.setHeader('Allow', ['DELETE'])
    res.status(405).end(`Method ${req.method} Not Allowed`)
  }
}

Best Practices for Server Actions

  1. Validation: Always validate the input data to ensure it meets the expected format and constraints before processing it.
  2. Error Handling: Implement robust error handling to manage unexpected errors gracefully and provide informative responses to the client.
  3. Security: Use authentication and authorization mechanisms to protect sensitive routes and data. Sanitize user inputs to prevent SQL injection and other attacks.
  4. Logging: Add logging to track server-side operations and help with debugging and monitoring.

Integrating Server Actions with the Frontend

Let’s integrate these server actions with our frontend to perform CRUD operations from our React components.

  1. Fetching Users (pages/dashboard.tsx)
// pages/dashboard.tsx
import { NextPage } from 'next'
import { useEffect, useState } from 'react'
import Head from 'next/head'

interface User {
  id: number
  email: string
  name: string
}

const Dashboard: NextPage = () => {
  const [users, setUsers] = useState<User[]>([])

  useEffect(() => {
    const fetchUsers = async () => {
      const response = await fetch('/api/users')
      const data = await response.json()
      setUsers(data)
    }

    fetchUsers()
  }, [])

  return (
    <div className="min-h-screen flex flex-col items-center justify-center bg-gray-100">
      <Head>
        <title>Dashboard | My SaaS App</title>
      </Head>
      <main className="flex flex-col items-center justify-center w-full flex-1 px-20 text-center">
        <h1 className="text-6xl font-bold">Dashboard</h1>
        <div className="mt-6">
          {users.map((user) => (
            <div key={user.id} className="p-4 bg-white shadow rounded-lg mb-4">
              <p>Email: {user.email}</p>
              <p>Name: {user.name}</p>
            </div>
          ))}
        </div>
      </main>
    </div>
  )
}

export default Dashboard
  1. Creating a User (pages/auth/register.tsx)
// pages/auth/register.tsx
import { NextPage } from 'next'
import Head from 'next/head'
import { useState } from 'react'
import Link from 'next/link'

const Register: NextPage = () => {
  const [email, setEmail] = useState('')
  const [name, setName] = useState('')

  const handleRegister = async (event: React.FormEvent) => {
    event.preventDefault()
    const response = await fetch('/api/users/create', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ email, name }),
    })

    if (response.ok) {
      console.log('User created successfully')
    } else {
      console.error('Error creating user')
    }
  }

  return (
    <div className="min-h-screen flex flex-col items-center justify-center bg-gray-100">
      <Head>
        <title>Register | My SaaS App</title>
      </Head>
      <main className="flex flex-col items-center justify-center w-full flex-1 px-20 text-center">
        <h1 className="text-6xl font-bold">Register</h1>
        <form className="mt-8 space-y-6" onSubmit={handleRegister}>
          <div className="rounded-md shadow-sm -space-y-px">
            <div>
              <label htmlFor="email" className="sr-only">Email address</label>
              <input
                id="email"
                name="email"
                type="email"
                autoComplete="email"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border 
                ```typescript
                focus:border-indigo-500 focus:z-10 sm:text-sm"
                placeholder="Email address"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
              />
            </div>
            <div>
              <label htmlFor="name" className="sr-only">Name</label>
              <input
                id="name"
                name="name"
                type="text"
                autoComplete="name"
                required
                className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
                placeholder="Name"
                value={name}
                onChange={(e) => setName(e.target.value)}
              />
            </div>
          </div>
          <div>
            <button
              type="submit"
              className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
            >
              Sign up
            </button>
          </div>
        </form>
        <p className="mt-2 text-sm text-gray-600">
          Already have an account?{' '}
          <Link href="/auth/login">
            <a className="font-medium text-indigo-600 hover:text-indigo-500">Sign in</a>
          </Link>
        </p>
      </main>
    </div>
  )
}

export default Register

Updating and Deleting Users

We can similarly create forms and functions to update and delete users, following the same principles as above.

  1. Update User Form

Create a form for updating a user in pages/dashboard.tsx or in a dedicated update page. Use a PUT request to the /api/users/[id] endpoint.

  1. Delete User Function

Add a delete button next to each user in pages/dashboard.tsx and send a DELETE request to the /api/users/[id] endpoint when clicked.

// pages/dashboard.tsx (additional code)
const handleDelete = async (id: number) => {
  const response = await fetch(`/api/users/${id}`, {
    method: 'DELETE',
  })

  if (response.ok) {
    setUsers(users.filter((user) => user.id !== id))
    console.log('User deleted successfully')
  } else {
    console.error('Error deleting user')
  }
}

// In the return statement, next to each user:
<button
  onClick={() => handleDelete(user.id)}
  className="text-red-600 hover:text-red-800"
>
  Delete
</button>

Best Practices for Server Actions

  1. Validation: Always validate the input data to ensure it meets the expected format and constraints before processing it.
  2. Error Handling: Implement robust error handling to manage unexpected errors gracefully and provide informative responses to the client.
  3. Security: Use authentication and authorization mechanisms to protect sensitive routes and data. Sanitize user inputs to prevent SQL injection and other attacks.
  4. Logging: Add logging to track server-side operations and help with debugging and monitoring.

Conclusion

In this post, we have explored how to implement server actions in Next.js using API routes. We created routes for performing CRUD operations with Prisma, integrated them with our frontend, and followed best practices for server-side logic. In the next post, we’ll dive into user authentication and authorization with Supabase. Stay tuned!

By following this series, you'll gain a comprehensive understanding of how to leverage these powerful tools to build a modern, scalable SaaS application. Happy coding!