La directiva «use server»: el RPC type-safe
Una función de servidor no es código que casualmente corre en el servidor: es una función cuya frontera declaras con la directiva `use server` y que el compilador parte en dos —un cuerpo real que solo vive en el bundle del servidor y un stub en el cliente que serializa los argumentos, hace `fetch` a un endpoint generado y deserializa el retorno, conservando la firma de TypeScript de punta a punta—. Se estudian la forma de función y la de archivo, qué fabrica exactamente el compilador y por qué asumir que es un RPC —asíncrono, con latencia y con fallos de red— es el único modelo mental correcto.
El término «función de servidor» promete menos precisión de la que merece. No es código que casualmente se ejecuta en el servidor, sino una función cuya frontera declaras con una sola línea —"use server"— y que el compilador corta en dos mitades: un cuerpo real que solo existe en el bundle del servidor, y un sustituto en el cliente que, al invocarse, dispara una llamada de red tipada. Es un RPC —una llamada a procedimiento remoto— disfrazado de función corriente. Esta lección desmonta el disfraz para que sepas con exactitud qué generas cuando escribes la directiva.
- Diferenciar
"use server"de un import o una API: es una marca para el compilador, no algo que se invoca. - Aplicar sus dos formas: directiva de función en la primera línea del cuerpo, y directiva de archivo en la cima del módulo.
- Describir qué fabrica el compilador: un endpoint registrado en el servidor y un stub de RPC en el cliente.
- Asumir el modelo mental de RPC: asíncrono por obligación, con serialización, latencia y fallos de red.
Una marca para el compilador, no un import
Lo primero que despista es que "use server" no se importa de ningún sitio. Es un literal de cadena —una directiva, hermana de "use strict"— que el compilador de funciones de servidor reconoce como frontera. No hay una función useServer que llamar; escribes la cadena en el lugar correcto y el pipeline de compilación hace el resto en tiempo de build. Su forma más granular es la directiva de función: la pones como primera sentencia del cuerpo y solo esa función se convierte en función de servidor.
async function registrar(mensaje: string) {
"use server";
console.log(mensaje); // esta linea solo se ejecuta en el servidor
}
La forma más amplia es la directiva de archivo: una única línea en la cima del módulo marca todas sus exportaciones como funciones de servidor a la vez. Es la que usarás para archivos que agrupan acceso a datos —una capa data/ entera que jamás debe tocar el navegador—.
// data/registro.ts — el modulo entero es de servidor
"use server";
export async function registrar(mensaje: string) {
console.log(mensaje);
}
export async function contarEventos() {
return db.eventos.count(); // el cliente nunca ve esta consulta
}
Lo que el compilador fabrica: un RPC
Aquí ocurre la magia, que conviene entender para que deje de serlo. En el build del servidor el cuerpo se conserva intacto y se registra en un manifiesto de funciones de servidor bajo un identificador estable —el archivo y el nombre—. En el build del cliente ese cuerpo se descarta por completo y se sustituye por un stub: una referencia que, al llamarse, serializa los argumentos, hace una petición al endpoint generado para esa función y deserializa la respuesta. El cliente nunca ve la lógica; solo ve la dirección a la que llamar.
// Aproximacion de lo que el cliente recibe en lugar del cuerpo real:
const registrar = crearReferenciaServidor("data/registro.ts#registrar");
// registrar(mensaje) ==> POST al endpoint generado, con los argumentos serializados
flowchart LR C[cliente llama al stub] -->|seroval serializa args| N[peticion POST al endpoint generado] N -->|viaja por la red| S[servidor ejecuta el cuerpo real] S -->|seroval serializa el retorno| R[respuesta en streaming] R -->|cliente deserializa| V[valor tipado] style S fill:#a6e3a1,color:#11111b style V fill:#89b4fa,color:#11111b
Esta es la razón de que una función de servidor deba ser asíncrona: bajo el disfraz siempre hay una llamada de red, y ninguna llamada de red es síncrona. Cuando la ejecutas en el propio servidor —durante el SSR— no hay salto de red y se invoca directamente, pero la firma se mantiene asíncrona para que el mismo código sirva a ambos mundos sin ramificaciones.
El identificador con el que el compilador registra cada función —derivado del archivo y del nombre— importa más de lo que parece: es una dirección estable, análoga a una ruta REST, que sobrevive a los rebuilds mientras no muevas ni renombres la función. De ahí una consecuencia práctica: reorganizar tus archivos de datos no es del todo inocuo si hay respuestas cacheadas por URL, porque cambiar la dirección invalida esa caché. El compilador trata ese identificador con el mismo respeto con que un servidor trata el contrato de sus rutas, y tú deberías imitarlo.
El tipo sobrevive; el cuerpo, no
La propiedad que hace especial a este RPC es que el tipo cruza la frontera aunque el cuerpo no lo haga. El stub del cliente hereda la firma de TypeScript de la función original, así que los argumentos y el valor de retorno se comprueban en tiempo de compilación de punta a punta, sin que escribas un esquema ni un router como exigirían otras soluciones RPC. Es seguridad de tipos gratis a cambio de una sola verdad incómoda: en ejecución, es una petición HTTP que puede tardar o fallar.
import { query, createAsync } from "@solidjs/router";
const getPerfil = query(async (id: string) => {
"use server";
return db.usuarios.find(id); // toca la base de datos; jamas viaja al navegador
}, "perfil");
function Perfil(props: { id: string }) {
const perfil = createAsync(() => getPerfil(props.id)); // consumo reactivo, tipado
return <span>{perfil()?.nombre}</span>;
}
Las consecuencias de cruzar una red
Aceptar que cada llamada es un RPC obliga a programar con las consecuencias de la red delante, no a sus espaldas. La primera es que la función puede fallar por motivos ajenos a su lógica: la red cae, el servidor tarda, la conexión se corta a mitad. Cuando la consumes con createAsync, esos fallos afloran como cualquier error asíncrono y se capturan con un límite de error; el mismo mecanismo que ya usabas para datos remotos sirve aquí sin cambios, porque bajo el disfraz esto son datos remotos.
import { query, createAsync } from "@solidjs/router";
import { ErrorBoundary, Suspense } from "solid-js";
const getPanel = query(async () => {
"use server";
return db.metricas.resumen(); // puede lanzar; el fallo viaja como error
}, "panel");
function Panel() {
const datos = createAsync(() => getPanel());
return (
<ErrorBoundary fallback={(e) => <p>Falló la carga: {e.message}</p>}>
<Suspense fallback={<p>Cargando…</p>}>
<pre>{JSON.stringify(datos(), null, 2)}</pre>
</Suspense>
</ErrorBoundary>
);
}
La segunda consecuencia es que el endpoint generado es público: existe una URL real a la que cualquiera puede llamar sin pasar por tu interfaz. Esa idea, que aquí solo enunciamos, es el eje entero de la lección de seguridad de este nivel; por ahora basta con grabar que un RPC no es una función privada de tu módulo, sino una puerta al mundo que tú decides cómo custodiar. La tercera es la latencia: lo que parece una llamada instantánea implica un viaje de ida y vuelta, así que agrupar trabajo en una sola función de servidor casi siempre bate a encadenar varias que van y vienen por la red.
El registro de cada función de servidor en el manifiesto no es un detalle interno inaccesible: getServerFunctionMeta de @solidjs/start te deja consultar, dentro de un cuerpo de servidor, la identidad con la que el compilador registró la función. Rara vez lo necesitarás para lógica de aplicación, pero verlo confirma la tesis de la lección —que la función tiene una identidad de endpoint estable, generada en build— y resulta útil para trazas, métricas por función o depuración del enrutado del RPC.
Directiva, no función
No se importa useServer: escribes la cadena "use server" y el compilador la reconoce como frontera en tiempo de build.
RPC, no llamada local
Cada invocación desde el cliente es una peticion de red serializada, no un salto de función en memoria.
El cuerpo se queda
El cuerpo vive solo en el bundle del servidor; el cliente recibe un stub con la dirección a la que llamar.
Por defecto una función de servidor se invoca con POST, que ningún caché HTTP guarda. Si la función es una lectura idempotente, envuélvela con GET de @solidjs/start para que viaje como una petición GET y pueda cachearse por URL en el navegador y en intermediarios. Reserva el POST implícito para las escrituras, donde repetir la petición no debe ser barato ni cacheable. La elección del verbo no es cosmética: comunica la semántica de la operación a toda la pila HTTP.
El salto conceptual de este nivel entero cabe en una frase: "use server" no traslada código al servidor, traza una frontera de red por la que solo cruzan datos serializados, y el compilador se encarga de que la llamada se sienta como una función local aunque nunca lo sea. Verlo así reordena todo lo demás. Explica por qué la función debe ser asíncrona —hay un fetch debajo—; por qué sus argumentos y su retorno tienen que poder serializarse —cruzan una red, no una pila de llamadas—; por qué el cuerpo nunca aparece en el bundle del cliente —vive del otro lado de la frontera—; y por qué obtienes tipos de punta a punta sin esfuerzo —el compilador conserva la firma mientras descarta la implementación—. Quien programa funciones de servidor creyendo que «esto simplemente corre en el servidor» tropieza tarde o temprano con una de esas consecuencias como si fuera un misterio: pasa un objeto que no serializa, cierra sobre una variable que no existe en el cliente, o se sorprende de que una función que «parece local» tenga latencia. Quien las programa sabiendo que cada una es un RPC disfrazado no se sorprende de nada, porque las cuatro consecuencias se deducen de la única premisa. Aprende la premisa —función de servidor igual a RPC con piel de función— y el resto del nivel deja de ser una lista de reglas para convertirse en un teorema con sus corolarios.
- Escribe una función con directiva de función y otra con directiva de archivo; razona qué exportaciones quedan marcadas en cada caso.
- Consume una de ellas con
createAsyncdentro de un componente y comprueba en las herramientas de red que cada llamada produce una petición HTTP. - Cambia a propósito el tipo de un argumento en la función y confirma que TypeScript marca el error en el sitio de la llamada, sin haber escrito ningún esquema.
- Envuelve una lectura idempotente con
GETy contrasta el verbo de la petición con el de una función sin envolver. - Explica con tus palabras por qué una función de servidor no puede ser síncrona, apoyándote en el diagrama del RPC.