DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Next.js 15 en AWS Amplify Gen 2 deployment production-ready con backend tipado

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

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

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

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

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

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

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

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

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,
});
Enter fullscreen mode Exit fullscreen mode
// lib/amplify-client.ts
'use client';

import { Amplify } from 'aws-amplify';
import outputs from '@/amplify_outputs.json';

Amplify.configure(outputs, { ssr: true });
Enter fullscreen mode Exit fullscreen mode
// 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>;
}
Enter fullscreen mode Exit fullscreen mode

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

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

Sandbox: el killer feature

El desarrollo día a día con sandbox es una experiencia distinta:

npx ampx sandbox
Enter fullscreen mode Exit fullscreen mode

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

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

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

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)