---
title: 'Astro Actions Tutorial: Type-Safe Forms in 2026'
description: 'Build a type-safe feedback form with Astro Actions and Zod: server-side validation, typed per-field errors, progressive enhancement, and server islands.'
url: https://nerdleveltech.com/astro-actions-type-safe-forms-tutorial
language: en
date: '2026-05-23'
category: frontend
tags:
  - astro
  - astro actions
  - forms
  - zod
  - typescript
  - progressive enhancement
  - server islands
  - frontend
markdown_url: https://nerdleveltech.com/astro-actions-type-safe-forms-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/astro-actions-type-safe-forms-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Astro Actions Tutorial: Type-Safe Forms in 2026

Astro Actions are type-safe server functions you define once and call from an HTML form, client JavaScript, or other server code. This tutorial builds a feedback form that validates input with Zod, returns typed per-field errors, and works even when JavaScript fails to load.

## TL;DR

This hands-on guide builds a working feedback form with Astro Actions from an empty project. You will scaffold an Astro 6.3.7 app, add the Node adapter, define a type-safe action whose input is validated by a Zod schema, and wire it to an HTML form that submits with zero client-side JavaScript. Then you will render per-field validation errors, redirect on success, reject bad input with a typed `ActionError`, progressively enhance the form with a typed client call, and finish by loading fresh submissions onto a static page with a server island. Every command and code block is copy-paste runnable and was verified by building and running the finished app on 23 May 2026.

## What you'll learn

- Scaffold an Astro project and add the Node adapter for on-demand rendering
- Keep submissions in a typed store you can later swap for a real database
- Define a type-safe action with `defineAction()` and a Zod schema
- Submit an HTML form to that action with zero client-side JavaScript
- Handle results: redirect on success and render per-field validation errors
- Reject bad input from the handler with a typed `ActionError`
- Progressively enhance the same form with a typed client-side call
- Display fresh submissions on a static page with a server island

## Why Astro Actions instead of an API route

An Astro Action is a backend function with a Zod-validated input and a type-safe client. Compared with hand-rolling an API endpoint, an action removes three classes of boilerplate: it parses the request body for you, it validates that body against a schema before your handler runs, and it generates a typed function so the client and server agree on the shape of every call.[^actions] Actions have been part of Astro since version 4.15 and are stable today — if a tutorial tells you to set an `experimental.actions` flag, it is out of date.[^actions]

The contrast is concrete. A traditional API route for this form would be a `POST` handler that calls `request.formData()`, pulls each field out by hand, coerces the rating string to a number, runs validation, branches on success or failure, and serializes a JSON response — most of it untyped, so a renamed field is a runtime bug. An action collapses that into a schema and a handler. The page imports the action and the form's `action` attribute points straight at it; call that same action from client or server code and a renamed field becomes a compile error instead of a runtime surprise. One definition covers three call sites — an HTML form, a client-side function, and other server code.[^actions]

## Prerequisites

- **Node.js 22.12.0 or newer.** Astro 6 sets `engines.node` to `>=22.12.0`.[^astro] Check with `node --version`.
- **A package manager** — this guide uses `npm`, which ships with Node.
- A terminal and a code editor. No database, no account, and no API key are required.

The finished app uses `astro@6.3.7` and `@astrojs/node@10.1.1`, the current published releases as of 23 May 2026.[^astro][^node] Pinning exact versions keeps the tutorial reproducible; `npm install` without a version pulls whatever is newest the day you run it.

## Step 1 — Scaffold the Astro project

Create a new project with the official scaffolding tool:[^install]

```bash
npm create astro@latest astro-actions-form
```

The wizard asks a few questions. Choose the **Empty** template (the `minimal` starter — it has no example pages to delete later), say **Yes** to installing dependencies, **Yes** to initializing a git repository, and when it asks about TypeScript choose **Strict**. When it finishes you have a working Astro app:

```bash
cd astro-actions-form
npm run dev
```

Open `http://localhost:4321` and you will see the starter page. Stop the dev server with `Ctrl+C` before the next step. To guarantee you are on the exact version this guide was tested against, pin Astro now:

```bash
npm install astro@6.3.7
```

## Step 2 — Add the Node adapter for on-demand rendering

Actions run on the server, and a page that submits to one through an HTML `<form>` must itself be **rendered on demand** rather than prerendered to static HTML. On-demand rendering needs a server adapter. For a self-hosted Node server, that is `@astrojs/node`.[^node] Add it with the built-in `astro add` command, which installs the package and edits your config in one step:

```bash
npx astro add node
```

Accept the prompt. The command installs `@astrojs/node` and adds the adapter to your `astro.config.mjs`. Open that file and make sure it matches the following — in particular, confirm the adapter is called with `mode: 'standalone'`, since `astro add` does not always write the `mode` option for you:

```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({ mode: 'standalone' }),
});
```

```bash
npm install @astrojs/node@10.1.1
```

`mode: 'standalone'` builds a server that starts itself — useful later when you run the production build.[^node] You do **not** need to set `output: 'server'`. Astro 6 keeps the default `output: 'static'`, which prerenders every page to HTML; you opt individual pages into on-demand rendering with a one-line export, which Step 5 does.[^ondemand] This way your marketing pages stay static and fast, and only the form page runs per request.

## Step 3 — Create a typed feedback store

Before the action has somewhere to write, give it a tiny store. To keep the tutorial self-contained, this is an in-memory array — it lives as long as the server process does. In production you would replace the three functions below with database calls; the rest of the app would not change.

Create `src/lib/store.ts`:

```ts
// src/lib/store.ts
export interface Feedback {
  id: string;
  name: string;
  email: string;
  rating: number;
  message: string;
  createdAt: Date;
}

// In-memory only. Swap for a real database in production.
const entries: Feedback[] = [];

export function addFeedback(data: Omit<Feedback, 'id' | 'createdAt'>): Feedback {
  const entry: Feedback = {
    id: crypto.randomUUID(),
    createdAt: new Date(),
    ...data,
  };
  entries.unshift(entry);
  return entry;
}

export function listFeedback(limit = 5): Feedback[] {
  return entries.slice(0, limit);
}

export function countFeedback(): number {
  return entries.length;
}
```

`crypto.randomUUID()` is a Node built-in, so there is nothing to install. `addFeedback` returns the created record, which the action will use to build a redirect URL.

## Step 4 — Define a type-safe action with Zod

All actions live in a `server` object exported from `src/actions/index.ts`.[^actions] Each action is created by `defineAction()` and has three parts: an `accept` mode, an `input` schema, and a `handler`.

Create `src/actions/index.ts`:

```ts
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro/zod';
import { addFeedback } from '../lib/store';

export const server = {
  submitFeedback: defineAction({
    accept: 'form',
    input: z.object({
      name: z.string().min(2, 'Name must be at least 2 characters.'),
      email: z.email('Enter a valid email address.'),
      rating: z
        .number('Choose a rating.')
        .min(1, 'Rating must be between 1 and 5.')
        .max(5, 'Rating must be between 1 and 5.'),
      message: z
        .string()
        .min(10, 'Message must be at least 10 characters.')
        .max(500, 'Message must be 500 characters or fewer.'),
    }),
    handler: async ({ name, email, rating, message }) => {
      const entry = addFeedback({ name, email, rating, message });
      return { id: entry.id, name: entry.name };
    },
  }),
};
```

Three details matter here. `accept: 'form'` tells Astro to parse an HTML form submission: it reads each `<input>`'s `name` attribute and builds an object before validation.[^form] `import { z } from 'astro/zod'` uses the Zod build that ships inside Astro 6 — Astro bundles Zod 4, so the modern top-level validators like `z.email()` are available without a separate install.[^astro][^zod] And the `handler` receives a fully typed, already-validated `input`: by the time your code runs, `rating` is a `number` between 1 and 5, not a raw form string. If validation fails, Astro returns a `BAD_REQUEST` error and the handler never runs.[^form]

Astro applies some conversions for form fields automatically. An `<input type="number">` is validated with `z.number()` even though form values arrive as strings; a checkbox is validated with `z.coerce.boolean()`; a file input with `z.instanceof(File)`; and an empty text field arrives as `null` rather than an empty string.[^form] That `null` behavior is why `name` uses `z.string().min(2)` — an empty submission fails validation cleanly instead of slipping through as `""`.

`accept` has two modes. The default, `'json'`, is for actions you call only from JavaScript with a plain object argument. `'form'` — used here — makes the action parse `FormData`, which is what both an HTML form submission and a client-side `FormData` call send. Choose `'form'` whenever a real `<form>` element is involved, because that is the mode that survives a browser with no JavaScript. If you omit the `input` schema entirely, the handler receives the raw `FormData` object and you validate it yourself; supplying a schema is what buys you the typed, pre-validated `input`.[^form]

## Step 5 — Build the form and submit it with zero JavaScript

Now wire an HTML form to the action. Create `src/pages/feedback.astro`:

```astro
---
// src/pages/feedback.astro
export const prerender = false;

import { actions } from 'astro:actions';
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Leave feedback</title>
  </head>
  <body>
    <h1>Leave feedback</h1>
    <form method="POST" action={actions.submitFeedback}>
      <p><label>Name <input type="text" name="name" required /></label></p>
      <p><label>Email <input type="email" name="email" required /></label></p>
      <p>
        <label>Rating
          <input type="number" name="rating" min="1" max="5" required />
        </label>
      </p>
      <p>
        <label>Message
          <textarea name="message" required></textarea>
        </label>
      </p>
      <button type="submit">Send feedback</button>
    </form>
  </body>
</html>
```

Two lines do the heavy lifting. `export const prerender = false` opts this page into on-demand rendering, which the form-action API requires.[^ondemand] Setting `action={actions.submitFeedback}` on a `method="POST"` form tells Astro to route the submission to your action — it renders as `action="?_action=submitFeedback"`, a query string the server handles for you.[^form] No `fetch()`, no `onsubmit`, no client JavaScript at all.

Run `npm run dev` and submit the form at `http://localhost:4321/feedback`. The action runs and stores the entry — but the page just reloads and the user sees nothing. A form that gives no feedback is not finished, which is what the next step fixes.

## Step 6 — Handle results: redirect on success, render field errors

Calling `Astro.getActionResult()` on the server returns the outcome of a form submission: an object with `data` or `error`, or `undefined` if the action was not called on this request.[^getresult] Use it to do two things — redirect after a successful submission, and render validation errors when the input was bad.

Redirecting after a successful POST is the **POST/Redirect/GET** pattern. It stops the browser's "confirm form resubmission?" dialog on refresh and gives the user a clean URL.[^form] For validation failures, the `isInputError()` helper narrows an error to an input error and exposes a `fields` object keyed by input name, with an array of messages for each field that failed.[^errors]

Replace `src/pages/feedback.astro` with the complete version:

```astro
---
// src/pages/feedback.astro
export const prerender = false;

import { actions, isInputError } from 'astro:actions';

const result = Astro.getActionResult(actions.submitFeedback);

// POST / Redirect / GET: leave the POST response on success.
if (result && !result.error) {
  return Astro.redirect('/feedback?success=' + encodeURIComponent(result.data.name));
}

const fieldErrors = isInputError(result?.error) ? result.error.fields : {};
const formError =
  result?.error && !isInputError(result.error) ? result.error.message : null;
const successName = Astro.url.searchParams.get('success');
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Leave feedback</title>
  </head>
  <body>
    <h1>Leave feedback</h1>

    {successName && <p class="success">Thanks, {successName}! Your feedback was recorded.</p>}
    {formError && <p class="error">{formError}</p>}

    <form method="POST" action={actions.submitFeedback}>
      <p>
        <label>Name <input type="text" name="name" required /></label>
        {fieldErrors.name && <span class="error">{fieldErrors.name.join(' ')}</span>}
      </p>
      <p>
        <label>Email <input type="email" name="email" required /></label>
        {fieldErrors.email && <span class="error">{fieldErrors.email.join(' ')}</span>}
      </p>
      <p>
        <label>Rating
          <input type="number" name="rating" min="1" max="5" required />
        </label>
        {fieldErrors.rating && <span class="error">{fieldErrors.rating.join(' ')}</span>}
      </p>
      <p>
        <label>Message
          <textarea name="message" required></textarea>
        </label>
        {fieldErrors.message && <span class="error">{fieldErrors.message.join(' ')}</span>}
      </p>
      <button type="submit">Send feedback</button>
    </form>
  </body>
</html>
```

Every action call — whether from a form, from client JavaScript, or from server code — resolves to the same object: `data` holds the typed return value of your handler, or `error` holds whatever went wrong, and exactly one of the two is present.[^actions] Validation failures and thrown errors both land on `error`, which is why a single `if (result && !result.error)` check is enough to gate the redirect. If you would rather not branch, appending `.orThrow()` to an action call throws on failure and returns `data` directly — handy inside a `try/catch` or while prototyping.[^actions]

Submit valid input and the browser lands on `/feedback?success=Your%20Name` with a thank-you message. Submit a bad email or a one-word message and the form comes back with a message under each offending field — and the handler from Step 4 never ran, because `BAD_REQUEST` was returned before it.[^form] The `required` and `type="email"` attributes on the inputs still give instant in-browser checks; the action is the authoritative server-side validation that a user cannot bypass.

The `formError` line catches a different category of failure, which the next step produces deliberately.

## Step 7 — Reject bad input from the handler with ActionError

Zod rejects input that has the wrong *shape* — a malformed email, a rating outside 1–5. But some failures only surface once the handler runs: a record is missing, a user is not authorized, a submission breaks a rule. For those, throw an `ActionError` from inside the handler. An `ActionError` carries a status `code` such as `BAD_REQUEST`, `UNAUTHORIZED`, or `NOT_FOUND`, plus an optional human-readable `message`, and it arrives on the result's `error` property like any other failure.[^errors]

Suppose feedback may not contain links. Update the action's import and handler in `src/actions/index.ts`:

```ts
// src/actions/index.ts
import { defineAction, ActionError } from 'astro:actions';
import { z } from 'astro/zod';
import { addFeedback } from '../lib/store';

export const server = {
  submitFeedback: defineAction({
    accept: 'form',
    input: z.object({
      name: z.string().min(2, 'Name must be at least 2 characters.'),
      email: z.email('Enter a valid email address.'),
      rating: z
        .number('Choose a rating.')
        .min(1, 'Rating must be between 1 and 5.')
        .max(5, 'Rating must be between 1 and 5.'),
      message: z
        .string()
        .min(10, 'Message must be at least 10 characters.')
        .max(500, 'Message must be 500 characters or fewer.'),
    }),
    handler: async ({ name, email, rating, message }) => {
      if (message.toLowerCase().includes('http://') || message.toLowerCase().includes('https://')) {
        throw new ActionError({
          code: 'BAD_REQUEST',
          message: 'Links are not allowed in feedback.',
        });
      }
      const entry = addFeedback({ name, email, rating, message });
      return { id: entry.id, name: entry.name };
    },
  }),
};
```

You did not touch `feedback.astro`. The `formError` line you wrote in Step 6 already handles this: `isInputError()` is `false` for an `ActionError` you threw yourself, so the error falls through to `formError` and renders in the banner above the form. Submit a message containing a link and you will see "Links are not allowed in feedback." A Zod failure surfaces per field; a thrown `ActionError` surfaces in the banner — two paths, one result object.[^errors]

## Step 8 — Progressively enhance the form with JavaScript

The form already works with no JavaScript. That is the baseline you never want to lose. *Progressive enhancement* means layering a nicer experience on top without removing that baseline — if the script fails to load, the plain form still submits.

Import `actions` in a `<script>` and call the action directly. Astro generates a typed client function for every action, so `actions.submitFeedback(formData)` is a real RPC call — no `fetch()`, no URL strings, no manual JSON.[^actions] Add this block just before `</body>` in `src/pages/feedback.astro`:

```astro
<script>
  import { actions } from 'astro:actions';

  const form = document.querySelector('form');
  form?.addEventListener('submit', async (event) => {
    event.preventDefault();
    const { data, error } = await actions.submitFeedback(new FormData(form));
    if (error) {
      // Hand off to the normal server round-trip, which re-renders field errors.
      form.submit();
      return;
    }
    const note = document.createElement('p');
    note.className = 'success';
    note.textContent = `Thanks, ${data.name}! Your feedback was recorded.`;
    form.replaceWith(note);
  });
</script>
```

`event.preventDefault()` stops the native form submission so the script can take over. The action is called with a `FormData` object built from the form; on success it returns the typed `data` from your handler, and the script swaps the form for an inline thank-you message with no navigation at all. On a validation or `ActionError` failure, `form.submit()` hands control back to the standard server round-trip from Step 6, so the field-error rendering still works through a single code path.[^form] Because every action is also exposed at a public RPC endpoint — `/_actions/submitFeedback` for this one — the client call and the form submission run the exact same validated handler.[^actions]

Because the action is imported rather than addressed by a URL, the client call is fully typed: your editor knows `submitFeedback` takes the four fields and that a successful `data` is `{ id, name }`. There is no endpoint string to keep in sync and no response shape to hand-write — rename a field in the schema and the call site stops type-checking. To prove the progressive-enhancement claim, open your browser's developer tools, disable JavaScript, and submit the form again. It still works, because the `<form action={actions.submitFeedback}>` markup never depended on the script. The script is pure enhancement: when it loads, a successful submission updates the page in place instead of reloading; when it does not, the server round-trip from Step 6 takes over and nothing breaks.

## Step 9 — Show fresh submissions with a server island

Your homepage can stay static and instant while still showing live data, using a **server island**. Marking a component with `server:defer` tells Astro to render the page immediately, send fallback content in the component's place, then fetch the component's real HTML separately and swap it in.[^islands] Server islands need an adapter — you already installed one in Step 2.

This is a different trade-off from a client-rendered component. A client component ships its JavaScript and renders in the browser; a server island renders on the server and ships only a small loader script in its place, so it can read your store or a database directly without exposing an API or sending its logic to the browser. Reach for `server:defer` when one slice of an otherwise-cacheable page needs fresh or personalized server data — a recent-activity list, a logged-in user's avatar, a per-request price.

Create the island component, `src/components/RecentFeedback.astro`:

```astro
---
// src/components/RecentFeedback.astro
import { listFeedback, countFeedback } from '../lib/store';

const recent = listFeedback(5);
const total = countFeedback();
---
<section>
  <h2>Recent feedback ({total})</h2>
  {recent.length === 0 ? (
    <p>No feedback yet. Be the first to leave some.</p>
  ) : (
    <ul>
      {recent.map((item) => (
        <li>
          <strong>{item.name}</strong> rated us {item.rating}/5
          <p>{item.message}</p>
        </li>
      ))}
    </ul>
  )}
</section>
```

Then use it on the homepage. Replace `src/pages/index.astro`:

```astro
---
// src/pages/index.astro
import RecentFeedback from '../components/RecentFeedback.astro';
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Feedback demo</title>
  </head>
  <body>
    <h1>Astro Actions feedback demo</h1>
    <p><a href="/feedback">Leave feedback</a></p>

    <RecentFeedback server:defer>
      <p slot="fallback">Loading recent feedback…</p>
    </RecentFeedback>
  </body>
</html>
```

There is no `prerender = false` on this page — the homepage is still a static document. Only the `RecentFeedback` island runs per request, so a visitor sees the page and the "Loading recent feedback…" placeholder instantly, and the real list arrives a moment later.[^islands] The child marked `slot="fallback"` is what shows while the island loads. Submit feedback, return to `http://localhost:4321/`, and your entry appears in the list — fresh data on a cached page.

Two constraints are worth knowing before you lean on islands. First, a server island renders in an isolated context: it is fetched as its own request, so `Astro.url` inside the island is the island's endpoint, not the page the visitor is on. If the island needs the page URL — for query parameters, say — read it from the `Referer` request header.[^islands] Second, any props you pass to a `server:defer` component must be serializable (plain objects, numbers, strings, arrays, `Date`, and similar); functions cannot cross that boundary.[^islands] Here the island takes no props and reads the store directly, so neither constraint bites — but they will the moment you pass data in.

## Verification

Confirm the whole flow end to end. With `npm run dev` running:

1. Visit `http://localhost:4321/feedback` and submit valid input. You land on `/feedback?success=Your%20Name` with a thank-you message.
2. Submit a bad email and a short message. The form returns with a message under each bad field; nothing is stored.
3. Submit a message containing `https://example.com`. The "Links are not allowed in feedback." banner appears.
4. Visit `http://localhost:4321/` — your valid submission shows in the server island.

You can also test the form endpoint from the command line. Astro's built-in CSRF protection requires a matching `Origin` header on form submissions, so pass one explicitly:

```bash
curl -s -i -X POST 'http://localhost:4321/feedback?_action=submitFeedback' \
  -H 'Origin: http://localhost:4321' \
  --data 'name=Jordan+Lee&email=jordan%40example.com&rating=5&message=Great+feedback+widget.'
```

A valid request responds with `HTTP/1.1 302 Found` and a `location:` header pointing at the success URL — the POST/Redirect/GET pattern in action.

For a production build, run `npm run build` and start the standalone server:

```bash
npm run build
node ./dist/server/entry.mjs
```

The build prints `output: "static"` with `mode: "server"` — the homepage is prerendered to a static file while `/feedback` is compiled into the on-demand server bundle, exactly the split you set up in Steps 2 and 5. In `standalone` mode the adapter emits `dist/server/entry.mjs`, a server that starts itself and also serves your static assets. It boots on `http://localhost:4321`; override the binding with the `HOST` and `PORT` environment variables if needed.[^node]

## Troubleshooting

**`403 Cross-site POST form submissions are forbidden`.** This is Astro's CSRF protection (`security.checkOrigin`, enabled by default), which rejects form-style POSTs whose `Origin` header does not match the site's origin.[^csrf] Real browsers send a matching `Origin` automatically, so this only bites command-line testing or a misconfigured reverse proxy. With `curl`, add `-H 'Origin: http://localhost:4321'` — and note the standalone Node adapter reports its origin as `localhost`, so use `localhost` rather than `127.0.0.1` in both the URL and the header.

**The handler never runs and the page just reloads.** You are missing `export const prerender = false`. Without it the page is prerendered to static HTML and the action cannot run.[^ondemand]

**`getActionResult` always returns `undefined`.** It only returns a value on the request that *called* the action — that is, the POST. On a normal GET it is `undefined` by design.[^getresult] Read submitted data from the result during the POST, then redirect.

**`415 Unsupported Media Type` when calling the action.** Your action is `accept: 'form'` but the request sent JSON. Form-mode actions take form-encoded or `multipart/form-data` bodies; submit a `FormData` object from the client (as Step 8 does), not a JSON object.[^form]

**Field errors never appear.** Each `<input>` must have a `name` attribute that matches a key in the Zod schema. Astro builds the validated object from those `name` attributes, so a typo means the value arrives as `undefined`.[^form]

**Your editor flags `Cannot find module 'astro:actions'`.** `astro:actions` is a virtual module generated when Astro syncs your project. Running `npm run dev` regenerates it; if you only see the error in your editor, run `npx astro sync` once to refresh the generated types in the `.astro/` directory.

## Before you ship to production

The app is correct, but three things need attention before real traffic reaches it.

The in-memory store is per-process. It is wiped on every restart and is not shared between multiple server instances, so a load-balanced deployment would show different lists on different requests. This is the deliberate trade-off that keeps the tutorial dependency-free; replace `src/lib/store.ts` with a database before you ship.

Every action is a public endpoint. The handler is reachable at `/_actions/submitFeedback` by anyone who knows the name, exactly like a REST route — Astro's docs are explicit that you must apply the same authorization and rate-limiting you would give an API endpoint.[^actions] For a public feedback form that means input limits and abuse protection in the handler; for anything user-specific it means an auth check that throws `ActionError` with an `UNAUTHORIZED` code.

Server islands encrypt the props passed to them with a key that is regenerated on every build. That is fine for a single server, but on rolling deployments, multi-region hosting, or a CDN that caches island-bearing pages, the encrypting build and the decrypting build can drift apart. Generate a stable key once with `astro create-key`, set it as the `ASTRO_KEY` environment variable in your build and runtime, and the key stays consistent across deploys.[^islands]

## Next steps

You now have a type-safe form with server validation, progressive enhancement, and a live server island — a pattern that scales from a contact form to a full dashboard. From here:

- Swap the in-memory store for a real database. The three functions in `src/lib/store.ts` are the only code that changes; the action and pages stay the same.
- Add authentication so an action can reject unauthorized users. The same `ActionError` mechanism with an `UNAUTHORIZED` code drives it.
- Compare the model with React's take in [Server Actions and optimistic UI in Next.js 16](https://nerdleveltech.com/nextjs-16-server-actions-react-19-optimistic-ui-tutorial), and with a no-framework approach in [building a live Kanban board with htmx and Express](https://nerdleveltech.com/htmx-typescript-express-live-kanban-tutorial).
- Server islands are close cousins of streaming UI — see [streaming and Suspense in Next.js 16](https://nerdleveltech.com/nextjs-16-streaming-suspense-use-cache-tutorial) for the React equivalent.

The core lesson holds across frameworks: validate on the server, make the no-JavaScript path work first, and enhance from there.

## Related reads

- [tRPC v11 End-to-End Type Safety: Server + Client (2026)](https://nerdleveltech.com/trpc-v11-end-to-end-type-safety-server-client-tutorial)

[^astro]: `astro` npm package — version `6.3.7` (`latest` dist-tag, published 2026-05-21) and `engines.node: ">=22.12.0"`, verified via `npm view astro` on 2026-05-23. https://www.npmjs.com/package/astro
[^node]: `@astrojs/node` npm package — version `10.1.1` (`latest` dist-tag, published 2026-05-13), and the `@astrojs/node` integration guide covering `astro add node`, `mode: 'standalone'`, the `dist/server/entry.mjs` entrypoint, and `HOST`/`PORT` overrides. https://docs.astro.build/en/guides/integrations-guide/node/
[^actions]: "Actions," Astro Docs — Actions added in `astro@4.15`; `defineAction()`, the `server` object in `src/actions/index.ts`, typed client calls, and the `/_actions/{name}` public endpoint. https://docs.astro.build/en/guides/actions/
[^form]: "Actions — Accepting form data" and "Call actions from an HTML form action," Astro Docs — `accept: 'form'`, form parsing by input `name`, the `?_action=` query string, the on-demand-rendering requirement, and the POST/Redirect/GET pattern. https://docs.astro.build/en/guides/actions/
[^errors]: "Actions — Handling backend errors" and "Displaying form input errors," Astro Docs — the `ActionError` class with status `code` and `message`, and the `isInputError()` guard exposing `error.fields`. https://docs.astro.build/en/reference/modules/astro-actions/
[^getresult]: "Render context — getActionResult," Astro Docs — `Astro.getActionResult()` returns `data`/`error` on the request that called the action, or `undefined` otherwise. https://docs.astro.build/en/reference/api-reference/
[^ondemand]: "On-demand rendering," Astro Docs — the default `output: 'static'` model and enabling per-page on-demand rendering with `export const prerender = false`. https://docs.astro.build/en/guides/on-demand-rendering/
[^islands]: "Server islands," Astro Docs — the `server:defer` directive, the `slot="fallback"` placeholder, the adapter requirement, and how deferred islands are fetched after page load. https://docs.astro.build/en/guides/server-islands/
[^csrf]: "Configuration Reference — security.checkOrigin," Astro Docs — cross-site form-submission origin checking, enabled by default for on-demand-rendered pages. https://docs.astro.build/en/reference/configuration-reference/
[^zod]: "astro/zod," Astro Docs — Astro re-exports its bundled Zod (Zod 4 in Astro 6) from the `astro/zod` module for use in action input schemas. https://docs.astro.build/en/reference/modules/astro-zod/
[^install]: "Install Astro," Astro Docs — the `npm create astro@latest` scaffolding command, its wizard prompts, and the starter template options. https://docs.astro.build/en/install-and-setup/
