Skip to main content

Server Actions Deep Dive

Overview

Server actions are the primary data mutation layer in the Ever Works web application. They run server-side via Next.js's 'use server' directive and are called directly from client components using useTransition or form actions. All server actions live in apps/web/src/app/actions/ and proxy requests to the NestJS backend API. They handle authentication token management, input validation (via Zod), error normalization, and cookie/redirect operations.

Architecture

apps/web/src/app/actions/
├── auth.ts # Authentication actions
├── api-keys.ts # API key management
├── email-verification.ts # Email verification
├── notifications.ts # Notification management
├── plugins.ts # Plugin management
├── settings.ts # User settings
├── validation.ts # Shared validation schemas

└── dashboard/
├── index.ts # Barrel re-exports
├── works.ts # Work CRUD + advanced operations
├── generator.ts # AI generation actions
├── generator-form.ts # Generator form schema
├── items.ts # Item CRUD
├── comparisons.ts # Comparison management
├── deploy.ts # Deployment actions
├── work-schedule.ts # Schedule management
├── members.ts # Team member management
├── navigation.ts # Navigation data
├── oauth.ts # OAuth connection management
├── organizations.ts # Organization management
└── taxonomy.ts # Category/tag management

All actions follow a consistent pattern: validate input, call the backend API, handle errors, and return a typed result object.

Components

Auth Actions

File: apps/web/src/app/actions/auth.ts

ActionParametersReturnsDescription
loginFormData | { email, password }Redirect or { error }Email/password login with Zod validation
registerFormData | { username, email, password }Redirect or { error }New user registration
logoutNoneRedirectClears cookies and redirects to home
connectProviderprovider: OAuthProvider{ success, url?, error? }Initiates OAuth flow, returns authorization URL
forgotPassword{ email }{ success, error? }Sends password reset email
resetPassword{ token, password }{ success, error? }Sets new password using reset token

Login flow:

  1. Parse input with Zod schema (LoginSchema).
  2. On validation failure, return field-level errors.
  3. Call authAPI.login({ email, password }).
  4. On success, set access_token and refresh_token cookies.
  5. Redirect to /dashboard.

Register flow:

  1. Parse input with RegisterSchema (username 3-50 chars, email, password 8+ chars).
  2. Call authAPI.register(...).
  3. On success, set auth cookies and redirect to /dashboard.

OAuth flow (connectProvider):

  1. Call backend to generate OAuth authorization URL with state parameter.
  2. Set oauth_state cookie for CSRF protection.
  3. Return the URL for the client to redirect to.
// Client component usage
'use client';
import { login } from '@/app/actions/auth';

function LoginForm() {
const [error, setError] = useState(null);

async function handleSubmit(formData: FormData) {
const result = await login(formData);
if (result?.error) setError(result.error);
// On success, login redirects automatically
}

return <form action={handleSubmit}>{/* fields */}</form>;
}

Work Actions

File: apps/web/src/app/actions/dashboard/works.ts

This is the largest action file, covering all work operations:

ActionDescription
createWorkCreate a new work with name, type, and optional repository config
createWorkWithAICreate a work with AI-assisted content generation
updateWorkUpdate work metadata (name, description, etc.)
deleteWorkDelete a work and its associated repositories
getWorksFetch all works for the current user
syncWorkDataTrigger a sync with the source repository
analyzeRepositoryAnalyze a URL to detect format and content structure
analyzeForLinkingCheck if a repository has existing Ever Works structure
importWorkImport a work from an external source
getUserRepositoriesList repositories from the user's git provider
updateWorkScheduleConfigure automated generation schedule
getRepositoryVisibilityCheck if a repository is public or private
toggleRepositoryVisibilityToggle repository public/private state
getAdvancedPromptsFetch custom AI prompts for a work
updateAdvancedPromptsUpdate custom AI prompts
getWebsiteSettingsFetch website deployment configuration
updateWebsiteSettingsUpdate website deployment configuration
updateCommunityPrSettingsUpdate community PR settings for a work
fetchWorkGenerationHistoryGet the generation history log

Example: createWorkWithAI

'use server';

export async function createWorkWithAI(params: {
name: string;
description: string;
aiProvider: string;
aiModel: string;
}) {
const session = await getSession();
if (!session) return { success: false, error: 'Unauthorized' };

const result = await workAPI.createWithAI(params, session.token);

if (!result.success) {
return { success: false, error: result.error };
}

revalidatePath('/dashboard');
return { success: true, data: result.data };
}

Generator Actions

File: apps/web/src/app/actions/dashboard/generator.ts

ActionDescription
generateItemsStart AI generation for a work with sanitized plugin config
updateItemsBatch update generated items
regenerateMarkdownRegenerate markdown output from existing items

generateItems is notable for its sanitizePluginConfig step, which strips sensitive data (like API keys marked with x-secret) from the plugin configuration before logging or transmitting it over non-secure channels. The actual secret values are resolved server-side from the stored plugin settings.

// Client component usage
startTransition(async () => {
const result = await generateItems({
workId: work.id,
providers: selectedProviders,
options: generatorOptions
});

if (result.success) {
toast.success('Generation started');
} else {
toast.error(result.error);
}
});

Notification Actions

File: apps/web/src/app/actions/notifications.ts

ActionDescription
getNotificationsFetch paginated notifications
getUnreadNotificationCountGet the number of unread notifications
markNotificationAsReadMark a single notification as read
markAllNotificationsAsReadMark all notifications as read
dismissNotificationRemove a notification

These actions are consumed by NotificationDropdown in the dashboard header.

Plugin Actions

File: apps/web/src/app/actions/plugins.ts

ActionDescription
updatePluginSettingsSave plugin configuration settings
getPluginSettingsFetch current plugin settings
enablePluginEnable a plugin
disablePluginDisable a plugin

OAuth Actions

File: apps/web/src/app/actions/dashboard/oauth.ts

ActionDescription
connectOAuthProviderInitiate OAuth connection for a git provider or plugin
disconnectOAuthProviderRemove an OAuth connection

Used by both GitProviderConnections and PluginOAuthConnection settings components.

Settings Actions

File: apps/web/src/app/actions/settings.ts

ActionDescription
updateProfileUpdate username
updatePasswordChange password
deleteAccountDelete the user's account

API Key Actions

File: apps/web/src/app/actions/api-keys.ts

ActionDescription
createApiKeyCreate a new API key with name and optional expiration
revokeApiKeyRevoke an existing API key

Implementation Details

Common Pattern

All server actions follow this structure:

'use server';

import { getSession } from '@/lib/auth/session';
import { revalidatePath } from 'next/cache';

export async function someAction(params: SomeInput) {
// 1. Authenticate
const session = await getSession();
if (!session) {
return { success: false, error: 'Unauthorized' };
}

// 2. Validate (optional, for form inputs)
const parsed = SomeSchema.safeParse(params);
if (!parsed.success) {
return { success: false, errors: parsed.error.flatten().fieldErrors };
}

// 3. Call backend API
try {
const result = await backendAPI.someMethod(parsed.data, session.token);

// 4. Revalidate cached data
revalidatePath('/dashboard');

return { success: true, data: result };
} catch (error) {
return { success: false, error: 'Operation failed' };
}
}

Authentication

Every action that requires authentication calls getSession() to retrieve the current user's session from cookies. The session contains the JWT access token used to authenticate requests to the NestJS backend API.

Input Validation with Zod

Form-based actions use Zod schemas for validation:

const LoginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(1, 'Password required')
});

const RegisterSchema = z.object({
username: z.string().min(3).max(50),
email: z.string().email(),
password: z.string().min(8, 'Minimum 8 characters')
});

Validation errors are returned as field-level error maps that client components display inline under each form field.

Error Normalization

Backend API errors are caught and normalized into a consistent { success: false, error: string } shape. This prevents backend implementation details from leaking to the client.

Cache Revalidation

After successful mutations, actions call revalidatePath() to invalidate Next.js's data cache for the affected pages. This ensures that server components re-fetch fresh data on the next render.

Auth actions (login, register, logout) manage HTTP-only cookies:

  • cookies().set('access_token', token, { httpOnly: true, secure: true, sameSite: 'lax' })
  • cookies().set('refresh_token', token, { ... })
  • cookies().delete('access_token') on logout

Redirect Handling

Actions that change authentication state use redirect() from next/navigation:

  • login and register redirect to /dashboard on success.
  • logout redirects to /.
  • deleteAccount clears cookies and redirects to /.

Styling & Theming

Server actions have no visual component. They are consumed by client components that handle all styling. The consistent return type pattern ({ success, data?, error? }) enables client components to show toast notifications and inline errors using the project's standard patterns.

Usage Examples

Form Action with Validation Errors

'use client';

import { useActionState } from 'react';
import { register } from '@/app/actions/auth';

export function RegisterForm() {
const [state, formAction, isPending] = useActionState(register, null);

return (
<form action={formAction}>
<Input name="username" label="Username" error={state?.errors?.username?.[0]} />
<Input name="email" type="email" label="Email" error={state?.errors?.email?.[0]} />
<Input name="password" type="password" label="Password" error={state?.errors?.password?.[0]} />
{state?.error && <p className="text-danger">{state.error}</p>}
<Button type="submit" loading={isPending}>
Register
</Button>
</form>
);
}

Transition-based Action Call

'use client';

import { useTransition } from 'react';
import { deleteWork } from '@/app/actions/dashboard/works';
import { toast } from 'sonner';

export function DeleteWorkButton({ workId }) {
const [isPending, startTransition] = useTransition();

const handleDelete = () => {
startTransition(async () => {
const result = await deleteWork(workId);
if (result.success) {
toast.success('Work deleted');
} else {
toast.error(result.error || 'Failed to delete');
}
});
};

return (
<Button variant="danger" onClick={handleDelete} loading={isPending}>
Delete Work
</Button>
);
}