Functions & API
Prerequisites: Backend defined in
amplify/backend.tswithdefineBackend({ auth, data }).
Lambda Functions
Define a function in amplify/functions/<name>/resource.ts:
import { defineFunction } from '@aws-amplify/backend';
export const myFunc = defineFunction({
name: 'my-func',
entry: './handler.ts',
timeoutSeconds: 30, // default 3, max 900
memoryMB: 512, // default 512
runtime: 22, // Node.js version (18, 20, 22, 24); default 22
environment: {
TABLE_NAME: 'my-table',
REGION: 'us-east-1',
},
});Create the handler at amplify/functions/<name>/handler.ts:
import type { Handler } from 'aws-lambda';
import { env } from '$amplify/env/my-func';
export const handler: Handler = async (event) => {
const table = env.TABLE_NAME; // typed, from defineFunction environment
return { statusCode: 200, body: JSON.stringify({ table }) };
};Import into amplify/backend.ts:
import { defineBackend } from '@aws-amplify/backend';
import { auth } from './auth/resource';
import { myFunc } from './functions/my-func/resource';
defineBackend({ auth, myFunc });Sharing Code Between Functions
Each Lambda function is bundled independently from its own directory. Importing from a shared directory (amplify/shared/utils.ts) fails at build time.
Options:
- Duplicate the shared code in each function directory
- Symlink:
ln -s ../../shared/utils.ts amplify/functions/my-fn/utils.ts - Package: Create a local npm package and install it in each function's
package.json
Handler Return Types
| Handler Type | Import | Returns |
|---|---|---|
S3Handler |
@types/aws-lambda |
void — async event, no response expected |
APIGatewayProxyHandler |
@types/aws-lambda |
{ statusCode, headers?, body } |
APIGatewayProxyHandlerV2 |
@types/aws-lambda |
{ statusCode, headers?, body } |
Common mistake: Writing an S3 handler like an API handler. S3Handler returns
void, not a response object.
Environment Variables & Secrets
You SHOULD import environment variables from $amplify/env/<function-name>
— this provides type-safe access to values defined in defineFunction.
Values are also available at runtime via process.env.VAR_NAME, but the
$amplify/env import is preferred because it gives you compile-time type
checking and autocompletion.
For sensitive values, use secret():
import { defineFunction, secret } from '@aws-amplify/backend';
export const myFunc = defineFunction({
name: 'my-func',
entry: './handler.ts',
environment: {
API_KEY: secret('MY_API_KEY'),
},
});Set secrets via CLI: echo -n "<value>" | npx ampx sandbox secret set MY_API_KEY.
Important: Use
echo -n(no trailing newline) when piping values tosecret set.Important: The
ampx sandbox secret setcommand is for local/sandbox development only. For apps deployed to Amplify Hosting, secrets must be created via the Amplify console (NOTampx sandbox secret— that's local only) — sandbox secrets are NOT available in hosted environments. See: https://docs.amplify.aws/react/deploy-and-host/fullstack-branching/secrets-and-vars/#set-secrets
Environment Variables in Lambda
Recommended (type-safe):
import { env } from '$amplify/env/my-function';
const tableName = env.TABLE_NAME;Fallback (if $amplify/env causes esbuild bundling errors):
const tableName = process.env.TABLE_NAME!;Scheduled Functions
Use schedule to invoke a function on a cron or natural-language schedule:
import { defineFunction } from '@aws-amplify/backend';
export const cronJob = defineFunction({
name: 'cron-job',
entry: './handler.ts',
schedule: 'every 1h', // natural-language shorthand
// Valid shorthands: 'every 5m', 'every 1h', 'every day', 'every week', 'every month', 'every year'
// OR: schedule: '0 */1 * * ? *', // cron expression — same property
});The handler must use EventBridgeHandler type:
import type { EventBridgeHandler } from 'aws-lambda';
export const handler: EventBridgeHandler<'Scheduled Event', void, void> = async () => {
// scheduled logic
};Resource Access
Grant a function access to other Amplify resources:
const backend = defineBackend({ auth, data, storage, myFunc });
// Grant function access to auth, data, and storage
backend.myFunc.resources.lambda.addEnvironment(
'USER_POOL_ID', backend.auth.resources.userPool.userPoolId
);
backend.data.resources.tables['Todo'].grantReadData(backend.myFunc.resources.lambda);
backend.storage.resources.bucket.grantReadWrite(backend.myFunc.resources.lambda);For data schema access, use allow.resource() in authorization rules:
const schema = a.schema({
Todo: a.model({
content: a.string(),
}).authorization(allow => [allow.resource(myFunc)]),
});Lambda + API Gateway + Data Access
When a Lambda both accesses data tables AND is exposed via API Gateway, use resourceGroupName to avoid circular dependencies:
export const myFunction = defineFunction({
name: 'my-function',
resourceGroupName: 'data', // Places in data stack to avoid circular dep
});Custom Queries and Mutations
Use a.query() and a.mutation() with .handler() to add custom server-side logic through AppSync (no API Gateway needed):
// amplify/data/resource.ts
const schema = a.schema({
// Custom query with Lambda handler
summarize: a.query()
.arguments({ text: a.string().required() })
.returns(a.string())
.handler(a.handler.function(summarizeHandler))
.authorization(allow => [allow.authenticated()]),
// Custom mutation with Lambda handler
processOrder: a.mutation()
.arguments({ orderId: a.string().required() })
.returns(a.json())
.handler(a.handler.function(processOrderHandler))
.authorization(allow => [allow.authenticated()]),
});How
.handler()works:.handler()grants AppSync the permission to invoke the Lambda (AppSync→Lambda). The Lambda IS the resolver — it receives the GraphQL event directly. If the Lambda also needs to call the Data API or access DynamoDB tables for side effects, addallow.resource(fn)to the model withresourceGroupName: 'data'on the function to avoid circular dependencies.// ❌ CIRCULAR DEPENDENCY — manual table grant in backend.ts backend.data.resources.tables["Model"].grantReadData(backend.myFn.resources.lambda); // ✅ Use resourceGroupName to co-locate the function in the data stack const myFn = defineFunction({ name: 'my-fn', resourceGroupName: 'data' }); // Then in the schema: allow.resource(myFn) on the modelGap: The Lambda resolver receives the GraphQL event but does NOT automatically get
TABLE_NAMEas an environment variable. Your Lambda must either:
- Use the Amplify data client (
generateClient()) which discovers tables automatically- Explicitly set env vars:
myFunction.addEnvironment('TABLE_NAME', backend.data.resources.tables['Todo'].tableName)When to use which:
a.query()/a.mutation()with.handler()— AppSync-native, type-safe, uses the data schema. Preferred for most custom logic.- API Gateway + Lambda — Use when you need REST endpoints, webhooks, or third-party integrations that require a specific URL.
REST API (API Gateway)
Create a REST API using CDK in amplify/backend.ts:
import { defineBackend } from '@aws-amplify/backend';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import { myFunc } from './functions/my-func/resource';
const backend = defineBackend({ auth, myFunc });
const apiStack = backend.createStack('RestApiStack');
const api = new apigateway.RestApi(apiStack, 'MyRestApi', {
restApiName: 'my-rest-api',
deployOptions: { stageName: 'prod' },
});
api.root.addResource('items').addMethod(
'GET', new apigateway.LambdaIntegration(backend.myFunc.resources.lambda)
);
backend.addOutput({ custom: { restApiUrl: api.url } });The handler must use APIGatewayProxyHandler type for REST API (v1):
import type { APIGatewayProxyHandler } from 'aws-lambda';HTTP API (API Gateway v2)
For a lightweight HTTP API:
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
import * as apigwv2 from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
const httpApi = new apigwv2.HttpApi(apiStack, 'MyHttpApi', {
corsPreflight: { allowOrigins: ['*'], allowMethods: [apigwv2.CorsHttpMethod.GET] },
});
httpApi.addRoutes({
path: '/items',
methods: [apigwv2.HttpMethod.GET],
integration: new HttpLambdaIntegration('GetItems', backend.myFunc.resources.lambda),
});
backend.addOutput({ custom: { httpApiUrl: httpApi.url! } });The handler must use APIGatewayProxyHandlerV2 type for HTTP API (v2).
Backend Outputs
Use backend.addOutput() to expose custom values to the frontend via
amplify_outputs.json:
backend.addOutput({ custom: { apiUrl: api.url, region: 'us-east-1' } });Frontend reads custom outputs from the configured Amplify outputs.
Calling from Client
For custom queries and mutations defined via a.query() or a.mutation(), call them from the client:
const { data } = await client.queries.summarize({ text: '...' });For REST/HTTP API outputs added via backend.addOutput(), read the endpoint URL from amplify_outputs.json and use standard HTTP clients.
Pitfalls
runtimemust be an integer: Useruntime: 22, NOTruntime: "nodejs22.x". String format causes build errors.Wrong handler type: REST API (v1) requires
APIGatewayProxyHandlerwithevent.httpMethod; HTTP API (v2) requiresAPIGatewayProxyHandlerV2withevent.requestContext.http.method. Mixing them causes malformed responses. Both return{ statusCode, body }.Missing resource access: A function without explicit grants cannot access auth, data, or storage resources — add grants in
backend.ts.Secrets in plain
environment: Sensitive values must usesecret(), not string literals.createStackname collision: Stack names passed tobackend.createStack()must be unique across the backend. Duplicate names cause deployment failures.Missing
@types/node: Lambda functions require@types/nodein devDependencies. Without it,process.envand Node.js globals cause TypeScript errors. Install:npm install --save-dev @types/node@types/aws-lambda: Lambda handlers (S3Handler,PreSignUpTriggerHandler, etc.) need this package for TypeScript types. Install at project root or in the function's directory if it has its ownpackage.json.AppSync identity typing:
event.identityin custom handlers has varying types depending on auth mode. Use type assertion:const identity = event.identity as { username?: string; sub?: string }; const userId = identity?.username || identity?.sub || 'unknown';dataSource: 'NONE': Usinga.handler.custom({ dataSource: 'NONE' })causes "Data source not found" during deployment. Use a Lambda handler instead, or create the NONE data source explicitly via CDK.Lambda error types lost: Custom error classes thrown in Lambda arrive at the frontend as generic
Errorwith only themessagepreserved. Error name, stack, and custom properties are stripped by AppSync. Return structured error data in the response instead.