Build features with AWS Amplify Gen 2 across the four-tier architecture. Use when developing with AppSync, Lambda, or data modelling, configuring authentication, or deploying Amplify apps.
Development patterns and best practices for AWS Amplify Gen 2 applications.
Load this skill when you need to:
Common triggers:
Managed services handle infrastructure. Lambda handles decisions. Frontend handles presentation.
Or more specifically for Amplify:
Cognito handles identity. Lambda handles business logic. AppSync handles data access. Frontend handles UI.
┌─────────────────────────────────────────────────────────────┐
│ FRONTEND (React/Next.js Client) │
│ Presentation Layer │
├─────────────────────────────────────────────────────────────┤
│ - Render UI components │
│ - Collect user input │
│ - Call mutations/queries via Amplify client │
│ - Display results and handle loading/error states │
│ ❌ NO business logic │
│ ❌ NO data transformation beyond display formatting │
│ ❌ NO direct AWS service calls │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ NEXT.JS API ROUTES (SSR) │
│ Backend for Frontend (BFF) Pattern │
├─────────────────────────────────────────────────────────────┤
│ - Session/auth context extraction │
│ - Data formatting for frontend consumption │
│ - Simple proxying to AppSync │
│ ❌ NO business logic │
│ ❌ NO direct AWS service calls (SES, Cognito Admin, etc.) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ APPSYNC (GraphQL API) │
│ Data Access Layer │
├─────────────────────────────────────────────────────────────┤
│ - CRUD operations on DynamoDB models │
│ - Authorization rules (allow.authenticated, etc.) │
│ - Real-time subscriptions │
│ - Custom resolvers → Lambda functions │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ LAMBDA FUNCTIONS (amplify/functions/) │
│ Business Logic Layer │
├─────────────────────────────────────────────────────────────┤
│ - Complex business logic and workflows │
│ - AWS service calls (SES, S3, Cognito Admin) │
│ - Multi-step orchestration │
│ - Batch data processing (import/export) │
│ - Auth triggers (post-confirmation, pre-signup) │
│ - Audit logging │
└─────────────────────────────────────────────────────────────┘
MUST place business logic in Lambda functions. Any code that makes decisions, enforces rules, or orchestrates workflows belongs in Lambda, not frontend or API routes.
MUST use AppSync mutations for write operations. Don't call Lambda directly from frontend; wire Lambdas as AppSync resolver handlers.
MUST grant explicit IAM permissions. Lambda functions need IAM policies in backend.ts for AWS service access (SES, Cognito, S3).
MUST include __typename when writing directly to DynamoDB. Amplify Data Client requires this field to recognize records. See Pitfall #1.
MUST use resourceGroupName: "data" for resolver Lambdas. Prevents circular dependency between function and data stacks. See Pitfall #2.
MUST avoid auto-generated mutation name conflicts. Don't name custom mutations createModelName, updateModelName, etc. See Pitfall #3.
SHOULD keep API routes as thin BFF wrappers. Extract session, call AppSync, format response. No business logic.
SHOULD test Lambdas in isolation. Unit tests for handlers with mocked AWS SDK, integration tests against sandbox.
SHOULD use the same sender identity across Cognito and custom emails. Prevents DKIM/deliverability issues.
Use this table to decide where code belongs:
| Responsibility | Correct Tier | Example |
|---|---|---|
| Render UI | Frontend | React components |
| Collect user input | Frontend | Forms, buttons |
| Display loading/error states | Frontend | Spinners, toasts |
| Extract session claims | API Route (BFF) | Reading JWT |
| Format data for frontend | API Route (BFF) | Aggregating responses |
| CRUD on DynamoDB models | AppSync | client.models.Tenant.get() |
| Authorization rules | AppSync | allow.authenticated() |
| Send emails (SES) | Lambda | Invitation emails |
| Cognito admin operations | Lambda | User attribute updates |
| Complex business logic | Lambda | Multi-step workflows |
| Batch data import | Lambda | CSV processing |
| Data export generation | Lambda | Report generation → S3 |
| Auth triggers | Lambda | Post-confirmation |
| Scheduled jobs | Lambda | Cleanup tasks |
Does it make a business decision?
└─ Yes → Lambda
└─ No → Does it call AWS services (SES, Cognito Admin, S3)?
└─ Yes → Lambda
└─ No → Does it need audit/logging?
└─ Yes → Lambda
└─ No → Could a malicious client exploit it?
└─ Yes → Lambda
└─ No → Frontend or BFF is acceptable
When: Adding a new mutation or query backed by Lambda
Procedure:
amplify/functions/:// amplify/functions/my-function/resource.ts
import { defineFunction } from "@aws-amplify/backend";
export const myFunction = defineFunction({
name: "project-my-function",
entry: "./handler.ts",
timeoutSeconds: 30,
memoryMB: 256,
resourceGroupName: "data", // Required for AppSync resolvers
});
// amplify/functions/my-function/handler.ts
import type { AppSyncResolverHandler } from "aws-lambda";
interface Args { email: string; }
interface Result { id: string; status: string; }
export const handler: AppSyncResolverHandler<Args, Result> = async (event) => {
const { email } = event.arguments;
const claims = (event.identity as { claims?: Record<string, string> })?.claims;
// Business logic here
return { id: "...", status: "success" };
};
amplify/data/resource.ts:import { myFunction } from "../functions/my-function/resource";
const schema = a.schema({
// Models...
myMutation: a
.mutation()
.arguments({ email: a.string().required() })
.returns(a.customType({
id: a.string(),
status: a.string(),
}))
.handler(a.handler.function(myFunction))
.authorization((allow) => [allow.authenticated()]),
});
amplify/backend.ts:import { PolicyStatement, Effect } from "aws-cdk-lib/aws-iam";
backend.myFunction.resources.lambda.addToRolePolicy(
new PolicyStatement({
effect: Effect.ALLOW,
actions: ["ses:SendEmail"],
resources: ["arn:aws:ses:eu-west-2:*:identity/*"],
})
);
amplify/backend.ts:import { myFunction } from "./functions/my-function/resource";
// Add to backend definition
const { data, errors } = await client.mutations.myMutation({
email: userEmail,
});
When: Lambda needs to write to DynamoDB directly (not via AppSync)
Critical: Include __typename and updatedAt fields:
const item = {
__typename: "ModelName", // Must match your model name exactly
id: uuid(),
// ... other fields
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await dynamoClient.send(new PutCommand({
TableName: TABLE_NAME,
Item: item,
}));
Without __typename, Amplify Data Client's .get() returns null even though the record exists in DynamoDB.
__typename in Direct DynamoDB WritesProblem: Lambda writes to DynamoDB succeed, but client.models.Model.get() returns null.
Root cause: Amplify Data Client expects __typename: "ModelName" field. Records created via client.models.create() include this automatically. Direct DynamoDB writes don't.
Solution:
const invitation = {
__typename: "TenantInvitation", // Required!
id: invitationId,
email: email.toLowerCase(),
// ... other fields
createdAt: now.toISOString(),
updatedAt: now.toISOString(), // Also expected by Amplify
};
Problem: Sandbox deployment fails with circular dependency error.
Root cause: Lambda functions used as AppSync resolvers are in a separate CloudFormation stack from data resources by default.
Solution: Add resourceGroupName: "data" to Lambda definition:
export const myFunction = defineFunction({
name: "project-my-function",
entry: "./handler.ts",
resourceGroupName: "data", // Places in same stack as data resources
});
Problem: Deployment fails with Object type extension 'Mutation' cannot redeclare field createTenant.
Root cause: Amplify auto-generates create, update, delete mutations for models defined with a.model(). Custom mutations with same names conflict.
Solution: Use prefixed names:
// ❌ Conflicts with auto-generated createTenant
createTenant: a.mutation()...
// ✅ Unique name, no conflict
adminCreateTenant: a.mutation()...
Problem: SES emails work locally but fail in deployed environments with permission errors.
Root cause: Next.js SSR compute (Lambda@Edge) doesn't have explicit IAM policies. Only custom Lambda functions defined in amplify/functions/ have manageable IAM.
Solution: Move AWS service calls to Lambda functions, wire via AppSync.
Problem: Email links point to wrong environment (e.g., production URL in sandbox emails).
Root cause: Lambda APP_URL environment variable set to production/staging, but test data only exists in sandbox.
Solution for E2E tests: Extract path from email, navigate to test server's path:
// Extract /invite/abc-123 from full URL
const path = emailBody.match(/\/invite\/([a-zA-Z0-9-]+)/)?.[0];
await page.goto(path); // Goes to localhost/invite/abc-123
Context: Sandboxes run in the dev AWS account with doppler run --config dev credentials.
Implication: If something works in deployed dev but not sandbox, it's likely a code/config difference, not AWS permissions. Both use the same AWS account and SES configuration.
// ✅ Invitation logic in Lambda
// amplify/functions/org-invite-user/handler.ts
export const handler: AppSyncResolverHandler<Args, Result> = async (event) => {
// 1. Authorization check
const claims = event.identity?.claims;
if (claims?.["custom:role"] !== "owner" && claims?.["custom:role"] !== "admin") {
throw new Error("Forbidden");
}
// 2. Business validation
const existingUser = await checkUserExists(email);
if (existingUser) throw new Error("User already exists");
// 3. Create invitation record
await dynamoClient.send(new PutCommand({ ... }));
// 4. Send email
await sesClient.send(new SendEmailCommand({ ... }));
return { id: invitationId, status: "pending" };
};
// ❌ Business logic in Next.js API route
// src/app/api/org/invite/route.ts
export async function POST(request: NextRequest) {
// Authorization, validation, DynamoDB, SES all in one place
// - No explicit IAM permissions
// - Harder to test
// - Mixed responsibilities
}
// ✅ BFF pattern - session extraction and formatting only
// src/app/api/org/users/route.ts
export async function GET() {
const session = await getSession();
const tenantId = session.claims["custom:tenant_id"];
// Delegate to AppSync
const { data } = await client.models.User.list({
filter: { tenantId: { eq: tenantId } },
});
// Format for frontend
return NextResponse.json(data.map(u => ({
id: u.id,
email: u.email,
role: u.role,
})));
}
Remember:
__typename in direct DynamoDB writesresourceGroupName: "data" for resolver Lambdas