Remix es una alternativa a Next.js con una filosofía distinta: loaders y actions en cada ruta, progressive enhancement por default, y nada de client-side data fetching si puedes evitarlo. Para AWS Lambda, Remix compila a algo sorprendentemente pequeño. Architect (arc) es un framework serverless que fue creado por el equipo de Remix y se integra de forma natural. Este artículo es el setup completo para equipos que quieren un stack más liviano que Next.js.
Por qué Remix es distinto
flowchart LR
subgraph NextJS[Next.js modelo]
N1[getServerSideProps]
N2[API routes separadas]
N3[Client fetching con SWR]
N4[Hydratación compleja]
end
subgraph Remix[Remix modelo]
R1[Loader por ruta]
R2[Action por ruta]
R3[useLoaderData en componente]
R4[Progressive enhancement]
end
style Remix fill:#3992ff,color:#fff
Los principios de Remix:
- Cada ruta tiene un
loaderpara GETs y unactionpara POSTs/PUTs/DELETEs. - Los formularios funcionan sin JavaScript.
- Nada de client-side fetch libraries. Todo está en loaders.
- Nested routes: layouts anidados cargan en paralelo.
Creando el proyecto
npx create-remix@latest mi-app --template remix-run/remix/templates/arc
cd mi-app
npm install
El template arc viene pre-configurado para Architect. La estructura:
app/
routes/
_index.tsx
posts._index.tsx
posts.$slug.tsx
api.newsletter.ts
root.tsx
entry.client.tsx
entry.server.tsx
db.server.ts
server/
index.ts
app.arc
package.json
La config de Architect
Architect usa un archivo app.arc en formato declarativo:
# app.arc
@app
mi-app-remix
@aws
region us-east-1
profile default
runtime nodejs20.x
memory 1024
timeout 30
architecture arm64
@http
get /*
post /*
@tables
posts
id *String
.
encrypt true
stream true
point-in-time-recovery true
@indexes
posts
slug *String
name by-slug
posts
published *Number
publishedAt **String
name published-index
@plugins
plugin-remix
src ./plugins/plugin-remix
@static
folder public
fingerprint true
Este archivo define: una Lambda + API Gateway para manejar todas las requests, una tabla DynamoDB con 2 GSIs, y un bucket S3 para assets. En 30 líneas.
El server entry para Lambda
// server/index.ts
import { createRequestHandler } from '@remix-run/architect';
import * as build from '@remix-run/dev/server-build';
export const handler = createRequestHandler({
build,
mode: process.env.NODE_ENV,
getLoadContext(req, res) {
return {
requestId: req.requestContext.requestId,
userIp: req.requestContext.http.sourceIp,
};
},
});
Eso es todo. createRequestHandler convierte el Remix build en un handler de Lambda.
Loader y action en una ruta
Una página que lista posts con filtrado:
// app/routes/posts._index.tsx
import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { useLoaderData, Form, useSearchParams, Link } from '@remix-run/react';
import { z } from 'zod';
import { queryPosts, type Post } from '~/db.server';
const QuerySchema = z.object({
tag: z.string().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().min(1).max(100).default(20),
});
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const result = QuerySchema.safeParse(Object.fromEntries(url.searchParams));
if (!result.success) {
throw redirect('/posts');
}
const { items, nextCursor } = await queryPosts(result.data);
return json(
{ posts: items, nextCursor, filters: result.data },
{
headers: {
'Cache-Control': 'public, max-age=60, s-maxage=300, stale-while-revalidate=600',
},
}
);
}
export const meta: MetaFunction = () => {
return [
{ title: 'Posts | Mi blog' },
{ name: 'description', content: 'Últimos posts publicados' },
];
};
export default function PostsIndex() {
const { posts, nextCursor, filters } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
return (
<section>
<header>
<h1>Posts</h1>
<Form method="get">
<input
type="text"
name="tag"
placeholder="Filtrar por tag"
defaultValue={filters.tag}
/>
<button type="submit">Filtrar</button>
</Form>
</header>
<ul>
{posts.map(post => (
<li key={post.id}>
<Link to={`/posts/${post.slug}`}>
<h2>{post.title}</h2>
</Link>
<p>{post.excerpt}</p>
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
</li>
))}
</ul>
{nextCursor && (
<Link
to={`?${new URLSearchParams({ ...Object.fromEntries(searchParams), cursor: nextCursor })}`}
>
Cargar más
</Link>
)}
</section>
);
}
Nota: el formulario <Form method="get"> actualiza la URL. Sin JavaScript, el navegador hace full page reload. Con JS, Remix intercepta y hace SPA-style navigation.
Rutas anidadas con layouts
Remix tiene un sistema de nested routing poderoso. El path /posts/mi-post renderiza:
root.tsx
└─ _app.tsx (layout opcional)
└─ posts.$slug.tsx
Cada nivel carga su loader en paralelo. Si root necesita user data y posts.$slug necesita post data, ambos se fetchan al mismo tiempo.
// app/root.tsx
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from '@remix-run/react';
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { getUser } from '~/auth.server';
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getUser(request);
return json({ user });
}
export default function App() {
const { user } = useLoaderData<typeof loader>();
return (
<html lang="es">
<head>
<Meta />
<Links />
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/posts">Blog</a>
{user ? (
<span>Hola, {user.name}</span>
) : (
<a href="/login">Ingresar</a>
)}
</nav>
</header>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
// app/routes/posts.$slug.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { getPostBySlug, incrementView } from '~/db.server';
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPostBySlug(params.slug!);
if (!post) {
throw new Response('Not Found', { status: 404 });
}
// Fire and forget
incrementView(post.id).catch(console.error);
return json({ post }, {
headers: {
'Cache-Control': 'public, max-age=300, s-maxage=3600',
},
});
}
export default function PostPage() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
export function ErrorBoundary() {
return (
<div>
<h1>Post no encontrado</h1>
<a href="/posts">Ver todos los posts</a>
</div>
);
}
Actions para mutaciones
Un formulario con validación, progressive enhancement, y optimistic UI:
// app/routes/posts.$slug.comments.tsx
import { json, redirect, type ActionFunctionArgs } from '@remix-run/node';
import { Form, useActionData, useNavigation } from '@remix-run/react';
import { z } from 'zod';
import { requireUser } from '~/auth.server';
import { createComment } from '~/db.server';
const CommentSchema = z.object({
content: z.string().min(3).max(1000),
postId: z.string().uuid(),
});
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
const formData = await request.formData();
const result = CommentSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return json(
{
error: 'Validation failed',
issues: result.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
const comment = await createComment({
...result.data,
authorId: user.id,
authorName: user.name,
});
return redirect(`/posts/${result.data.postId}#comment-${comment.id}`);
}
export default function NewCommentForm({ postId }: { postId: string }) {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<Form method="post" action={`/posts/${postId}/comments`}>
<input type="hidden" name="postId" value={postId} />
<textarea name="content" required minLength={3} />
{actionData?.issues?.content && (
<span role="alert">{actionData.issues.content[0]}</span>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Enviando...' : 'Comentar'}
</button>
</Form>
);
}
El formulario funciona sin JS (navegación normal), y con JS usa fetch detrás del scene.
Resource routes
Para endpoints tipo API (no renderizan HTML):
// app/routes/api.newsletter.ts
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { z } from 'zod';
import { subscribeToNewsletter } from '~/newsletter.server';
const Schema = z.object({
email: z.string().email(),
});
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
const body = await request.json();
const result = Schema.safeParse(body);
if (!result.success) {
return json({ error: 'Invalid email' }, { status: 400 });
}
try {
await subscribeToNewsletter(result.data.email);
return json({ success: true });
} catch (error: any) {
if (error.code === 'ALREADY_SUBSCRIBED') {
return json({ error: 'Ya estás suscrito' }, { status: 409 });
}
throw error;
}
}
db.server.ts con DynamoDB
El prefijo .server le dice a Remix que este código no debe ir al bundle del cliente:
// app/db.server.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, GetCommand, PutCommand, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import arc from '@architect/functions';
const { tables } = arc;
export interface Post {
id: string;
slug: string;
title: string;
content: string;
excerpt: string;
publishedAt: string;
published: number;
tags: string[];
viewCount: number;
}
export async function getPostBySlug(slug: string): Promise<Post | null> {
const data = await tables();
const result = await data.posts.query({
IndexName: 'by-slug',
KeyConditionExpression: 'slug = :slug',
ExpressionAttributeValues: { ':slug': slug },
Limit: 1,
});
return (result.Items?.[0] as Post) ?? null;
}
export async function queryPosts({
tag,
cursor,
limit = 20,
}: {
tag?: string;
cursor?: string;
limit?: number;
}) {
const data = await tables();
const result = await data.posts.query({
IndexName: 'published-index',
KeyConditionExpression: 'published = :p',
FilterExpression: tag ? 'contains(tags, :tag)' : undefined,
ExpressionAttributeValues: {
':p': 1,
...(tag && { ':tag': tag }),
},
Limit: limit,
ExclusiveStartKey: cursor
? JSON.parse(Buffer.from(cursor, 'base64').toString())
: undefined,
ScanIndexForward: false,
});
return {
items: (result.Items ?? []) as Post[],
nextCursor: result.LastEvaluatedKey
? Buffer.from(JSON.stringify(result.LastEvaluatedKey)).toString('base64')
: null,
};
}
export async function incrementView(postId: string): Promise<void> {
const data = await tables();
await data.posts.update({
Key: { id: postId },
UpdateExpression: 'ADD viewCount :one',
ExpressionAttributeValues: { ':one': 1 },
});
}
arc.tables() es un wrapper que lee los nombres de tablas del app.arc. Sin manual environment variables.
Build y deploy
# Desarrollo local
npm run dev
# Levanta Remix en watch mode + sandbox local de Architect
# Deploy a producción
npx arc deploy production
arc deploy es una sola operación que:
- Builda Remix.
- Crea el CloudFormation stack.
- Sube la Lambda.
- Crea las tablas DynamoDB.
- Sube los assets estáticos a S3.
Tiempo típico: 2-3 minutos.
Tipado de loaders
Un detalle que me gusta de Remix: el tipado se propaga de loader a componente:
// Si el loader retorna:
export async function loader() {
return json({ post: { id: '1', title: 'Hello' } });
}
// En el componente:
export default function Page() {
const data = useLoaderData<typeof loader>();
// data.post.title tiene tipo string
// Si accedes data.inexistent, error de TypeScript
}
Sin manual types. El tipo se infiere del loader.
Error handling por route
Cada ruta puede exportar un ErrorBoundary:
// app/routes/posts.$slug.tsx
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return (
<div>
<h1>Post no encontrado</h1>
<a href="/posts">Ver todos</a>
</div>
);
}
}
return (
<div>
<h1>Algo salió mal</h1>
<p>Intenta de nuevo más tarde.</p>
</div>
);
}
Si hay un error en el loader o el render, solo se aísla a esta ruta. El resto de la app sigue funcionando.
Métricas de performance
Un proyecto real, comparando Remix + arc vs Next.js + custom infra:
| Métrica | Remix + arc | Next.js 15 |
|---|---|---|
| Bundle cliente inicial | 62 KB | 195 KB |
| Build time | 18s | 1m 40s |
| Deploy time | 2m 30s | 5m |
| Cold start P50 | 280ms | 520ms |
| Cold start P99 | 680ms | 1.4s |
| Líneas de config | 35 | 180 |
La simplicidad es real. Lo pagas en menos features (no hay app router, server actions, etc).
Lo que aprendí
1. El prefijo .server es más útil de lo que parece.
Separar código server del cliente automáticamente es genial. No más "import de module-only-server en cliente por accidente".
2. Los loaders en paralelo son mágicos.
Si tienes 3 niveles anidados, los 3 loaders ejecutan en paralelo. Sin hacer nada, tu TTFB es el máximo de los tres, no la suma.
3. Progressive enhancement es filosofía.
Si te obligas a que todo funcione sin JS, te vuelves mejor developer. Casos edge que ignorarías emergen.
4. Architect tiene su aprendizaje.
El archivo app.arc es raro al principio. Pero una vez que lo entiendes, es más conciso que CloudFormation.
5. Remix tiene menos ecosistema que Next.js.
Si buscas "Remix + Stripe", hay menos tutoriales. Si buscas "Next.js + Stripe", hay docenas.
Cuándo usar Remix
- Equipos pequeños que valoran simplicidad.
- Apps donde progressive enhancement importa.
- Proyectos sin mucha interactividad client-side.
- Desarrolladores que prefieren web standards (fetch, FormData, Response).
Cierre
Remix + Architect es un stack underutilizado en AWS. Es más liviano que Next.js, más explícito, y encaja perfectamente con el modelo serverless. Para ciertos proyectos, es la mejor elección.
En el próximo artículo: feature flags con AppConfig para deploys graduales y experimentación.
Top comments (0)