DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Remix en AWS con Architect y loaders optimizados para serverless

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
Enter fullscreen mode Exit fullscreen mode

Los principios de Remix:

  1. Cada ruta tiene un loader para GETs y un action para POSTs/PUTs/DELETEs.
  2. Los formularios funcionan sin JavaScript.
  3. Nada de client-side fetch libraries. Todo está en loaders.
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 },
  });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

arc deploy es una sola operación que:

  1. Builda Remix.
  2. Crea el CloudFormation stack.
  3. Sube la Lambda.
  4. Crea las tablas DynamoDB.
  5. 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
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)