Analog
On this page

Form Server Actions

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

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.

Opting Into Enhanced Forms

Set [enhanceForm]="true" to enable enhanced form handling. It defaults to false in v2, so existing forms keep their current behavior. Enhanced handling is planned to become the default in v3; [enhanceForm]="false" explicitly selects the existing behavior.

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

Import FormAction in the component's imports, as with existing forms. The flag configures the same directive. It enables 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 the flag omitted or set to false, forms retain beta's 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 use access environment variables, read cookies, and perform other server-side only operations.

// src/app/pages/newsletter.server.ts
export async function action({ event }: PageServerAction) {
  const body = await readFormData(event);
  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 readFormData(event);
  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 form action.

// src/app/pages/search.server.ts
export async function load({ event }: PageServerLoad) {
  const query = getQuery(event);
  console.log('loaded search', query['search']);

  return {
    loaded: true,
    searchTerm: `${query['search']}`,
  };
}