Analog
On this page

Form Server Actions

Analog supports server-side handling of form submissions and validation.

FormAction is a good fit when you want progressive-enhancement-style form submissions that post directly to a page action. If you need client-managed mutation state, cache invalidation, or optimistic updates, use TanStack Query mutations against an Analog server route instead.

Setting up the Form

To handle form submissions, use the FormAction directive from the @analogjs/router package. The directive collects FormData and handles GET navigation or POST submission to the current page.

The directive emits after processing the form:

  • onSuccess: when the form is processing on the server and returns a success response.
  • onError: when the form returns an error response.
  • state: emits submitting, success, error, redirect, or navigate as the submission progresses.

The example page below submits an email for a newsletter signup.

// src/app/pages/newsletter.page.ts
type FormErrors =
  | {
      email?: string;
    }
  | undefined;

@Component({
  selector: 'app-newsletter-page',
  imports: [FormAction],
  template: `
    <h3>Newsletter Signup</h3>

    @if (!signedUp()) {
      <form
        method="post"
        (onSuccess)="onSuccess()"
        (onError)="onError($any($event))"
        (state)="$event === 'submitting' && errors.set(undefined)"
      >
        <div>
          <label for="email"> Email </label>
          <input type="email" name="email" />
        </div>

        <button class="button" type="submit">Submit</button>
      </form>

      @if (errors()?.email) {
        <p>{{ errors()?.email }}</p>
      }
    } @else {
      <div>Thanks for signing up!</div>
    }
  `,
})
export default class NewsletterComponent {
  signedUp = signal(false);
  errors = signal<FormErrors>(undefined);

  onSuccess() {
    this.signedUp.set(true);
  }

  onError(result?: FormErrors) {
    this.errors.set(result);
  }
}

The FormAction directive submits the form data to the server, which is processed by its handler.

Enhanced Forms

Enhanced form handling is enabled by default. Set [enhanceForm]="false" to opt out and keep the previous behavior.

<form
  method="post"
  action="/api/newsletter"
  (onSuccess)="onSuccess($event)"
  (onError)="onError($event)"
>
  <input name="email" type="email" />
  <button type="submit">Subscribe</button>
</form>

Import FormAction in the component's imports. Enhanced handling includes all of the following:

  • Use action or [action] as the submission destination. Without an action, POST uses the current page endpoint and GET uses the current route.
  • Preserve repeated GET fields as multiple query values. Submitted fields replace existing values with the same name; other destination query parameters and the fragment are retained.
  • Preserve complete redirect URLs, including query parameters and fragments. Same-origin GET destinations and redirects use Angular navigation with onSameUrlNavigation: 'reload'; external destinations use browser navigation.
  • Set data-state="idle" initially and update it with submission state. Set aria-busy="true" while submitting, then remove it when processing completes.
  • Emit error after network or response-parsing failures, clearing busy state.

The directive manages data-state and aria-busy only while enhancement is enabled. Disabling it restores the previous attribute values. Use the state output to track progress in either mode; FormActionState is exported for typing state handlers.

With [enhanceForm]="false", forms use the current page endpoint, GET query replacement and last-value handling for repeated fields, and pathname-only Angular redirects. The directive does not modify state attributes.

Handling the Form Action

To handle the form action, define the .server.ts alongside the .page.ts file that contains the async action function to process the form submission.

In the server action, you can access environment variables, read cookies, and perform other server-side only operations.

// src/app/pages/newsletter.server.ts
import {
  type PageServerAction,
  redirect,
  json,
  fail,
} from '@analogjs/router/server/actions';

export async function action({ event }: PageServerAction) {
  const body = await event.req.formData();
  const email = body.get('email') as string;

  if (!email) {
    return fail(422, { email: 'Email is required' });
  }

  if (email.length < 10) {
    return redirect('/');
  }

  return json({ type: 'success' });
}
  • The json function returns a JSON response.
  • The redirect function returns a redirect response to the client. This should be an absolute path.
  • The fail function is used for returning form validation errors.

Validating a Form Action with a Schema

Use defineAction to parse JSON or form data and validate it with a Standard Schema-compatible library, such as Zod 3.24+ or Valibot. The handler receives the schema's inferred output type, including transformed values.

// src/app/pages/newsletter.server.ts
import { defineAction, json } from '@analogjs/router/server/actions';
import { z } from 'zod';

export const action = defineAction({
  schema: z.object({ email: z.string().email() }),
  handler: ({ data }) => json({ email: data.email }),
});

An optional params schema validates route parameters. The handler also receives params, req, res, fetch, and event, just like a regular PageServerAction. Both synchronous and asynchronous schemas are supported. Without a schema, data contains the parsed request body.

Invalid input returns HTTP 422 with the X-Analog-Errors header and an array of Standard Schema issues. The handler is not called. The existing FormAction directive emits this array through onError; each issue includes a message and an optional path. Existing actions returning fail() keep their own error shape.

Repeated form fields are preserved as arrays, including file fields. Empty or unparseable bodies fall back to {}, which is then validated by the schema.

Displaying Validation Errors

Use issuesToFieldErrors and issuesToFormErrors to display the issues returned by defineAction. Field paths become dot-separated names, and multiple messages for the same field remain in order. Issues without a path are form-level errors.

import { signal } from '@angular/core';
import {
  issuesToFieldErrors,
  issuesToFormErrors,
  type ValidationFieldErrors,
} from '@analogjs/router';
import type { StandardSchemaV1 } from '@analogjs/router/server/actions';

// Inside the component handling a defineAction form:
fieldErrors = signal<ValidationFieldErrors>({});
formErrors = signal<string[]>([]);

onError(result: unknown) {
  // This form's defineAction handler returns Standard Schema issues.
  const issues = result as ReadonlyArray<StandardSchemaV1.Issue>;
  this.fieldErrors.set(issuesToFieldErrors(issues));
  this.formErrors.set(issuesToFormErrors(issues));
}

Bind (onError)="onError($event)" on the form and render the messages:

@for (message of fieldErrors()['email'] ?? []; track $index) {
<p>{{ message }}</p>
} @for (message of formErrors(); track $index) {
<p>{{ message }}</p>
}

issuePathToFieldName(['profile', { key: 'name' }, 0]) returns 'profile.name.0' when you need to normalize an individual issue path. These helpers accept Standard Schema issue arrays; existing actions that return custom error objects with fail() can keep their existing error handlers.

Field names use dots to separate path segments without escaping. A literal key such as ['profile.name'] and a nested path ['profile', 'name'] both map to 'profile.name'. If your schema distinguishes these keys, use the original issue paths to display their messages separately.

Handling Multiple Forms

To handle multiple forms on the same page, add a hidden input to distinguish each form.

<form method="post">
  <div>
    <label for="email"> Email </label>
    <input type="email" name="email" />
  </div>

  <input type="hidden" name="action" value="register" />

  <button class="button" type="submit">Submit</button>
</form>

In the server action, use the action value.

export async function action({ event }: PageServerAction) {
  const body = await event.req.formData();
  const action = body.get('action') as string;

  if (action === 'register') {
    // process register form
  }
}

Handling GET Requests

Forms with a GET action can be used to navigate to the same URL, with the form inputs passed as query parameters.

The example below defines a search form with the search field as a query param.

// src/app/pages/search.page.ts
@Component({
  selector: 'app-search-page',
  imports: [FormAction],
  template: `
    <h3>Search</h3>

    <form method="get">
      <div>
        <label for="search"> Search </label>
        <input type="text" name="search" [value]="searchTerm()" />
      </div>

      <button class="button" type="submit">Submit</button>
    </form>

    @if (searchTerm()) {
      <p>Search Term: {{ searchTerm() }}</p>
    }
  `,
})
export default class NewsletterComponent {
  loader = toSignal(injectLoad<typeof load>(), { requireSync: true });
  searchTerm = computed(() => this.loader().searchTerm);
}

The query parameter can be accessed through the server load function.

// src/app/pages/search.server.ts
import type { PageServerLoad } from '@analogjs/router';

export async function load({ event }: PageServerLoad) {
  const searchTerm = event.url.searchParams.get('search') ?? '';
  console.log('loaded search', searchTerm);

  return {
    loaded: true,
    searchTerm,
  };
}

TanStack Query Mutations

When a form submission needs to invalidate cached queries or participate in a broader client-side server-state flow, use injectMutation with an Analog server route.

import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core';
import { lastValueFrom } from 'rxjs';
import {
  QueryClient,
  injectMutation,
} from '@tanstack/angular-query-experimental';

@Component({
  template: `
    <button type="button" (click)="save()">Save</button>
    @if (error()) {
      <p>{{ error() }}</p>
    }
  `,
})
export default class MutationExampleComponent {
  private readonly http = inject(HttpClient);
  private readonly queryClient = inject(QueryClient);

  readonly error = signal('');

  readonly mutation = injectMutation(() => ({
    mutationFn: () =>
      lastValueFrom(
        this.http.post('/api/v1/query-todos', {
          scope: 'docs',
          title: 'Write docs',
        }),
      ),
    onSuccess: () =>
      this.queryClient.invalidateQueries({
        queryKey: ['analog-query-todos', 'docs'],
      }),
    onError: (err: HttpErrorResponse) => {
      this.error.set(err.message);
    },
  }));

  save() {
    this.error.set('');
    this.mutation.mutate();
  }
}