Server Actions
Guide to server actions for data mutations and form handling in the BISO Sites web app.
Server Actions
Server Actions are asynchronous functions that run on the server and can be called from Client or Server Components. They're the primary way to handle data mutations, form submissions, and database operations in the web app.
What are Server Actions?
Server Actions provide a way to execute server-side code from the client without creating API routes. They're defined with the "use server" directive and can be used directly in forms or called from event handlers.
Benefits
- Type-safe - Full TypeScript support
- No API routes needed - Direct function calls
- Form-native - Progressive enhancement
- Revalidation - Automatic cache invalidation
- Security - Runs on server with access to secrets
Server Actions Organization
All server actions are located in app/actions/:
app/actions/
├── events.ts # Event management actions
├── membership.ts # Membership actions
├── cart-reservations.ts # Shopping cart & stock reservations
├── orders.ts # Order management & checkout
├── products.ts # Product operations
├── webshop.ts # E-commerce actions
├── news.ts # News/posts actions
├── jobs.ts # Job postings actions
├── campus.ts # Department/campus actions
├── funding.ts # Funding applications
├── large-events.ts # Large event management
├── locale.ts # Locale switching
├── pages.ts # Dynamic page fetching
├── purchase-limits.ts # Product purchase limits
└── varsling.ts # Safety reportingBISO Sites uses custom TablesDB with methods like getRow, listRows, createRow, updateRow, deleteRow instead of standard Appwrite methods. Database name is "app".
Basic Server Action Pattern
Define the Action
// app/actions/events.ts
'use server';
import { createSessionClient } from '@repo/api/server';
import { Query } from '@repo/api';
import { ContentTranslations, ContentType, Locale } from '@repo/api/types/appwrite';
export async function listEvents(params: {
limit?: number;
status?: string;
campus?: string;
locale?: 'en' | 'no';
} = {}): Promise<ContentTranslations[]> {
const {
limit = 25,
status = 'published',
campus,
locale = 'en'
} = params;
try {
const { db } = await createSessionClient();
// Build queries for Appwrite
const queries = [
Query.equal('content_type', 'event'),
Query.select(['content_id', '$id', 'locale', 'title', 'description', 'event_ref.*']),
Query.equal('locale', locale as Locale),
Query.orderDesc('$createdAt')
];
// Get events with their translations using nested relationships
const eventsResponse = await db.listRows<ContentTranslations>(
'app', // Database name
'content_translations', // Collection name
queries
);
let events = eventsResponse.rows;
// Filter on nested fields (not possible to filter in Appwrite query)
if (status !== 'all') {
events = events.filter(event => event.event_ref?.status === status);
}
if (campus && campus !== 'all') {
events = events.filter(event => event.event_ref?.campus_id === campus);
}
// Apply limit after filtering
return events.slice(0, limit);
} catch (error) {
console.error('Error fetching events:', error);
return [];
}
}Use in a Form
// app/(public)/events/[id]/page.tsx
import { registerForEvent } from 'app/actions/events';
export default function EventPage({ event }) {
return (
<form action={registerForEvent}>
<input type="hidden" name="eventId" value={event.$id} />
<input type="email" name="email" placeholder="Email" required />
<input type="text" name="name" placeholder="Name" required />
<button type="submit">Register</button>
</form>
);
}Use in Client Component
// components/events/event-details-client.tsx
'use client';
import { registerForEvent } from 'app/actions/events';
import { useState, useTransition } from 'react';
export function EventDetailsClient({ event }) {
const [isPending, startTransition] = useTransition();
const [result, setResult] = useState<{ success: boolean; error?: string }>();
function handleSubmit(formData: FormData) {
startTransition(async () => {
const result = await registerForEvent(formData);
setResult(result);
});
}
return (
<form action={handleSubmit}>
<input type="hidden" name="eventId" value={event.$id} />
<input type="email" name="email" placeholder="Email" required />
<input type="text" name="name" placeholder="Name" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Registering...' : 'Register'}
</button>
{result?.error && <p className="text-red-500">{result.error}</p>}
</form>
);
}Cart and Order Actions
Cart Reservations
The cart system uses temporary reservations with expiration:
// app/actions/cart-reservations.ts
'use server';
import { createSessionClient } from '@repo/api/server';
import { Query } from '@repo/api';
/**
* Create or update a cart reservation for a product
* Reserves stock for 10 minutes
*/
export async function createOrUpdateReservation(
productId: string,
quantity: number
): Promise<{ success: boolean; message?: string }> {
try {
const { db, account } = await createSessionClient();
// Get session user ID (works for authenticated and anonymous sessions)
const session = await account.get();
const userId = session.$id;
// Set expiration to 10 minutes from now
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
// Check if user already has a reservation (RLS filters by user automatically)
const existingReservations = await db.listRows(
'app',
'cart_reservations',
[Query.equal('product_id', productId)]
);
if (existingReservations.rows.length > 0) {
// Update existing reservation
const reservation = existingReservations.rows[0];
await db.updateRow('app', 'cart_reservations', reservation.$id, {
quantity,
expires_at: expiresAt,
});
} else {
// Create new reservation
await db.createRow('app', 'cart_reservations', 'unique()', {
product_id: productId,
user_id: userId,
quantity,
expires_at: expiresAt,
});
}
return { success: true };
} catch (error) {
console.error('Error creating/updating reservation:', error);
return {
success: false,
message: 'Failed to reserve stock'
};
}
}
/**
* Get available stock for a product, accounting for active reservations
*/
export async function getAvailableStock(productId: string): Promise<number> {
try {
const { db } = await createSessionClient();
// Get product stock
const product = await db.getRow('app', 'webshop_products', productId);
// If stock is not tracked (null), return Infinity
if (product.stock === null || product.stock === undefined) {
return Infinity;
}
const totalStock = product.stock as number;
// Get active reservations (not expired)
const now = new Date().toISOString();
const reservations = await db.listRows(
'app',
'cart_reservations',
[
Query.equal('product_id', productId),
Query.greaterThan('expires_at', now),
]
);
// Sum reserved quantities
const reservedQuantity = reservations.rows.reduce((sum, reservation) => {
return sum + (reservation.quantity as number);
}, 0);
return Math.max(0, totalStock - reservedQuantity);
} catch (error) {
console.error('Error getting available stock:', error);
return 0;
}
}Cart items are reserved for 10 minutes and expire automatically. A cron job (/api/cron/cleanup-reservations) removes expired reservations.
Create Order & Checkout
// app/actions/orders.ts
'use server';
import { createAdminClient } from '@repo/api/server';
import { createVippsCheckout } from '@/lib/vipps';
import { getProduct } from '@/app/actions/products';
import { getAvailableStock } from '@/app/actions/cart-reservations';
export interface CheckoutLineItemInput {
productId: string;
slug: string;
quantity: number;
variationId?: string;
customFields?: Record<string, string>;
}
export interface CartCheckoutData {
items: CheckoutLineItemInput[];
name: string;
email: string;
phone?: string;
}
export async function createCartCheckoutSession(
data: CartCheckoutData
): Promise<{ success: boolean; paymentUrl?: string; orderId?: string; error?: string }> {
try {
if (!data.items || data.items.length === 0) {
throw new Error('Your cart is empty');
}
const locale = 'en'; // or get from session
let subtotal = 0;
const orderItems = [];
// Process each cart item
for (const input of data.items) {
const product = await getProduct(input.productId, locale);
if (!product) {
throw new Error(`Product ${input.slug} is not available`);
}
// Check available stock (considering reservations)
if (product.stock !== null) {
const availableStock = await getAvailableStock(input.productId);
if (availableStock < input.quantity) {
throw new Error(`Only ${availableStock} of ${product.title} available`);
}
}
const basePrice = Number(product.price || 0);
const discountedUnit = basePrice; // Add discount logic if needed
orderItems.push({
product_id: product.$id,
product_slug: product.slug,
title: product.title || product.slug,
unit_price: discountedUnit,
quantity: input.quantity,
});
subtotal += discountedUnit * input.quantity;
}
// Create order in database
const { db } = await createAdminClient();
const order = await db.createRow('app', 'orders', 'unique()', {
status: 'pending',
currency: 'NOK',
subtotal,
total: subtotal,
buyer_name: data.name || 'Guest',
buyer_email: data.email || '',
buyer_phone: data.phone || '',
items_json: JSON.stringify(orderItems),
});
// Create Vipps checkout session
const vippsCheckout = await createVippsCheckout({
amount: Math.round(subtotal * 100), // Convert to øre
reference: order.$id,
paymentDescription: `Order ${order.$id}`,
email: data.email,
firstName: data.name.split(' ')[0] || data.name,
lastName: data.name.split(' ').slice(1).join(' ') || '',
phoneNumber: data.phone || '',
orderId: order.$id,
});
if (!vippsCheckout.ok) {
return { success: false, error: 'Failed to create payment session' };
}
// Update order with Vipps session data
await db.updateRow('app', 'orders', order.$id, {
vipps_session_id: vippsCheckout.data.token,
vipps_payment_link: vippsCheckout.data.checkoutFrontendUrl,
});
return {
success: true,
paymentUrl: vippsCheckout.data.checkoutFrontendUrl,
orderId: order.$id,
};
} catch (error) {
console.error('Checkout error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Internal error',
};
}
}Membership Actions
// app/actions/membership.ts
'use server';
import { createSessionClient } from '@repo/api/server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const membershipSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().min(8),
studentId: z.string().optional(),
campus: z.enum(['oslo', 'bergen', 'trondheim']),
});
export async function submitMembershipApplication(
data: z.infer<typeof membershipSchema>
) {
const validated = membershipSchema.parse(data);
const { db } = await createSessionClient();
try {
await db.createDocument(
process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID!,
'membership_applications',
'unique()',
{
...validated,
status: 'pending',
submittedAt: new Date().toISOString(),
}
);
revalidatePath('/membership');
return { success: true };
} catch (error) {
return {
success: false,
error: 'Failed to submit application'
};
}
}Event Management Actions
// app/actions/events.ts
'use server';
import { createSessionClient } from '@repo/api/server';
import { Query } from '@repo/api';
import { ContentTranslations, ContentType, Locale } from '@repo/api/types/appwrite';
export async function getEvents(params: {
limit?: number;
status?: string;
campus?: string;
locale?: 'en' | 'no';
} = {}): Promise<ContentTranslations[]> {
const {
limit = 25,
status = 'published',
campus,
locale = 'en'
} = params;
try {
const { db } = await createSessionClient();
const queries = [
Query.equal('content_type', 'event'),
Query.select(['content_id', '$id', 'locale', 'title', 'description', 'event_ref.*']),
Query.equal('locale', locale as Locale),
Query.orderDesc('$createdAt')
];
const eventsResponse = await db.listRows<ContentTranslations>(
'app',
'content_translations',
queries
);
let events = eventsResponse.rows;
// Filter on nested fields (not queryable directly in Appwrite)
if (status !== 'all') {
events = events.filter(event => event.event_ref?.status === status);
}
if (campus && campus !== 'all') {
events = events.filter(event => event.event_ref?.campus_id === campus);
}
return events.slice(0, limit);
} catch (error) {
console.error('Error fetching events:', error);
return [];
}
}
export async function getEventById(
id: string,
locale: 'en' | 'no'
): Promise<ContentTranslations[] | null> {
try {
const { db } = await createSessionClient();
const response = await db.listRows<ContentTranslations>(
'app',
'content_translations',
[
Query.equal('content_type', ContentType.EVENT),
Query.equal('content_id', id),
Query.equal('locale', locale),
Query.select(['content_id', '$id', 'locale', 'title', 'description', 'event_ref.*']),
Query.limit(1)
]
);
return response.rows.length > 0 ? response.rows : null;
} catch (error) {
console.error('Error fetching event:', error);
return null;
}
}Product Actions
// app/actions/products.ts
'use server';
import { createAdminClient } from '@repo/api/server';
import { Query } from '@repo/api';
import { ContentTranslations, ContentType, Locale } from '@repo/api/types/appwrite';
export async function listProducts(params: {
status?: string;
campus_id?: string;
locale?: 'en' | 'no';
limit?: number;
} = {}): Promise<ContentTranslations[]> {
try {
const { db } = await createAdminClient();
const queries = [
Query.equal('content_type', ContentType.PRODUCT),
Query.select(['content_id', '$id', 'locale', 'title', 'description', 'product_ref.*']),
Query.equal('locale', params.locale as Locale ?? Locale.EN),
Query.orderDesc('$createdAt')
];
if (params.status) {
queries.push(Query.equal('product_ref.status', params.status));
}
if (params.limit) {
queries.push(Query.limit(params.limit));
}
const response = await db.listRows<ContentTranslations>(
'app',
'content_translations',
queries
);
return response.rows;
} catch (error) {
console.error('Error listing products:', error);
return [];
}
}
export async function getProduct(
id: string,
locale: 'en' | 'no'
): Promise<ContentTranslations | null> {
try {
const { db } = await createAdminClient();
const response = await db.listRows<ContentTranslations>(
'app',
'content_translations',
[
Query.equal('content_type', ContentType.PRODUCT),
Query.equal('content_id', id),
Query.equal('locale', locale),
Query.select(['content_id', '$id', 'locale', 'title', 'description', 'product_ref.*']),
Query.limit(1)
]
);
return response.rows[0] ?? null;
} catch (error) {
console.error('Error getting product:', error);
return null;
}
}Products use a content translation system with content_translations table containing localized title/description, and webshop_products table (accessed via product_ref.*) containing product data (price, stock, etc).
Form Submission Patterns
With useActionState (Recommended)
'use client';
import { useActionState } from 'react';
import { submitMembershipApplication } from 'app/actions/membership';
export function MembershipForm() {
const [state, formAction, isPending] = useActionState(
submitMembershipApplication,
{ success: false }
);
return (
<form action={formAction}>
<input name="firstName" required />
<input name="lastName" required />
<input name="email" type="email" required />
<button disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
{state.error && <p className="text-red-500">{state.error}</p>}
{state.success && <p className="text-green-500">Success!</p>}
</form>
);
}With useTransition
'use client';
import { useTransition } from 'react';
import { addToCart } from 'app/actions/cart-reservations';
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(async () => {
await addToCart(productId, 1);
});
}
return (
<button onClick={handleClick} disabled={isPending}>
{isPending ? 'Adding...' : 'Add to Cart'}
</button>
);
}Revalidation Strategies
Revalidate Path
'use server';
import { revalidatePath } from 'next/cache';
export async function updateEvent(eventId: string, data: UpdateData) {
// ... update database
// Revalidate specific page
revalidatePath(`/events/${eventId}`);
// Revalidate listing page
revalidatePath('/events');
}Revalidate Tag
'use server';
import { revalidateTag } from 'next/cache';
export async function updateProduct(productId: string, data: UpdateData) {
// ... update database
// Revalidate all pages with this tag
revalidateTag('products');
}Error Handling
Always handle errors in server actions and return user-friendly messages. Never expose internal errors to clients.
Structured Error Responses
'use server';
import { z } from 'zod';
type ActionResult<T = unknown> =
| { success: true; data: T }
| { success: false; error: string; field?: string };
export async function createEvent(
data: unknown
): Promise<ActionResult<{ id: string }>> {
try {
const validated = eventSchema.parse(data);
const { db } = await createSessionClient();
const event = await db.createDocument(/*...*/);
return {
success: true,
data: { id: event.$id }
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: 'Validation failed',
field: error.errors[0]?.path[0]?.toString(),
};
}
return {
success: false,
error: 'Failed to create event'
};
}
}Security Considerations
Authentication Check
'use server';
import { createSessionClient } from '@repo/api/server';
export async function sensitiveAction() {
const { account } = await createSessionClient();
try {
// Verify user is authenticated
const user = await account.get();
// Optionally check roles/permissions
if (!user.labels?.includes('admin')) {
return {
success: false,
error: 'Unauthorized'
};
}
// Proceed with action
} catch (error) {
return {
success: false,
error: 'Authentication required'
};
}
}Input Validation
Always validate input using Zod or similar:
'use server';
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
amount: z.number().positive().max(10000),
});
export async function processPayment(data: unknown) {
// This will throw if validation fails
const validated = schema.parse(data);
// Safe to use validated data
}Testing Server Actions
// __tests__/actions/events.test.ts
import { registerForEvent } from '@/app/actions/events';
import { createSessionClient } from '@repo/api/server';
jest.mock('@repo/api/server');
describe('registerForEvent', () => {
it('registers user for event', async () => {
const mockDb = {
createDocument: jest.fn().mockResolvedValue({}),
};
(createSessionClient as jest.Mock).mockResolvedValue({
db: mockDb,
account: { get: () => ({ $id: 'user123' }) },
});
const result = await registerForEvent(/* formData */);
expect(result.success).toBe(true);
expect(mockDb.createDocument).toHaveBeenCalled();
});
});Related Documentation
- API Routes - HTTP endpoints
- Components - Using actions in components
- @repo/api Package - Database operations
- Forms Guide - Form handling
Best Practices
- Always validate input - Use Zod for type-safe validation
- Handle errors gracefully - Return structured error responses
- Revalidate appropriately - Invalidate cache after mutations
- Use TypeScript - Type all inputs and outputs
- Check authentication - Verify user identity when needed
- Keep actions focused - One action, one purpose
- Test thoroughly - Write unit tests for actions
