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: emitssubmitting,success,error,redirect, ornavigateas 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
actionor[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. Setaria-busy="true"while submitting, then remove it when processing completes. - Emit
errorafter 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
jsonfunction returns a JSON response. - The
redirectfunction returns a redirect response to the client. This should be an absolute path. - The
failfunction 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']}`,
};
}