Amplify Gen 2 cambió el juego. Dejamos atrás el JSON infernal y la CLI imperativa para pasar a un modelo de código TypeScript donde todo tu backend se define con autocompletado y validación de tipos. Llevo meses llevando proyectos de Gen 1 a Gen 2 y quiero compartir el setup completo para Next.js 15 con App Router en producción.
Este artículo no es un "hello world". Es el flujo real con auth, data, storage, custom domains, ambientes, y el plumbing que nadie te cuenta.
Por qué Gen 2 vale la pena
Antes de entrar al código, el contexto. Estos son los problemas que Gen 2 resuelve:
flowchart LR
subgraph Gen1[Gen 1 - JSON imperativo]
A1[amplify/backend/<br/>api/schema.graphql]
A2[amplify/backend/<br/>auth/cli-inputs.json]
A3[Team shared env]
A4[Regenerar tipos<br/>manualmente]
end
subgraph Gen2[Gen 2 - TypeScript declarativo]
B1[amplify/data/<br/>resource.ts]
B2[amplify/auth/<br/>resource.ts]
B3[Sandbox por dev]
B4[Tipos autogenerados<br/>en tiempo real]
end
Gen1 -.migracion.-> Gen2
style Gen2 fill:#ff9900,color:#000
- Sandbox por desarrollador: cada persona tiene su backend aislado. No más "espera que Juan no ha terminado su prueba".
- Tipos end-to-end: defines el modelo una vez, lo consumes tipado en server components, client components y APIs.
- Menos magia: menos CloudFormation generado por detrás, más código que puedes leer.
Arrancar el proyecto
npm create next-app@latest mi-app -- --typescript --tailwind --app
cd mi-app
npm create amplify@latest
Lo segundo crea esta estructura:
amplify/
auth/
resource.ts
data/
resource.ts
storage/
resource.ts
backend.ts
tsconfig.json
amplify.yml
amplify_outputs.json (autogenerado)
Definiendo el schema de datos
Aquí es donde Gen 2 brilla. El schema es TypeScript puro:
// amplify/data/resource.ts
import { a, defineData, type ClientSchema } from '@aws-amplify/backend';
const schema = a.schema({
Post: a
.model({
title: a.string().required(),
slug: a.string().required(),
content: a.string().required(),
excerpt: a.string(),
published: a.boolean().default(false),
publishedAt: a.datetime(),
tags: a.string().array(),
coverImage: a.url(),
viewCount: a.integer().default(0),
author: a.belongsTo('User', 'authorId'),
authorId: a.id().required(),
comments: a.hasMany('Comment', 'postId'),
})
.authorization((allow) => [
allow.owner().to(['create', 'update', 'delete']),
allow.authenticated().to(['read']),
allow.publicApiKey().to(['read']),
])
.secondaryIndexes((index) => [
index('slug').queryField('postBySlug'),
index('published').sortKeys(['publishedAt']).queryField('publishedPosts'),
]),
User: a
.model({
email: a.email().required(),
displayName: a.string(),
bio: a.string(),
avatar: a.url(),
posts: a.hasMany('Post', 'authorId'),
})
.authorization((allow) => [
allow.owner(),
allow.authenticated().to(['read']),
]),
Comment: a
.model({
content: a.string().required(),
post: a.belongsTo('Post', 'postId'),
postId: a.id().required(),
authorEmail: a.email().required(),
authorName: a.string().required(),
approved: a.boolean().default(false),
})
.authorization((allow) => [
allow.owner().to(['create', 'update', 'delete']),
allow.publicApiKey().to(['read', 'create']),
allow.groups(['Admin']).to(['read', 'update', 'delete']),
]),
incrementPostView: a
.mutation()
.arguments({ postId: a.id().required() })
.returns(a.ref('Post'))
.authorization((allow) => [allow.publicApiKey()])
.handler(
a.handler.custom({
dataSource: a.ref('Post'),
entry: './increment-view.js',
})
),
});
export type Schema = ClientSchema<typeof schema>;
export const data = defineData({
schema,
authorizationModes: {
defaultAuthorizationMode: 'userPool',
apiKeyAuthorizationMode: {
expiresInDays: 30,
},
},
});
El resolver custom para incrementar views:
// amplify/data/increment-view.js
import { util } from '@aws-appsync/utils';
export function request(ctx) {
return {
operation: 'UpdateItem',
key: util.dynamodb.toMapValues({ id: ctx.args.postId }),
update: {
expression: 'ADD viewCount :inc',
expressionValues: util.dynamodb.toMapValues({ ':inc': 1 }),
},
};
}
export function response(ctx) {
return ctx.result;
}
Configurando auth con custom attributes
// amplify/auth/resource.ts
import { defineAuth } from '@aws-amplify/backend';
export const auth = defineAuth({
loginWith: {
email: true,
externalProviders: {
google: {
clientId: secret('GOOGLE_CLIENT_ID'),
clientSecret: secret('GOOGLE_CLIENT_SECRET'),
scopes: ['email', 'profile'],
attributeMapping: {
email: 'email',
fullname: 'name',
profilePicture: 'picture',
},
},
callbackUrls: [
'http://localhost:3000/auth/callback',
'https://app.miempresa.com/auth/callback',
],
logoutUrls: [
'http://localhost:3000/',
'https://app.miempresa.com/',
],
},
},
userAttributes: {
email: { required: true, mutable: false },
givenName: { required: true, mutable: true },
familyName: { required: true, mutable: true },
'custom:plan': {
dataType: 'String',
mutable: true,
},
'custom:onboarding_completed': {
dataType: 'Boolean',
mutable: true,
},
},
multifactor: {
mode: 'OPTIONAL',
totp: true,
},
accountRecovery: 'EMAIL_ONLY',
groups: ['Admin', 'Editor', 'Subscriber'],
});
Storage con permisos granulares
// amplify/storage/resource.ts
import { defineStorage } from '@aws-amplify/backend';
export const storage = defineStorage({
name: 'mediaAssets',
access: (allow) => ({
'public/*': [
allow.guest.to(['read']),
allow.authenticated.to(['read', 'write']),
],
'posts/{entity_id}/*': [
allow.entity('identity').to(['read', 'write', 'delete']),
allow.authenticated.to(['read']),
],
'avatars/{entity_id}/*': [
allow.entity('identity').to(['read', 'write', 'delete']),
allow.guest.to(['read']),
],
'admin/*': [
allow.groups(['Admin']).to(['read', 'write', 'delete']),
],
}),
});
El backend.ts que cose todo
// amplify/backend.ts
import { defineBackend } from '@aws-amplify/backend';
import { auth } from './auth/resource';
import { data } from './data/resource';
import { storage } from './storage/resource';
const backend = defineBackend({
auth,
data,
storage,
});
// Custom infrastructure: Cognito post-confirmation trigger
import { aws_lambda as lambda, aws_iam as iam } from 'aws-cdk-lib';
import { Construct } from 'constructs';
const customResources = backend.createStack('CustomResources');
// Trigger que crea un User record cuando alguien se registra
const postConfirmationLambda = new lambda.Function(customResources, 'PostConfirmation', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('./amplify/functions/post-confirmation'),
environment: {
USER_TABLE: backend.data.resources.tables['User'].tableName,
},
});
backend.data.resources.tables['User'].grantReadWriteData(postConfirmationLambda);
backend.auth.resources.userPool.addTrigger(
'postConfirmation' as any,
postConfirmationLambda
);
// Override de CloudFront para agregar WAF
import { aws_wafv2 as wafv2 } from 'aws-cdk-lib';
const waf = new wafv2.CfnWebACL(customResources, 'ApiWAF', {
scope: 'REGIONAL',
defaultAction: { allow: {} },
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'api-waf',
sampledRequestsEnabled: true,
},
rules: [
{
name: 'RateLimit',
priority: 1,
action: { block: {} },
statement: {
rateBasedStatement: {
limit: 2000,
aggregateKeyType: 'IP',
},
},
visibilityConfig: {
cloudWatchMetricsEnabled: true,
metricName: 'rate-limit',
sampledRequestsEnabled: true,
},
},
],
});
Arquitectura resultante
flowchart TB
NextApp[Next.js 15<br/>App Router] -->|SSR calls| AppSync[AWS AppSync<br/>GraphQL API]
NextApp -->|Auth| Cognito[Cognito User Pool]
NextApp -->|Upload| S3[S3 Bucket]
Cognito -->|PostConfirm| PostLambda[Lambda<br/>post-confirmation]
PostLambda -->|Create User| DDB[(DynamoDB)]
AppSync --> DDB
AppSync -->|Custom resolver| DDB
AppSync -->|WAF protection| WAF[AWS WAF]
style NextApp fill:#000,color:#fff
style AppSync fill:#ff4f8b,color:#fff
style Cognito fill:#DD344C,color:#fff
style S3 fill:#569A31,color:#fff
Integrando con Next.js 15 App Router
El cliente de Amplify en el server y el client son distintos. Este es el patrón que me funciona:
// lib/amplify-server-utils.ts
import { cookies } from 'next/headers';
import { createServerRunner } from '@aws-amplify/adapter-nextjs';
import outputs from '@/amplify_outputs.json';
export const { runWithAmplifyServerContext } = createServerRunner({
config: outputs,
});
// lib/amplify-client.ts
'use client';
import { Amplify } from 'aws-amplify';
import outputs from '@/amplify_outputs.json';
Amplify.configure(outputs, { ssr: true });
// app/providers.tsx
'use client';
import '@/lib/amplify-client';
import { Authenticator } from '@aws-amplify/ui-react';
import '@aws-amplify/ui-react/styles.css';
export function Providers({ children }: { children: React.ReactNode }) {
return <Authenticator.Provider>{children}</Authenticator.Provider>;
}
Server Components que consumen el backend tipado
Aquí es donde el tipado end-to-end paga dividendos:
// app/posts/[slug]/page.tsx
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
import { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/data';
import { type Schema } from '@/amplify/data/resource';
import outputs from '@/amplify_outputs.json';
const cookiesClient = generateServerClientUsingCookies<Schema>({
config: outputs,
cookies,
});
type Post = Schema['Post']['type'];
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { data: posts, errors } = await cookiesClient.models.Post.postBySlug({
slug,
});
if (errors || !posts || posts.length === 0) {
notFound();
}
const post = posts[0];
// Incrementar view count en background
cookiesClient.mutations.incrementPostView({ postId: post.id });
const { data: comments } = await cookiesClient.models.Comment.list({
filter: { postId: { eq: post.id }, approved: { eq: true } },
});
return (
<article className="max-w-4xl mx-auto py-12">
<header>
<h1 className="text-4xl font-bold">{post.title}</h1>
<time dateTime={post.publishedAt ?? ''}>
{new Date(post.publishedAt ?? '').toLocaleDateString()}
</time>
</header>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<CommentsSection comments={comments ?? []} postId={post.id} />
</article>
);
}
export async function generateStaticParams() {
const { data: posts } = await cookiesClient.models.Post.publishedPosts({
published: true,
});
return posts?.map((post) => ({ slug: post.slug })) ?? [];
}
export const revalidate = 3600;
Server Actions con auth
Para mutaciones desde formularios, Server Actions son el camino:
// app/actions/comments.ts
'use server';
import { cookies } from 'next/headers';
import { revalidatePath } from 'next/cache';
import { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/data';
import { type Schema } from '@/amplify/data/resource';
import { getCurrentUser } from 'aws-amplify/auth/server';
import { runWithAmplifyServerContext } from '@/lib/amplify-server-utils';
import outputs from '@/amplify_outputs.json';
import { z } from 'zod';
const CommentSchema = z.object({
postId: z.string().uuid(),
content: z.string().min(3).max(1000),
authorEmail: z.string().email(),
authorName: z.string().min(2).max(50),
});
const cookiesClient = generateServerClientUsingCookies<Schema>({
config: outputs,
cookies,
});
export async function createComment(formData: FormData) {
const validated = CommentSchema.safeParse({
postId: formData.get('postId'),
content: formData.get('content'),
authorEmail: formData.get('email'),
authorName: formData.get('name'),
});
if (!validated.success) {
return { error: 'Datos inválidos', issues: validated.error.issues };
}
// Verificar si el usuario está autenticado
const isAuthenticated = await runWithAmplifyServerContext({
nextServerContext: { cookies },
operation: async (contextSpec) => {
try {
await getCurrentUser(contextSpec);
return true;
} catch {
return false;
}
},
});
const { data, errors } = await cookiesClient.models.Comment.create({
...validated.data,
approved: isAuthenticated,
});
if (errors) {
return { error: 'Error al crear comentario' };
}
revalidatePath(`/posts/${validated.data.postId}`);
return { success: true, comment: data };
}
Sandbox: el killer feature
El desarrollo día a día con sandbox es una experiencia distinta:
npx ampx sandbox
Esto levanta tu propio backend aislado. Cuando editas amplify/data/resource.ts, en segundos se refleja en tu sandbox. No tocas el ambiente compartido. Este es el flujo:
sequenceDiagram
participant Dev as Desarrollador
participant CLI as ampx sandbox
participant CF as CloudFormation
participant AppSync as AppSync
participant Next as Next.js local
Dev->>CLI: ampx sandbox
CLI->>CF: Deploy stack personal
CF-->>CLI: amplify_outputs.json
CLI->>Next: Watch mode activo
Dev->>CLI: Editar resource.ts
CLI->>CF: Update stack
CF-->>CLI: Nuevo outputs
CLI->>Next: Hot reload
Dev->>Next: Ver cambios
Deploy a producción
El archivo amplify.yml controla el build pipeline:
version: 1
backend:
phases:
build:
commands:
- npm ci --cache .npm --prefer-offline
- npx ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID
frontend:
phases:
preBuild:
commands:
- npm ci --cache .npm --prefer-offline
build:
commands:
- npm run build
artifacts:
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- .next/cache/**/*
- .npm/**/*
- node_modules/**/*
Multi-environment sin dolor
Cada branch en Amplify Console es un environment. El setup:
-
main→ producción -
develop→ staging -
feature/*→ preview deployments automáticos
En amplify/backend.ts puedes diferenciar por branch:
const backend = defineBackend({
auth,
data,
storage,
});
// Solo agregar WAF en producción
if (process.env.AWS_BRANCH === 'main') {
attachWAFToApi(backend);
}
// Más logs en desarrollo
if (process.env.AWS_BRANCH !== 'main') {
backend.data.resources.cfnResources.cfnGraphqlApi.xrayEnabled = true;
}
Lo que aprendí migrando
1. La autorización es el 50% de la complejidad.
El modelo de allow.owner() + allow.groups() + allow.publicApiKey() es potente pero hay que pensarlo desde el inicio. Si cambias la autorización después, los registros existentes pueden volverse inaccesibles.
2. No uses list() sin filtros en producción.
Uno pensaría que es obvio, pero es fácil caer en ello desde un componente de servidor. Siempre usa índices secundarios con queryField y llama esos.
3. El cliente de aws-amplify/data tiene dos sabores.
generateClient<Schema>() para client components. generateServerClientUsingCookies<Schema>() para server components. Mezclarlos da errores raros de SSR.
4. Custom resolvers JS son más rápidos que Lambda resolvers.
Para operaciones simples como incrementar un contador, los JS resolvers de AppSync corren dentro del motor de AppSync. Son más baratos y más rápidos que llamar una Lambda.
5. Amplify Console tiene una UI horrible pero los logs son buenos.
El deployment log de Amplify Console te dice exactamente qué paso falló. Úsalo. No adivines.
Cuándo no usar Amplify Gen 2
Si necesitas:
- VPC peering con recursos existentes complejos.
- Control total sobre la infraestructura de red.
- Ciclos de deployment sub-minuto.
Considera CDK directo con AppSync. Vas a escribir más código, pero vas a tener más control.
Cierre
Amplify Gen 2 es la mejor experiencia de desarrollo fullstack que AWS ha tenido nunca. La combinación de tipado, sandbox y deploy automatizado es difícil de vencer. El trade-off es que sigues atado al stack de Amplify (AppSync, Cognito, S3, DynamoDB). Para la mayoría de apps SaaS, ese stack es perfecto.
En el próximo artículo voy a mostrar cómo construir stacks reutilizables de CDK para cuando Amplify no es suficiente.
Top comments (0)