Forms are a common part of web applications. Users rely on them to register accounts, sign in, submit feedback, update profiles, and complete purchases. A form must validate user input, display useful error messages, and process valid data safely.
Many applications use JavaScript to handle form submission and validation. This can provide a responsive user experience, but it should not be the only way a form works. JavaScript may be disabled, fail to load, or encounter a runtime error. A form that depends entirely on client-side code can become unusable in these situations.
SvelteKit provides form actions that allow developers to process form submissions on the server using standard HTML forms. With progressive enhancement, the same form can work without JavaScript and become more interactive when JavaScript is available.
This article explains how to build a SvelteKit form with server-side validation, return validation errors, preserve user input, and progressively enhance the experience without making JavaScript a requirement.
What Are SvelteKit Form Actions?
Form actions are server-side functions defined in a route's +page.server.ts file. They receive submitted form data, validate it, and return a result to the page.
A standard HTML form sends its data to the server when the user submits it. SvelteKit processes the request through the corresponding form action.
This approach provides several benefits:
- Form submission works without client-side JavaScript.
- Validation runs on the server.
- Sensitive business rules remain outside the browser.
- Validation errors can be returned to the page.
- Progressive enhancement can improve the experience when JavaScript is available.
For example, a registration form might require a name, a valid email address, and a password of sufficient length. The browser can provide immediate feedback, but the server must validate these fields before accepting the submission.
Client-side validation improves usability. Server-side validation enforces the rules.
Create a Basic Form in SvelteKit
Consider a simple contact form that accepts a user's name, email address, and message.
A typical route structure looks like this:
src/
└── routes/
└── contact/
├── +page.svelte
└── +page.server.ts
The +page.svelte file contains the form interface, while +page.server.ts defines the server-side action.
Define the Server Action
Create the following file:
src/routes/contact/+page.server.ts
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const message = String(formData.get("message") ?? "").trim();
const errors: Record<string, string> = {};
if (!name) {
errors.name = "Name is required.";
}
if (!email) {
errors.email = "Email address is required.";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = "Enter a valid email address.";
}
if (!message) {
errors.message = "Message is required.";
}
if (Object.keys(errors).length > 0) {
return fail(400, {
errors,
values: { name, email, message }
});
}
// Save the validated data or pass it to a service.
// Do not treat validation alone as authorization.
return {
success: true,
message: "Your message has been submitted."
};
}
};
The action reads the submitted fields through request.formData(). It then validates the values and collects any errors.
The fail() function returns a failure result with an HTTP status code and data that the page can use to display validation errors.
The example uses a basic email pattern to demonstrate the validation flow. Real email validation is more complicated, so production applications should use an appropriate validation library or a well-defined validation policy.
Also, validation does not replace authorization, rate limiting, or spam protection. Those controls must be implemented separately when required.
Build the Form Interface
Now create src/routes/contact/+page.svelte:
<script lang="ts">
import type { ActionData } from "./$types";
let { form }: { form: ActionData } = $props();
</script>
<h2>Contact Us</h2>
{#if form?.success}
<p role="status">{form.message}</p>
{/if}
<form method="POST">
<div>
<label for="name">Name</label>
<input
id="name"
name="name"
value={form?.values?.name ?? ""}
required
/>
{#if form?.errors?.name}
<p class="error">{form.errors.name}</p>
{/if}
</div>
<div>
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
value={form?.values?.email ?? ""}
required
/>
{#if form?.errors?.email}
<p class="error">{form.errors.email}</p>
{/if}
</div>
<div>
<label for="message">Message</label>
<textarea
id="message"
name="message"
required
>{form?.values?.message ?? ""}</textarea>
{#if form?.errors?.message}
<p class="error">{form.errors.message}</p>
{/if}
</div>
<button type="submit">Send Message</button>
</form>
<style>
form {
display: grid;
gap: 1rem;
max-width: 32rem;
}
form div {
display: grid;
gap: 0.4rem;
}
input,
textarea {
padding: 0.65rem;
font: inherit;
}
.error {
color: #b42318;
margin: 0;
}
</style>
This component uses Svelte 5's $props() syntax to receive the action result through the form prop. If your application uses an earlier Svelte version, the component's prop syntax will need to be adjusted.
The form uses method="POST" and has no JavaScript submission handler. When the user submits it, SvelteKit sends the form data to the default action.
If validation fails, the returned values repopulate the fields, and the errors appear beside the corresponding inputs. If the submission succeeds, the page displays a confirmation message.
Preserving the submitted values prevents users from having to re-enter every field after a validation error.
How Server-Side Validation Works
Server-side validation should happen before data is stored or passed to downstream services.
The example follows a straightforward sequence:
- Read the submitted form data.
- Normalize values where appropriate, such as trimming unnecessary whitespace.
- Validate each required field.
- Return a failure result if any validation rule fails.
- Process valid data using the application's service or database layer.
- Return a success result that the page can display.
The server must not trust values merely because an HTML input has the required attribute. Users can bypass browser validation by constructing requests directly.
For example, a user can submit an empty name even if the form contains:
<input name="name" required />
The browser normally prevents submission when the field is empty, but the server must still reject the request.
Validate Field Lengths and Input Types
Required fields are only the beginning. A production form should enforce reasonable limits.
For example, a message might require at least 10 characters and allow no more than 2,000 characters.
if (message.length < 10) {
errors.message = "Message must contain at least 10 characters.";
} else if (message.length > 2000) {
errors.message = "Message cannot exceed 2,000 characters.";
}
Apply limits that match the application's actual requirements. Avoid arbitrary restrictions that prevent legitimate input.
For structured input, such as phone numbers, dates, and identifiers, use appropriate validation rules. When data is used in database queries, use parameterized queries or the database library's safe query mechanisms rather than building SQL strings through concatenation.
If the application renders submitted text as HTML, escape it or use a safe rendering mechanism to prevent cross-site scripting vulnerabilities.
Add Multiple Form Actions
A page may contain more than one form. For example, an account settings page could let users update their profile and change their password.
SvelteKit supports named form actions, allowing different forms to invoke different server-side functions.
In +page.server.ts:
import { fail } from "@sveltejs/kit";
import type { Actions } from "./$types";
export const actions: Actions = {
updateProfile: async ({ request }) => {
const formData = await request.formData();
const displayName = String(
formData.get("displayName") ?? ""
).trim();
if (!displayName) {
return fail(400, {
profileError: "Display name is required."
});
}
// Update the authenticated user's profile.
return {
profileSuccess: "Profile updated."
};
},
changePassword: async ({ request }) => {
const formData = await request.formData();
const currentPassword = String(
formData.get("currentPassword") ?? ""
);
const newPassword = String(
formData.get("newPassword") ?? ""
);
if (!currentPassword || newPassword.length < 12) {
return fail(400, {
passwordError:
"Enter your current password and a new password of at least 12 characters."
});
}
// Verify the current password, then securely update it.
return {
passwordSuccess: "Password updated."
};
}
};
The corresponding forms can target their named actions:
<form method="POST" action="?/updateProfile">
<label for="displayName">Display name</label>
<input id="displayName" name="displayName" required />
<button type="submit">Update Profile</button>
</form>
<form method="POST" action="?/changePassword">
<label for="currentPassword">Current password</label>
<input
id="currentPassword"
name="currentPassword"
type="password"
required
/>
<label for="newPassword">New password</label>
<input
id="newPassword"
name="newPassword"
type="password"
minlength="12"
required
/>
<button type="submit">Change Password</button>
</form>
These examples illustrate action routing and basic validation. The password action is not a complete password-change implementation: a real application must authenticate the user, verify the current password using its password-verification mechanism, apply its password policy, and securely update the stored password.
For named actions, remember that SvelteKit's page form result represents the most recent action result. If several forms share a page, structure the returned data and displayed messages so that errors from one action are not mistakenly shown beside another form.
Improve the Experience With Progressive Enhancement
A standard SvelteKit form works without JavaScript. However, a full-page form submission may feel less responsive than an enhanced form.
SvelteKit provides use:enhance for progressively enhancing forms that submit to page actions.
Add the following import to the page component:
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData } from "./$types";
let { form }: { form: ActionData } = $props();
</script>
Then update the form:
<form method="POST" use:enhance>
<!-- Keep the existing form fields here. -->
<button type="submit">Send Message</button>
</form>
The form still submits normally when JavaScript is unavailable. When JavaScript is active, SvelteKit enhances the submission by using a client-side request and updating the page with the action result.
By default, use:enhance handles common action responses, including updating the form result and applying the usual page invalidation behavior. You can provide a custom callback when you need to control the submission lifecycle, show a loading state, or customize the result handling.
Do not make essential validation or data processing dependent on use:enhance. Progressive enhancement should improve the experience, not change the fundamental correctness of the form.
Return Validation Errors From a Schema
As forms become more complex, manually validating every field can make server actions difficult to maintain.
A schema-validation library can centralize the rules and return structured errors. The following example uses Zod:
import { fail } from "@sveltejs/kit";
import { z } from "zod";
import type { Actions } from "./$types";
const contactSchema = z.object({
name: z.string().trim().min(1, "Name is required."),
email: z.string().trim().email("Enter a valid email address."),
message: z
.string()
.trim()
.min(10, "Message must contain at least 10 characters.")
.max(2000, "Message cannot exceed 2,000 characters.")
});
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const result = contactSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message")
});
if (!result.success) {
return fail(400, {
errors: result.error.flatten().fieldErrors,
values: {
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? ""),
message: String(formData.get("message") ?? "")
}
});
}
const contact = result.data;
// Persist the validated contact data.
return {
success: true,
message: "Your message has been submitted."
};
}
};
Install Zod in the project before using this example. The schema validates the submitted values and produces field-level errors that can be displayed by the page.
Schema validation is particularly useful when several actions share the same rules. It also makes validation easier to test independently.
However, validation libraries do not automatically provide authentication, authorization, database transaction handling, or protection against abuse. Those concerns still belong in the application's server-side design.
Common Mistakes to Avoid
Relying Only on Browser Validation
Attributes such as required, minlength, and type="email" improve usability but can be bypassed.
Always validate untrusted input on the server before processing it.
Forgetting to Return Submitted Values
When a form fails validation, clearing every field forces users to repeat work.
Return appropriate values with the failure result and use them to repopulate safe fields. Avoid returning passwords, authentication tokens, or other sensitive values to the page.
Returning an Error Without Displaying It
A server action can correctly reject invalid input while leaving users unsure what went wrong.
Display field-level errors close to the relevant controls. Use an accessible status or error summary when appropriate, and ensure that error messages can be identified by assistive technologies.
Treating Validation as Authorization
A valid email address or password does not prove that a user has permission to perform an operation.
Check the user's identity and permissions before modifying protected resources. For example, a profile update should modify the authenticated user's account rather than trusting a user ID submitted by the browser.
Making the Form Depend on Client-Side JavaScript
A custom JavaScript submit handler may improve the interface, but it should not be the only path to the server action when no-JavaScript support is a requirement.
Start with a standard HTML form and add use:enhance when needed.
Advantages and Limitations
| Aspect | Benefit | Consideration |
|---|
| Server-side actions | Centralize validation and data processing | Server requests still need appropriate security controls |
| Standard HTML forms | Work without client-side JavaScript | Submission may involve a full-page navigation |
| Progressive enhancement | Improves the experience when JavaScript is available | Custom enhancement logic needs additional testing |
| Schema validation | Makes complex rules easier to maintain | Adds a dependency and requires deliberate schema design |
| Returned form data | Supports clear error messages and preserved input | Sensitive values must not be returned unnecessarily |
Best Practices for Production Applications
- Keep validation and business rules on the server.
- Use schema validation for complex forms with multiple fields.
- Preserve safe input values after validation failures.
- Never return passwords or secrets in action results.
- Check authentication and authorization before protected operations.
- Use parameterized database queries and safe output rendering.
- Add request-size limits, rate limiting, and abuse prevention where appropriate.
- Use progressive enhancement without making JavaScript essential.
- Test successful submissions, invalid input, missing fields, and server errors.
- Make error messages accessible and specific enough to help users correct their input.
Summary
SvelteKit form actions provide a server-side approach to processing HTML forms without requiring client-side JavaScript. They allow developers to validate submitted data, return structured errors, preserve safe input values, and process valid submissions through server-side application logic.
For simple forms, a standard POST form and a default action may be sufficient. For larger forms, named actions and schema-validation libraries can improve organization and consistency. The use:enhance directive can then add a more responsive experience without removing the form's no-JavaScript fallback.
The most reliable approach is to build a form that works with standard HTML first, enforce validation and authorization on the server, and add client-side enhancements only when they provide a clear benefit.
SvelteKitform actionsserver-side validationprogressive enhancementHTML formsTypeScript
Join the conversation! Your thoughts help the community grow.