Here’s the complete solution for every step, including the handler code, the trust policy, all CLI commands, and the expected output at each stage.
Why This Works
- The execution role and trust policy solve different problems: one tells Lambda it may assume the role, and the other tells the role what the function may do after that.
- Packaging
dist/handler.jsinto the deployment zip gives Lambda exactly the artifact it expects at runtime instead of asking it to transpile TypeScript for you. - The CLI invocation and CloudWatch log checks prove both halves of the system work: the code path and the operational path.
If the console or CLI output looks a little different when you do this, keep the aws lambda create-function command reference and the aws lambda update-function-code command reference open.
Project Setup
mkdir -p lambda/src
cd lambda
npm init -y
npm install -D typescript @types/aws-lambda @types/nodelambda/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}lambda/package.json (scripts section)
{
"scripts": {
"build": "tsc"
}
}The Handler
lambda/src/handler.ts
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
interface GreetingResponse {
greeting: string;
timestamp: string;
}
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const name = event.queryStringParameters?.name ?? 'World';
Note The ?. and ?? operators handle the case where queryStringParameters is undefined.
const response: GreetingResponse = {
greeting: `Hello, ${name}!`,
timestamp: new Date().toISOString(),
};
console.log('Greeting request:', { name, timestamp: response.timestamp });
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(response),
};
};Build
cd lambda
npm run buildExpected: dist/handler.js, dist/handler.js.map, dist/handler.d.ts, and dist/handler.d.ts.map are created with no errors.
The Execution Role
trust-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}Create the role
aws iam create-role \
--role-name my-frontend-app-lambda-role \
--assume-role-policy-document file://trust-policy.json \
--region us-east-1 \
--output jsonExpected output:
{
"Role": {
"RoleName": "my-frontend-app-lambda-role",
"Arn": "arn:aws:iam::123456789012:role/my-frontend-app-lambda-role",
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
}
}Attach the basic execution policy
aws iam attach-role-policy \
--role-name my-frontend-app-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole \
--region us-east-1 \
--output jsonVerify the role
aws iam list-attached-role-policies \
--role-name my-frontend-app-lambda-role \
--region us-east-1 \
--output jsonExpected output:
{
"AttachedPolicies": [
{
"PolicyName": "AWSLambdaBasicExecutionRole",
"PolicyArn": "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
]
}Package and Deploy
Create the deployment zip
cd lambda/dist
zip -r ../function.zip .
cd ..Create the function
aws lambda create-function \
--function-name my-frontend-app-api \
--runtime nodejs20.x \
--role arn:aws:iam::123456789012:role/my-frontend-app-lambda-role \
--handler handler.handler \
--zip-file fileb://function.zip \
--region us-east-1 \
--output jsonExpected output:
{
"FunctionName": "my-frontend-app-api",
"FunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:my-frontend-app-api",
"Runtime": "nodejs20.x",
"Role": "arn:aws:iam::123456789012:role/my-frontend-app-lambda-role",
"Handler": "handler.handler",
"CodeSize": 1523,
"Timeout": 3,
"MemorySize": 128,
"LastUpdateStatus": "Successful",
"State": "Active"
}If you get “The role defined for the function cannot be assumed by Lambda,” wait 10-15 seconds and try again. IAM role propagation is eventually consistent—the role exists, but Lambda’s endpoint might not have seen it yet.
Verify the function exists
aws lambda get-function \
--function-name my-frontend-app-api \
--region us-east-1 \
--output jsonInvoke the Function
Test event with name parameter
Save as test-event.json:
{
"requestContext": {
"http": {
"method": "GET",
"path": "/greeting"
}
},
"queryStringParameters": {
"name": "Lambda"
}
}Invoke:
aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-event.json \
--region us-east-1 \
--output json \
response.jsonExpected terminal output:
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}Check the response:
cat response.jsonExpected (formatted for readability):
{
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"body": "{\"greeting\":\"Hello, Lambda!\",\"timestamp\":\"2026-03-18T12:00:00.000Z\"}"
}The body is a stringified JSON object. If you parse it, you get:
{
"greeting": "Hello, Lambda!",
"timestamp": "2026-03-18T12:00:00.000Z"
}In the console, the Test tab shows the same result with the execution status and response body expanded.

Test event without name parameter
Save as test-event-no-name.json:
{
"requestContext": {
"http": {
"method": "GET",
"path": "/greeting"
}
}
}Invoke:
aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-event-no-name.json \
--region us-east-1 \
--output json \
response.json
cat response.jsonExpected body:
{
"greeting": "Hello, World!",
"timestamp": "2026-03-18T12:00:05.000Z"
}The default value "World" is used when queryStringParameters is missing or doesn’t include name.
Read the Logs
Verify the log group exists
aws logs describe-log-groups \
--log-group-name-prefix /aws/lambda/my-frontend-app-api \
--region us-east-1 \
--output jsonExpected output:
{
"logGroups": [
{
"logGroupName": "/aws/lambda/my-frontend-app-api",
"arn": "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/my-frontend-app-api:*",
"storedBytes": 1234
}
]
}Read the latest log events
aws logs describe-log-streams \
--log-group-name /aws/lambda/my-frontend-app-api \
--order-by LastEventTime \
--descending \
--limit 1 \
--region us-east-1 \
--output jsonUse the logStreamName from the response to fetch events:
aws logs get-log-events \
--log-group-name /aws/lambda/my-frontend-app-api \
--log-stream-name "2026/03/18/[$LATEST]abcdef1234567890" \
--region us-east-1 \
--output jsonYou should see log entries that include:
START RequestId: ...- Your
console.logoutput:Greeting request: { name: 'Lambda', timestamp: '...' } END RequestId: ...REPORT RequestId: ... Duration: X.XX ms Billed Duration: XX ms Memory Size: 128 MB Max Memory Used: XX MB Init Duration: XXX.XX ms
The Init Duration line appears only on cold start invocations. If you invoke the function a second time quickly, it won’t appear—the second invocation reused the warm execution environment.
Stretch Goal: Environment Variable
Set the environment variable
aws lambda update-function-configuration \
--function-name my-frontend-app-api \
--environment 'Variables={GREETING_PREFIX=Howdy}' \
--region us-east-1 \
--output jsonUpdate the handler to use it
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';
interface GreetingResponse {
greeting: string;
timestamp: string;
}
const prefix = process.env.GREETING_PREFIX ?? 'Hello';
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const name = event.queryStringParameters?.name ?? 'World';
const response: GreetingResponse = {
greeting: `${prefix}, ${name}!`,
timestamp: new Date().toISOString(),
};
console.log('Greeting request:', { name, prefix, timestamp: response.timestamp });
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(response),
};
};Rebuild and redeploy
cd lambda
npm run build
cd dist && zip -r ../function.zip . && cd ..
aws lambda update-function-code \
--function-name my-frontend-app-api \
--zip-file fileb://function.zip \
--region us-east-1 \
--output jsonInvoke and verify
aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-event.json \
--region us-east-1 \
--output json \
response.json
cat response.jsonExpected body:
{
"greeting": "Howdy, Lambda!",
"timestamp": "2026-03-18T12:05:00.000Z"
}The greeting prefix changed from "Hello" to "Howdy" without changing the code logic—only the environment variable.
Stretch Goal: Cold Start Measurement
Invoke with log tailing:
aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-event.json \
--log-type Tail \
--region us-east-1 \
--output json \
response.jsonThe response includes a LogResult field containing base64-encoded log output. Decode it:
aws lambda invoke \
--function-name my-frontend-app-api \
--cli-binary-format raw-in-base64-out \
--payload file://test-event.json \
--log-type Tail \
--region us-east-1 \
--output json \
response.json \
| python3 -c "import sys,json,base64; print(base64.b64decode(json.load(sys.stdin)['LogResult']).decode())"On a cold start, the output includes:
REPORT RequestId: abc-123 Duration: 12.34 ms Billed Duration: 13 ms
Memory Size: 128 MB Max Memory Used: 67 MB Init Duration: 198.45 msInvoke immediately again. The Init Duration field disappears—the second invocation was a warm start.