routeAction$()

routeAction$() is used to define functions called actions that execute exclusively on the server, and only when explicitly called. Actions can have side effects such as writing to a database or sending an email, that cannot happen during client-side rendering. This makes them ideal for handling form submissions, performing operations with side effects, and then returning data back to the client/browser where it can be used to update the UI.

Actions can be declared using routeAction$() or globalAction$() exported from @qwik.dev/router.

src/routes/layout.tsx
import { component$ } from '@qwik.dev/core';
import { routeAction$, Form } from '@qwik.dev/router';
 
export const useAddUser = routeAction$(async (data, requestEvent) => {
  // This will only run on the server when the user submits the form (or when the action is called programmatically)
  const userID = await db.users.add({
    firstName: data.firstName,
    lastName: data.lastName,
  });
  return {
    success: true,
    userID,
  };
});
 
export default component$(() => {
  const action = useAddUser();
 
  return (
    <>
      <Form action={action}>
        <input name="firstName" />
        <input name="lastName" />
        <button type="submit">Add user</button>
      </Form>
      {action.value?.success && (
        // When the action is done successfully, the `action.value` property will contain the return value of the action
        <p>User {action.value.userID} added successfully</p>
      )}
    </>
  );
});
NOTESince actions are not executed during rendering, they can have side effects such as writing to a database, or sending an email. An action only runs when called explicitly.

Using actions with <Form/>

The best way to call an action is using the <Form/> component exported in @qwik.dev/router.

src/routes/index.tsx
import { component$ } from '@qwik.dev/core';
import { routeAction$, Form } from '@qwik.dev/router';
 
export const useAddUser = routeAction$(async (user) => {
  const userID = await db.users.add(user);
  return {
    success: true,
    userID,
  };
});
 
export default component$(() => {
  const action = useAddUser();
  return (
    <Form action={action}>
      <input name="name" />
      <button type="submit">Add user</button>
      {action.value?.success && <p>User added successfully</p>}
    </Form>
  );
});

Under the hood, the <Form/> component uses a native HTML <form> element, so it will work without JavaScript.

When JavaScript is enabled, <Form action={action}> intercepts the submission and calls the action in SPA mode. Set reloadDocument to use a native submission with a full page reload instead.

NOTEAfter an action completes, Qwik Router refreshes invalidated route loaders. The action's invalidate option controls which loaders are refreshed.

Complex forms can be created using dot notation.

Repeated submissions

In SPA mode, <Form action={action}> handles one submission at a time. This starts as soon as the submit event is received, including time spent loading and awaiting onSubmit$ handlers. Further submissions from the same form are ignored until the current submission finishes or throws. Their asynchronous handlers do not run, and their data is not queued, even if the user changes fields, files, or the submit button. Leading sync$ handlers still run during each event so they can control propagation.

After the current submission finishes or fails, the user can submit again. This behavior applies separately to each form. Forms using reloadDocument or running without JavaScript follow native browser submission behavior.

Forms without an action and native <form> elements do not use this guard.

Use action.isRunning to show progress or disable the submit button while the action runs:

<button type="submit" disabled={action.isRunning}>
  {action.isRunning ? 'Saving…' : 'Save'}
</button>

action.isRunning becomes true when the action's submit implementation starts, before the request is queued. Loading its lazy code can happen first. It does not include time spent in an onSubmit$ handler before calling the action; the form still ignores additional submissions during that time.

Using actions programmatically

Actions can also be triggered programmatically using the action.submit() method (i.e. you don't need a <Form/> component). However, you can trigger the action from a button click or any other event, just like you would do with a function.

src/routes/index.tsx
import { component$ } from '@qwik.dev/core';
import { routeAction$ } from '@qwik.dev/router';
 
export const useAddUser = routeAction$(async (user) => {
  const userID = await db.users.add(user);
  return {
    success: true,
    userID,
  };
});
 
export default component$(() => {
  const action = useAddUser();
  return (
    <section>
      <button
        onClick$={async () => {
          const { value } = await action.submit({ name: 'John' });
          console.log(value);
        }}
      >
        Add user
      </button>
      {action.value?.success && <p>User added successfully</p>}
    </section>
  );
});

In the example above, the addUser action is triggered when the user clicks the button. The action.submit() method returns a Promise that resolves when the action is done.

Calls to action.submit() outside a <Form> are not covered by the form's repeated-submission guard. Each call submits the action.

Uploading files

In SPA mode, <Form> submits file inputs in a multipart/form-data request. To upload files with a native submission, including when JavaScript is disabled or reloadDocument is enabled, also set encType="multipart/form-data" on the form.

But when using actions programmatically, you can still upload files by passing a File object to the action.submit() method by creating a FormData object and appending the file to it.

src/routes/index.tsx
 
import { component$, useSignal } from '@qwik.dev/core';
import { routeAction$ } from '@qwik.dev/router';
 
export const useUploadFile = routeAction$(async ({file}) => {
  // save the file somewhere...
  return {
    success: true,
  };
});
 
export default component$(() => {
  const action = useUploadFile();
  const fileUploadRef = useSignal<HTMLInputElement | undefined>();
  return (
    <section>
      <input type="file" ref={fileUploadRef}/>
      <button
        onClick$={async () => {
          
          const file = fileUploadRef.value?.files?.[0];
 
          if (file){
            const formData = new FormData();
            formData.append('file', file);
            const { value } = await action.submit(formData);
            console.log(value);
          }
        }}
      >
        Upload file
      </button>
      
    </section>
  );
});
 

Actions with Event Handlers

In SPA mode, <Form action={action}> awaits its onSubmit$ handlers before submitting the action automatically. If you pass an array of handlers, they run in order. Put sync$ handlers before asynchronous handlers to run them synchronously during event dispatch. If an asynchronous handler throws, the automatic submission is skipped and the form accepts another submission.

The automatic submission reads the form fields after these handlers finish. Returning false or calling event.preventDefault() in a handler does not disable the automatic action submission.

Calling action.submit() in onSubmit$ creates an additional submission. The form still submits its action automatically after the handler finishes, even if the handler already called action.submit(event) with the same event. Use onSubmit$ for additional logic such as validation or updating the UI; it does not take over the form's automatic submission.

For a fully manual flow, use a native <form preventdefault:submit> with an onSubmit$ handler. Passing the submit event to action.submit(event) includes the form fields and the clicked submit button's name and value.

Handling request errors

action.submit() rejects if the request fails or its response cannot be read, for example because a proxy returns HTML instead of JSON. Catch these errors to display feedback and let the user retry:

src/components/add-user-form.tsx
import { component$, useSignal } from '@qwik.dev/core';
import { useAddUser } from '../routes/layout';
 
export default component$(() => {
  const action = useAddUser();
  const requestError = useSignal('');
 
  return (
    <form
      action={action.actionPath}
      method="post"
      preventdefault:submit
      onSubmit$={async (event) => {
        requestError.value = '';
        try {
          await action.submit(event);
        } catch {
          requestError.value = 'Could not submit the form. Please try again.';
        }
      }}
    >
      <input name="firstName" />
      <input name="lastName" />
      <button type="submit" disabled={action.isRunning}>
        Add user
      </button>
      {requestError.value && <p role="alert">{requestError.value}</p>}
    </form>
  );
});

This native form submits the action once through its handler and catches request errors. It does not use the <Form> component's repeated-submission guard. After a rejected submission, action.isRunning returns to false and the user can retry. The same try/catch pattern works for programmatic calls outside a form.

Validation failures and values returned with fail() resolve normally; inspect action.value?.failed to handle them. A rejected request does not fire onSubmitCompleted$ or reset the form through spaReset.

After a response

The onSubmitCompleted$ event handler runs in SPA mode when an action submitted with a form event receives its result, including a result returned with fail(). Its event.detail contains the response status and action value. Check the result before treating the operation as successful.

The spaReset prop defaults to false. When enabled, it resets the form controls to their default values before onSubmitCompleted$ runs, including after a validation failure.

Here's an example of the onSubmitCompleted$ handler used to edit an item in a EditForm component of a todo app.

src/components/EditForm.tsx
import { component$, type Signal } from '@qwik.dev/core';
import { Form } from '@qwik.dev/router';
import { type ListItem, useEditFromListAction } from '../../routes/index';
 
export interface EditFormProps {
  item: ListItem;
  editingIdSignal: Signal<string>;
}
 
const EditForm = component$(
  ({ item, editingIdSignal }: EditFormProps) => {
    const editAction = useEditFromListAction();
 
    return (
      <div>
        <Form
          action={editAction}
          onSubmitCompleted$={() => {
            editingIdSignal.value = '';
          }}
          spaReset
        >
          <input
            type="text"
            value={item.text}
            name="text"
            id={`edit-${item.id}`}
          />
          {/* Sends item.id with form data on submission. */}
          <input type="hidden" name="id" value={item.id} />
          <button type="submit">
            Submit
          </button>
        </Form>
 
        <div>
          <button onClick$={() => (editingIdSignal.value = '')}>
            Cancel
          </button>
        </div>
      </div>
    );
  }
);
 
export default EditForm;

In this example, onSubmitCompleted$ resets editingIdSignal to an empty string once the form receives an action result. This returns the application to its default view. If the action can return a failure, check event.detail.value?.failed before closing the editor.

Validation and type safety

Qwik comes with built-in support for Zod, a TypeScript-first schema validation that can be used directly with actions, using the zod$() function.

Actions + Zod allows to create type safe forms, where the data is validated server side before the action is executed.

src/routes/index.tsx
import { component$ } from '@qwik.dev/core';
import { routeAction$, Form } from '@qwik.dev/router';
import { zod$, z } from '@qwik.dev/utils';
 
export const useAddUser = routeAction$(
  async (user) => {
    // The "user" is strongly typed: { firstName: string, lastName: string }
    const userID = await db.users.add({
      firstName: user.firstName,
      lastName: user.lastName,
    });
    return {
      success: true,
      userID,
    };
  },
  // Zod schema is used to validate that the FormData includes "firstName" and "lastName"
  zod$({
    firstName: z.string(),
    lastName: z.string(),
  })
);
 
export default component$(() => {
  const action = useAddUser();
  return (
    <>
      <Form action={action}>
        <input name="firstName" />
        <input name="lastName" />
 
        {action.value?.failed && <p>{action.value.fieldErrors?.firstName}</p>}
        <button type="submit">Add user</button>
      </Form>
      {action.value?.success && (
        <p>User {action.value.userID} added successfully</p>
      )}
    </>
  );
});

When submitting data to a routeAction(), the data is validated against the Zod schema. If the data is invalid, the action will put the validation error in the routeAction.value property.

Please refer to the Zod documentation for more information on how to use Zod schemas.

Advanced event based validation

The constructor of zod$ can also take a function, as the first argument is zod itself, so you can use this directly to build the schema. The second parameter is the RequestEvent to construct an event-based zod schema. Especially in combination with refine and superRefine in zod, the only limit is your imagination.

Advanced event based validation
export const useAddUser = routeAction$(
  async (user) => {
    // The "user" is still strongly typed, but firstname
    // is now optional: { firstName?: string | undefined, lastName: string }
    const userID = await db.users.add({
      firstName: user.firstName,
      lastName: user.lastName,
    });
    return {
      success: true,
      userID,
    };
  },
  // Zod schema is used to validate that the FormData includes "firstName" and "lastName"
  zod$((z, ev) => {
    // The first name is optional if the url contains the query parameter "firstname=optional"
    const firstName =
      ev.url.searchParams.get("firstname") === "optional"
        ? z.string().optional()
        : z.string().nonempty();
 
    return z.object({
      firstName,
      lastName: z.string(),
    });
  })
);

HTTP request and response

routeAction$ and globalAction$ have access to the RequestEvent object which includes information about the current HTTP request and response.

This allows actions to access the request headers, cookies, url and environment variables within the routeAction$ function.

src/routes/product/[user]/index.tsx
import { routeAction$ } from '@qwik.dev/router';
 
// The second argument of the action is the `RequestEvent` object
export const useProductRecommendations = routeAction$(
  async (_data, requestEvent) => {
    console.log('Request headers:', requestEvent.request.headers);
    console.log('Request cookies:', requestEvent.cookie);
    console.log('Request url:', requestEvent.url);
    console.log('Request params:', requestEvent.params);
    console.log('MY_ENV_VAR:', requestEvent.env.get('MY_ENV_VAR'));
  }
);
 

Action Failures

In order to return non-success values, the action must use the fail() method.

import { routeAction$ } from '@qwik.dev/router';
import { zod$, z } from '@qwik.dev/utils';
 
export const useAddUser = routeAction$(
  async (user, { fail }) => {
    // `user` is typed { name: string }
    const userID = await db.users.add(user);
    if (!userID) {
      return fail(500, {
        message: 'User could not be added',
      });
    }
    return {
      userID,
    };
  },
  zod$({
    name: z.string(),
  })
);

Failures are stored in the action.value property, just like the success value. However, the action.value.failed property is set to true when the action fails. Futhermore, failure messages can be found in the fieldErrors object according to properties defined in your Zod schema.

The fieldErrors become a dot notation object. See Complex forms for more information.

import { component$ } from '@qwik.dev/core';
import { Form } from '@qwik.dev/router';
 
export default component$(() => {
  const action = useAddUser();
  return (
    <Form action={action}>
      <input name="name" />
      <button type="submit">Add user</button>
      {action.value?.failed && <p>{action.value.fieldErrors.name}</p>}
      {action.value?.userID && <p>User added successfully</p>}
    </Form>
  );
});

Thanks to Typescript type discrimination, you can use the action.value.failed property to discriminate between success and failure.

Previous form state

When an action is triggered, the previous state is stored in the action.formData property. This is useful to display a loading state while the action is running.

import { component$ } from '@qwik.dev/core';
import { routeAction$, Form } from '@qwik.dev/router';
import { zod$, z } from '@qwik.dev/utils';
 
export const useAddUser = routeAction$(async (user) => {
  // handle action...
});
 
export default component$(() => {
  const action = useAddUser();
  return (
    <Form action={action}>
      <input name="name" value={action.formData?.get('name')} />
      <button type="submit">Add user</button>
    </Form>
  );
});

The action.formData is especially useful for retaining user-filled form data even after a page refresh. This enables a seamless SPA experience, even with JS disabled.

Route vs Global actions

Actions can be declared using the routeAction$() or globalAction$() exported from @qwik.dev/router, the only difference between the two is that routeAction$() is scoped to a route, while globalAction$() is globally available across the whole app.

It's recommended to start with routeAction$(). Use globalAction$() only when sharing an action across multiple routes, or if you wish to use the action in a component that is not a route.

routeAction$()

routeAction$() can only be declared inside the src/routes folder, in a layout.tsx or index.tsx file, and they MUST be exported, just like a routeLoader$(). Since routeAction$()s are only accessible within the route it's declared, they are recommended when the action needs to access some user data, or it's a protected route. Think about it like a "private" action.

NOTEIf you want to manage common reusable routeAction$() it is essential that this function is re-exported from within 'layout.tsx' or 'index.tsx file of the existing route otherwise it will not run or throw exception. For more information check this section.
src/routes/form/index.tsx
import { routeAction$ } from '@qwik.dev/router';
 
export const useChangePassword = routeAction$((data) => {
  // ...
});

globalAction$()

globalAction$() can be declared anywhere in the src folder. Since globalAction$() are globally available, they are recommended when the action needs to be shared across multiple routes, or when the action doesn't need to access any user data. For example, a useLogin action that logs in a user. Think about it like a "public" action.

src/components/login/login.tsx
import { globalAction$ } from '@qwik.dev/router';
 
export const useLogin = globalAction$((data) => {
  // ...
});

Redirecting after a POST

When JavaScript is disabled, a native form POST is stored in browser history. Reloading the page triggers a "Confirm Form Resubmission" warning and re-runs the action. To prevent this, use the Post/Redirect/Get (PRG) pattern: detect if the submission came from the browser (no-JS) and redirect to a GET request instead.

A native browser form submission always includes text/html in the Accept header. Use that to detect when to apply the redirect:

src/routes/index.tsx
import { component$ } from '@qwik.dev/core';
import { routeAction$, zod$, z } from '@qwik.dev/router';
 
export const useCreatePost = routeAction$(
  async (data) => {
    const postId = await db.posts.create(data);
    return { postId };
  },
  zod$({ title: z.string() })
);
 
export default component$(() => {
  const action = useCreatePost();
  return (
    // ⚠️ Native form POST — reloading the result page triggers "Confirm Form Resubmission"
    <form method="POST" action={action.actionPath}>
      <input name="title" />
      <button type="submit">Create post</button>
    </form>
  );
});

To fix this, detect the submission type using the Accept header and throw redirect() for browser submissions:

src/routes/index.tsx
import { component$ } from '@qwik.dev/core';
import { routeAction$, routeLoader$, Form, zod$, z } from '@qwik.dev/router';
 
export const useCreatePost = routeAction$(
  async (data, { request, redirect }) => {
    const postId = await db.posts.create(data);
 
    // Browser (no-JS) submissions include text/html in the Accept header.
    // Redirect to a GET so the user can safely reload the result page.
    if (request.headers.get('accept')?.includes('text/html')) {
      throw redirect(303, `/?created=${postId}`);
    }
 
    return { postId };
  },
  zod$({ title: z.string() })
);
 
// Read the search param set by the redirect on the destination page
export const useCreatedId = routeLoader$(({ url }) => {
  return { createdId: url.searchParams.get('created') };
});
 
export default component$(() => {
  const action = useCreatePost();
  const loader = useCreatedId();
 
  // JS path: action.value holds the result directly.
  // No-JS path: loader.value holds the param passed via the redirect.
  const createdId = action.value?.postId ?? loader.value.createdId;
 
  return (
    <Form action={action}>
      <input name="title" />
      <button type="submit">Create post</button>
      {createdId && <p>Post {createdId} created!</p>}
    </Form>
  );
});

In SPA mode, Qwik sends the action request with Accept: application/json, so the redirect in this example is not triggered and action.value holds the result. With JavaScript disabled or reloadDocument enabled, <Form/> performs a native form POST, so the PRG pattern prevents resubmission when the user reloads the result page.

NOTEYou can also pass state across a redirect using cookies instead of search params, which is useful for flash messages or session data. See the cookie section in the middleware docs for usage.

Contributors

Thanks to all the contributors who have helped make this documentation better!

  • manucorporat
  • cunzaizhuyi
  • forresst
  • keuller
  • hamatoyogi
  • AnthonyPAlicea
  • saisrikardumpeti
  • thejackshelton
  • adnanebrahimi
  • mhevery
  • ulic75
  • CoralWombat
  • tzdesign
  • igorbabko
  • gioboa
  • mrhoodz
  • VinuB-Dev
  • aivarsliepa
  • wtlin1228
  • adamdbradley
  • gioboa
  • jemsco
  • tzdesign
  • shairez
  • syrfmy