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.
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 serializadosEsta 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.