Understanding Server Actions in Next.js
Learn how to improve your Next.js application's performance and user experience by utilizing server actions. This guide provides clear explanations and practical examples.
- Next.js
- Next Safe Action
Introduction
Hello everyone, in this blog post, we will learn how to use server action in Next.js. Server action is a powerful feature in Next.js that allows you to create server-side logic that can be called from the client-side. This is useful for handling form submissions, authentication, and other server-side tasks.
In this blog post, we will create a simple authentication system using server action in Next.js. We will use the Lucia library for authentication and Prisma for database access.
Setup
- First, let's create a new Next.js app. You can use my pre-configured template with some tools like TypeScript, Tailwind CSS, Prettier, and ESLint by running the following command:
npx create-t3-app
- Install the required dependencies:
bun add -d prisma
bun add @prisma/client lucia @lucia-auth/adapter-prisma next-safe-action zod zod-form-data
- Create new postgres database and table with the following schema:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid()) @db.Text
email String @unique
name String @db.Text
password String @db.Text
sessions Session[]
}
model Session {
id String @id @db.Text
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
userId String @db.Text
}
- Start the
postgresql
server by running the following command:
docker run --name postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres
Then add the following environment variables to your .env
file:
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
Next, add following scripts to your package.json
file:
{
"scripts": {
...
"db:push": "prisma db push",
"db:studio": "prisma studio",
"postinstall": "prisma generate"
}
}
Then run the following command to push the schema to the database:
bun prisma format
bun db:push
Setting up authentication with Lucia
First, let's create a new configuration file for Lucia:
import type { User } from '@prisma/client'
import { PrismaAdapter } from '@lucia-auth/adapter-prisma'
import { Lucia } from 'lucia'
import { env } from '@/env'
import { db } from '@/server/db'
const adapter = new PrismaAdapter(db.session, db.user)
export const lucia = new Lucia(adapter, {
sessionCookie: {
expires: false,
attributes: { secure: env.NODE_ENV === 'production' },
},
getUserAttributes: (attr) => attr,
})
declare module 'lucia' {
interface Register {
Lucia: typeof lucia
DatabaseUserAttributes: User
}
}
Next, let's create a new auth
function to get the current user:
import 'server-only'
import type { Session, User } from '@prisma/client'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { lucia } from '@/server/auth/lucia'
type Auth = null | (Session & { user: User })
const uncachedAuth = async (): Promise<Auth> => {
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null
if (!sessionId) return null
const result = await lucia.validateSession(sessionId)
try {
if (result.session?.fresh) {
const sessionCookie = lucia.createSessionCookie(result.session.id)
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
)
}
if (!result.session) {
const sessionCookie = lucia.createBlankSessionCookie()
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
)
}
} catch {}
if (!result.session) return null
return { ...result.session, user: result.user }
}
export const auth = cache(uncachedAuth)
Finally, create a SessionProvider
to get the current user in client-side:
'use client'
import type { Session, User } from '@prisma/client'
import * as React from 'react'
type SessionContext = null | (Session & { user: User })
const sessionContext = React.createContext<SessionContext>(null)
interface SessionProviderProps {
session: SessionContext
children: Readonly<React.ReactNode>
}
export const SessionProvider: React.FC<SessionProviderProps> = ({ session, children }) => (
<sessionContext.Provider value={session}>{children}</sessionContext.Provider>
)
export const useSession = () => {
const context = React.useContext(sessionContext)
if (!context) throw new Error('useSession must be used within a SessionProvider')
return context
}
Then, add the SessionProvider
to your layout.tsx
file:
import { SessionProvider } from '@/lib/session'
import { auth } from '@/server/auth'
const RootLayout: React.FC<React.PropsWithChildren> = async ({ children }) => {
const session = await auth()
return (
<html lang="en">
<body>
<SessionProvider session={session}>{children}</SessionProvider>
</body>
</html>
)
}
export default RootLayout
Creating a server action
- Create a instance of safe action:
import * as nsa from 'next-safe-action'
import { zodAdapter } from 'next-safe-action/adapters/zod'
import { z } from 'zod'
import { auth } from '@/server/auth'
import { db } from '@/server/db'
/**
* 1. CONTEXT
*
* This section defines the "contexts" that are available in the backend API.
*
* These allow you to access things when processing a request, like the database, the session, etc.
*
* @see https://next-safe-action.dev/docs/define-actions/middleware#create-standalone-middleware
*/
const context = nsa.createMiddleware().define(async ({ next }) => {
const session = await auth()
return next({ ctx: { db, session } })
})
/**
* 2. INITIALIZATION
*
* This is where the safe-action client is initialized, connecting the context.
*/
const action = nsa
.createSafeActionClient({
validationAdapter: zodAdapter(),
defaultValidationErrorsShape: 'flattened',
defineMetadataSchema: () =>
z.object({ name: z.string().min(1, 'Action name is required') }),
handleServerError: ({ message }, { metadata }) => {
if (message) {
console.error(
`[Server Error] ${metadata.name} threw an error: ${message}`,
)
return message
}
return nsa.DEFAULT_SERVER_ERROR_MESSAGE
},
})
.use(context)
/**
* Middleware for timing action execution.
*
* You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
* network latency that would occur in production but not in local development.
*/
const timingMiddleware = nsa
.createMiddleware<{ metadata: { name: string } }>()
.define(async ({ next, metadata }) => {
const start = performance.now()
const result = await next()
const end = performance.now()
const time = Math.round((end - start) * 100) / 100
console.log(`[Action] ${metadata.name} took ${time}ms to execute`)
return result
})
/**
* Public (unauthenticated) action
*
* This action is available to anyone, regardless of whether they are logged in or not.
* */
export const publicAction = action.use(timingMiddleware)
/**
* Protected (authenticated) action
*
* If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
* the session is valid and guarantees `ctx.session` and `ctx.user` is not null.
*/
export const protectedAction = action
.use(timingMiddleware)
.use(async ({ next, ctx: { session } }) => {
if (!session)
throw new Error('You must be logged in to perform this action')
return next({ ctx: { user, session } })
})
Now, let's create a new server action to handle sign up
, sign in
, and sign out
:
'use server'
import { cookies } from 'next/headers'
import { Scrypt } from 'lucia'
import { z } from 'zod'
import { zfd } from 'zod-form-data'
import { protectedAction, publicAction } from '@/server/actions/safe-action'
import { lucia } from '@/server/auth/lucia'
export const signUp = publicAction
.metadata({ name: 'signUp' })
.schema(
zfd
.formData({
userName: z.string().min(1, 'User Name is required'),
email: z.string().email(),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z
.string()
.min(8, 'Password must be at least 8 characters'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
}),
)
.action(async ({ parsedInput, ctx }) => {
const { userName, email, password } = parsedInput
const existedUser = await ctx.db.user.findUnique({ where: { email } })
if (existedUser) throw new Error('User already exists')
const hashedPassword = await new Scrypt().hash(password)
const newUser = await ctx.db.user.create({
data: { userName, email, password: hashedPassword },
})
if (!newUser) throw new Error('Failed to create user')
return { success: true }
})
export const signIn = publicAction
.metadata({ name: 'signIn' })
.schema(
zfd.formData({
email: z.string().email(),
password: z.string().min(8, 'Password must be at least 8 characters'),
}),
)
.action(async ({ parsedInput: { email, password }, ctx }) => {
const user = await ctx.db.user.findUnique({ where: { email } })
if (!user) throw new Error('User not found')
if (!user.password) throw new Error('User has no password')
const isValid = await new Scrypt().verify(user.password, password)
if (!isValid) throw new Error('Password is incorrect')
const session = await lucia.createSession(user.id, {})
const sessionCookie = lucia.createSessionCookie(session.id)
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
)
return { success: true }
})
export const logout = protectedAction
.metadata({ name: 'logout' })
.action(async ({ ctx }) => {
await lucia.invalidateSession(ctx.session.id)
const sessionCookie = lucia.createBlankSessionCookie()
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes,
)
return { success: true }
})
const passwordSchema = zfd
.formData({
currentPassword: z
.string()
.min(8, 'Password must be at least 8 characters')
.optional(),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z
.string()
.min(8, 'Password must be at least 8 characters'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
export const createPassword = protectedAction
.metadata({ name: 'createPassword' })
.schema(passwordSchema)
.action(async ({ parsedInput: { password }, ctx }) => {
const hashedPassword = await new Scrypt().hash(password)
await ctx.db.user.update({
where: { id: ctx.user.id },
data: { password: hashedPassword },
})
return { success: true }
})
export const changePassword = protectedAction
.metadata({ name: 'changePassword' })
.schema(passwordSchema)
.action(async ({ parsedInput: { currentPassword, password }, ctx }) => {
if (!ctx.user.password) throw new Error('User has no password')
const isValid = await new Scrypt().verify(
ctx.user.password,
currentPassword!,
)
if (!isValid) throw new Error('Current password is incorrect')
const hashedPassword = await new Scrypt().hash(password)
await ctx.db.user.update({
where: { id: ctx.user.id },
data: { password: hashedPassword },
})
return { success: true }
})
Finally, add this route to root action:
import * as auth from '@/server/actions/routes/auth'
/**
* Create a caller for the server actions.
* @example
* const res = await actions.post.getPosts()
* ^? Post[]
*/
export const actions = {
auth,
// ...other actions
}
Using server action in Next.js
Now, let's create a new sign up form components:
'use client'
import { useRouter } from 'next/navigation'
import { useAction } from 'next-safe-action/hooks'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { FormField } from '@/components/ui/form-field'
import { actions } from '@/server/actions'
export const SignUpForm: React.FC = () => {
const router = useRouter()
const { execute, isPending, result } = useAction(actions.auth.signUp, {
onSuccess: () => {
toast.success('Registered successfully')
router.push('/sign-in')
},
onError: ({ error }) => {
if (!error.validationErrors) toast.error(error.serverError)
},
})
return (
<form action={execute} className="space-y-4">
{fields.map((field) => (
<FormField
key={field.name}
{...field}
disabled={isPending}
message={result.validationErrors?.fieldErrors?.[field.name]}
/>
))}
<Button className="w-full" disabled={isPending}>
Register
</Button>
<p className="text-center text-sm">
Already have an account?{' '}
<button
type="button"
className="hover:underline"
onClick={() => router.push('/sign-in')}
>
Login
</button>
</p>
</form>
)
}
const fields = [
{
name: 'userName' as const,
type: 'text',
label: 'User Name',
placeholder: 'Yuki',
},
{
name: 'email' as const,
type: 'email',
label: 'Email',
placeholder: 'yuki@tiesen.id.vn',
},
{
name: 'password' as const,
type: 'password',
label: 'Password',
placeholder: '********',
},
{
name: 'confirmPassword' as const,
type: 'password',
label: 'Confirm Password',
placeholder: '********',
},
]
Then, add it to your app/sign-up/page.tsx
file:
import type { NextPage } from 'next'
import { SignUpForm } from '../_components/sign-up-form'
const Page: NextPage = () => <SignUpForm />
export default Page```
Next, let's create a new sign in form components:
```tsx title="app/(auth)/_components/sign-in-form.tsx"
'use client'
import { useAction } from 'next-safe-action/hooks'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { FormField } from '@/components/ui/form-field'
import { signIn } from '@/server/actions/routes/auth'
export const SignInForm: React.FC = () => {
const router = useRouter()
const { execute, isPending, result } = useAction(signIn, {
onSuccess: () => {
toast.success('Logged in successfully')
router.push('/')
},
onError: ({ error }) => {
if (!error.validationErrors) toast.error(error.serverError)
},
})
return (
<form action={execute} className="space-y-4">
{fields.map((field) => (
<FormField
key={field.name}
{...field}
disabled={isPending}
message={result.validationErrors?.fieldErrors?.[field.name]}
/>
))}
<Button className="w-full" disabled={isPending}>
Login
</Button>
<p className="text-center text-sm">
Don't have an account?{' '}
<button type="button" className="hover:underline" onClick={() => router.push('/sign-up')}>
Register
</button>
</p>
</form>
)
}
const fields = [
{ name: 'email' as const, type: 'email', label: 'Email', placeholder: 'yuki@tiesen.id.vn' },
{ name: 'password' as const, type: 'password', label: 'Password', placeholder: '********' },
]
Then, add it to your app/sign-in/page.tsx
file:
import type { NextPage } from 'next'
import { SignInForm } from '../_components/sign-in-form'
const Page: NextPage = () => {
return <SignInForm />
}
export default Page
Finally, you can get a user's information by using the following component:
'use client'
import Link from 'next/link'
import { useSession } from '@/lib/session'
export const Component: React.FC = () => {
const session = useSession()
if (!session)
return (
<div className="flex items-center gap-2">
<Link href="/sign-in">Sign In</Link> |{' '}
<Link href="/sign-up">Sign Up</Link>
</div>
)
return <div>{session.user.name}</div>
}
Conclusion
In this blog post, we have learned how to use server action in Next.js. We have created a simple authentication system using server action in Next.js. We have used the Lucia library for authentication and Prisma for database access. We have created server actions to handle sign up, sign in, and sign out. We have also created sign up and sign in form components to interact with the server actions.
I hope you find this blog post helpful. If you have any questions or feedback, feel free to leave a comment below. Thank you for reading!
Repository: next-safety-server-action
References
- Next.js: nextjs.org
- Prisma: prisma.io
- Lucia: lucia-auth.com
ElysiaJS Integration in Next.js
Discover the benefits of using ElysiaJS as your backend for Next.js applications. Explore various integration methods and typesafe api calls.
Build Your Own Image Classifier with PyTorch
Dive into the world of Convolutional Neural Networks (CNNs) by building your own image classifier using PyTorch. This blog explores the steps involved, referencing the tiesen243/cnn repository on GitHub.