Next.js, SolidStart, TanStack Start, and SvelteKit all offer some form of server or remote functions. The integrations differ, but being able to connect server code to forms and data fetching is appealing.
I've used Nuxt since its early releases, and I like Nuxt 5's work on typed HTTP routes. Seeing those approaches develop made me wonder:
How much of that DX can we get while keeping an explicit HTTP endpoint as the starting point?
I'm trying this in Nuxt Endpoints. Routes keep their files, URLs, and HTTP methods. NE's defineRouteHandler() builds on the validated-routing design being discussed in H3 RFC #1437: request and response schemas alongside the handler, with a contract that other tools can read. NE implements and extends that design to connect it to client behavior, including pagination and forms. The upstream design is still under discussion; these examples use NE's implementation.
The idea is that a server contract should do more than type the response. It should constrain the handler and let the client use features that depend on that contract. A pagination adapter, for instance, should require a route that declares pagination.
Pagination: what the declaration buys you
Cursor pagination is a small example of the idea:
// server/api/articles.get.ts
import { z } from 'zod'
import { defineRouteHandler } from 'nuxt-endpoints/runtime'
import { listArticles } from '../utils/articles' // your database code
const Article = z.object({
id: z.number(),
title: z.string(),
})
export default defineRouteHandler({
pagination: { kind: 'cursor', item: Article },
handler: (event) => listArticles(event.validated.query),
})
This defines GET /api/articles. The database query and cursor encoding are your code. NE supplies the rest of the pagination contract:
- the request has validated
cursorandlimitquery fields; - the
200response is{ items: Article[], nextCursor?: string }; - the handler's successful return must match that shape;
- the generated OpenAPI document exposes those fields;
- the client can use an adapter that requires this contract.
Returning { nextCursor } without items, for example, is a server-side type error. This page format is NE's convention, not an HTTP standard. The contract checks the shape; it cannot prove that your database query returns the right next page.
The client can pass the same typed request to Pinia Colada:
import { useInfiniteQuery } from '@pinia/colada'
import { infiniteQueryOptions } from '#endpoints/colada'
const articles = useInfiniteQuery(
infiniteQueryOptions(
$endpoint('/api/articles', { method: 'get' }),
),
)
infiniteQueryOptions() rejects an endpoint without the cursor-pagination contract at compile time. Nuxt Endpoints connects nextCursor to the next HTTP request; Pinia Colada owns the page cache, reactive state, and refetch lifecycle.
Handle responses by status
For a detail route declaring 200 with an article and 404 with a message, checking the status narrows the body type:
const result = await $endpoint('/api/articles/:id', {
method: 'get',
params: { id: '42' },
})
if (result.status === 200) {
result.body.title
}
if (result.status === 404) {
result.body.message
}
The 404 is a result you can handle, not a thrown fetch error. Network failures still reject the request. These types describe the declared responses; they cannot account for every response a proxy or other infrastructure might return.
The same route is callable from another service or with curl. Its HTTP interface is documented in the generated OpenAPI, so callers don't need NE to use it.
Forms, from the same contract
The experimental Nuxt 5 prototype applies the same idea to progressively enhanced forms. The server is still an explicit POST route:
// server/api/users.post.ts
import { z } from 'zod'
import { defineRouteHandler, formOf } from 'nuxt-endpoints/runtime'
const UserInput = z.object({
name: z.string().min(1),
})
export default defineRouteHandler({
form: {
action: '/users/new',
redirect: '/users/{id}',
},
validate: {
body: {
'application/json': UserInput,
'application/x-www-form-urlencoded': formOf(UserInput),
},
response: {
201: z.object({ id: z.number(), name: z.string() }),
},
},
handler: (event) =>
event.respond(201, {
id: 1,
name: event.validated.body.name,
}),
})
On the Vue page:
<script setup lang="ts">
const form = useEndpointForm('/api/users', {
method: 'post',
body: { name: '' },
})
</script>
<template>
<form v-bind="form.attrs" @submit="form.enhance">
<label>
Name
<input v-bind="form.fields.name" />
</label>
<p v-for="issue in form.issues.name" :key="issue.message">
{{ issue.message }}
</p>
<button>Create</button>
</form>
</template>
useEndpointForm requires a compatible form contract. It checks body values and field names against the input schema, so a field such as form.fields.email would be a type error here. Build-time checks reject requirements that a native form cannot satisfy, such as a mandatory custom request header.
NE generates the form attributes and connects server validation issues to form.issues. The progressive-enhancement documentation covers the generated submission route and the browser tests with and without JavaScript.
What is available today?
The published Nuxt 4 version includes pagination, status-aware requests, Pinia Colada adapters, OpenAPI generation, and idempotency. These are ported into the module using its own implementation.
The Nuxt 5 branch is where I'm experimenting with upstream integration and progressive enhancement. It requires prototype forks. The extensions in those forks have not been accepted upstream.
I'm following Nuxt's merged fetchdts route-typing PR, which generates $fetch and useFetch types from reported server routes, Nitro's removal of its previous typed-fetch implementation, and the September Nitro 3 beta. I'd like to contribute the contract introspection and type-inference pieces that downstream tools need, and reuse them in NE. The aim is less duplicated code while keeping NE's application APIs as stable as practical.
You can see the current split and setup instructions on the Nuxt 5 progress page.
Why not make every remote operation a function?
Server functions can expose HTTP controls, and these frameworks also support explicit API routes. I prefer starting with the route itself.
For a simple GET, I want to write a simple GET. When I need pagination or form integration, I want to opt into that abstraction for that purpose. I can see the extra requirements in the declaration and inspect the resulting HTTP interface in OpenAPI.
NE still introduces conventions: pagination and form are examples. That's fine with me as long as I can see what they add and call the endpoint without the generated client.
These examples don't cover everything server functions offer. NE doesn't yet provide automatic cache revalidation after a mutation, for example. What I've been able to build so far makes me want to keep exploring this approach.
What part of server-function DX would be hardest—or most valuable—to reproduce this way?
Top comments (0)