Our Kids Learn frontend is a Next.js 14 application with server-side rendering for dynamic learning content, static generation for marketing pages, and client-side interactivity for lessons. It needs to load fast — children don’t wait. A 3-second load time means a 4-year-old has already wandered off to watch Bluey.

In this part, we deploy the frontend with three AWS services working in concert: Amplify Hosting runs our Next.js application with full SSR support, S3 stores our media assets (lesson images, audio files, celebration animations), and CloudFront serves everything from 600+ edge locations worldwide.

This is Part 3 of the AWS Full-Stack Mastery series. We’re building on the CDK infrastructure from Part 2.

Frontend architecture — Next.js on Amplify with CloudFront CDN and S3 media storage

Understanding Amplify Gen 2

AWS Amplify has two generations. Gen 1 is configuration-driven — you define resources in amplify/ config files that get mapped to CloudFormation behind the scenes. Gen 2, launched in late 2024, is code-driven — you define everything in TypeScript using CDK under the hood.

We use Amplify Gen 2 for the frontend hosting because it handles the hard parts of deploying Next.js on AWS:

  • SSR functions automatically deploy as Lambda functions behind CloudFront
  • Static pages pre-render at build time and serve from S3
  • API routes deploy as Lambda functions
  • Image optimization works out of the box with the Next.js <Image> component
  • Incremental Static Regeneration (ISR) caches pre-rendered pages at the edge

Amplify vs. Self-Hosting Next.js

Could we deploy Next.js on ECS Fargate or a standalone Lambda? Yes. But we’d need to handle:

  • CloudFront distribution configuration for SSR vs. static routing
  • Lambda function packaging for the SSR handler
  • S3 bucket configuration for static assets with proper cache headers
  • Build pipeline for Next.js output
  • Cache invalidation on deployment
  • Custom domain and certificate management

Amplify handles all of this automatically. Our team focuses on building lesson experiences, not infrastructure plumbing.

Setting Up Amplify Gen 2 in CDK

The Amplify Stack

// lib/stacks/frontend-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as amplify from '@aws-cdk/aws-amplify-alpha';
import * as codebuild from 'aws-cdk-lib/aws-codebuild';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as route53targets from 'aws-cdk-lib/aws-route53-targets';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
import { EnvironmentConfig } from '../config/environments';

export interface FrontendStackProps extends cdk.StackProps {
  config: EnvironmentConfig;
}

export class FrontendStack extends cdk.Stack {
  public readonly amplifyApp: amplify.App;
  public readonly mediaBucket: s3.Bucket;
  public readonly mediaDistribution: cloudfront.Distribution;

  constructor(scope: Construct, id: string, props: FrontendStackProps) {
    super(scope, id, props);

    const { config } = props;

    // =========================================
    // Amplify Hosting — Next.js Application
    // =========================================
    this.amplifyApp = new amplify.App(this, 'KidsLearnApp', {
      appName: `kidslearn-${config.envName}`,
      
      sourceCodeProvider: new amplify.GitHubSourceCodeProvider({
        owner: 'kidslearn',
        repository: 'web-app',
        oauthToken: cdk.SecretValue.secretsManager('github-token'),
      }),

      // Build settings for Next.js
      buildSpec: codebuild.BuildSpec.fromObjectToYaml({
        version: 1,
        applications: [
          {
            frontend: {
              phases: {
                preBuild: {
                  commands: [
                    'npm ci --cache .npm --prefer-offline',
                  ],
                },
                build: {
                  commands: [
                    'npm run build',
                  ],
                },
              },
              artifacts: {
                baseDirectory: '.next',
                files: ['**/*'],
              },
              cache: {
                paths: [
                  '.npm/**/*',
                  'node_modules/**/*',
                  '.next/cache/**/*',
                ],
              },
            },
          },
        ],
      }),

      // Environment variables
      environmentVariables: {
        NEXT_PUBLIC_API_URL: `https://api.${config.envName === 'production' 
          ? '' : config.envName + '.'}kidslearn.app`,
        NEXT_PUBLIC_MEDIA_URL: `https://media.${config.envName === 'production' 
          ? '' : config.envName + '.'}kidslearn.app`,
        NEXT_PUBLIC_ENV: config.envName,
        AMPLIFY_MONOREPO_APP_ROOT: '.',
      },

      // Enable SSR for Next.js
      platform: amplify.Platform.WEB_COMPUTE,
    });

    // Branch configuration
    const mainBranch = this.amplifyApp.addBranch('main', {
      autoBuild: config.envName !== 'production', // Auto-build in dev/staging
      stage: config.envName === 'production' ? 'PRODUCTION' : 'DEVELOPMENT',
      performanceMode: config.envName === 'production',
    });

    // Custom domain for production
    if (config.envName === 'production') {
      const domain = this.amplifyApp.addDomain('kidslearn.app', {
        enableAutoSubdomain: false,
      });
      domain.mapRoot(mainBranch);
      domain.mapSubDomain(mainBranch, 'www');
    }

    // =========================================
    // S3 — Media Assets Bucket
    // =========================================
    this.mediaBucket = new s3.Bucket(this, 'MediaBucket', {
      bucketName: `kidslearn-media-${config.envName}-${this.account}`,
      
      // Security
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      
      // Versioning for rollback capability
      versioned: true,
      
      // Lifecycle rules
      lifecycleRules: [
        {
          id: 'IntelligentTiering',
          enabled: true,
          transitions: [
            {
              storageClass: s3.StorageClass.INTELLIGENT_TIERING,
              transitionAfter: cdk.Duration.days(30),
            },
          ],
        },
        {
          id: 'CleanupOldVersions',
          enabled: true,
          noncurrentVersionExpiration: cdk.Duration.days(90),
        },
      ],
      
      // CORS for client-side uploads
      cors: [
        {
          allowedMethods: [s3.HttpMethods.GET, s3.HttpMethods.PUT],
          allowedOrigins: [
            `https://${config.envName === 'production' 
              ? '' : config.envName + '.'}kidslearn.app`,
          ],
          allowedHeaders: ['*'],
          maxAge: 3600,
        },
      ],
      
      removalPolicy: config.enableDeletionProtection 
        ? cdk.RemovalPolicy.RETAIN 
        : cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: !config.enableDeletionProtection,
    });

    // =========================================
    // CloudFront — Media CDN
    // =========================================
    
    // Origin Access Control for S3
    const oac = new cloudfront.S3OriginAccessControl(this, 'MediaOAC', {
      signing: cloudfront.Signing.SIGV4_ALWAYS,
    });

    this.mediaDistribution = new cloudfront.Distribution(this, 'MediaCDN', {
      comment: `Kids Learn Media CDN (${config.envName})`,
      
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(this.mediaBucket, {
          originAccessControl: oac,
        }),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        responseHeadersPolicy: cloudfront.ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT,
        compress: true,
      },
      
      // Cache behavior for different asset types
      additionalBehaviors: {
        '/images/*': {
          origin: origins.S3BucketOrigin.withOriginAccessControl(this.mediaBucket, {
            originAccessControl: oac,
          }),
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          cachePolicy: new cloudfront.CachePolicy(this, 'ImageCachePolicy', {
            cachePolicyName: `kidslearn-images-${config.envName}`,
            defaultTtl: cdk.Duration.days(30),
            maxTtl: cdk.Duration.days(365),
            minTtl: cdk.Duration.days(1),
            enableAcceptEncodingGzip: true,
            enableAcceptEncodingBrotli: true,
          }),
          compress: true,
        },
        '/audio/*': {
          origin: origins.S3BucketOrigin.withOriginAccessControl(this.mediaBucket, {
            originAccessControl: oac,
          }),
          viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          cachePolicy: new cloudfront.CachePolicy(this, 'AudioCachePolicy', {
            cachePolicyName: `kidslearn-audio-${config.envName}`,
            defaultTtl: cdk.Duration.days(90),
            maxTtl: cdk.Duration.days(365),
            minTtl: cdk.Duration.days(7),
          }),
          compress: true,
        },
      },
      
      // Error pages
      errorResponses: [
        {
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: '/404.html',
          ttl: cdk.Duration.minutes(5),
        },
        {
          httpStatus: 403,
          responseHttpStatus: 404,
          responsePagePath: '/404.html',
          ttl: cdk.Duration.minutes(5),
        },
      ],
      
      // Security
      minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
      httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
      
      // Price class — use all edge locations for global audience
      priceClass: config.envName === 'production'
        ? cloudfront.PriceClass.PRICE_CLASS_ALL
        : cloudfront.PriceClass.PRICE_CLASS_100,
    });

    // =========================================
    // Tags
    // =========================================
    cdk.Tags.of(this).add('Project', 'KidsLearn');
    cdk.Tags.of(this).add('Environment', config.envName);
    cdk.Tags.of(this).add('Stack', 'Frontend');

    // =========================================
    // Outputs
    // =========================================
    new cdk.CfnOutput(this, 'AmplifyAppId', {
      value: this.amplifyApp.appId,
      description: 'Amplify Application ID',
    });

    new cdk.CfnOutput(this, 'MediaBucketName', {
      value: this.mediaBucket.bucketName,
      description: 'Media assets S3 bucket',
    });

    new cdk.CfnOutput(this, 'MediaCDNUrl', {
      value: `https://${this.mediaDistribution.distributionDomainName}`,
      description: 'Media CDN distribution URL',
    });
  }
}

Next.js Configuration for Amplify

next.config.js

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Output mode for Amplify SSR
  output: 'standalone',
  
  // Image optimization
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'media.kidslearn.app',
        pathname: '/images/**',
      },
    ],
    formats: ['image/avif', 'image/webp'],
    minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days
  },
  
  // Headers for security and caching
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff',
          },
          {
            key: 'X-Frame-Options',
            value: 'DENY',
          },
          {
            key: 'X-XSS-Protection',
            value: '1; mode=block',
          },
          {
            key: 'Referrer-Policy',
            value: 'strict-origin-when-cross-origin',
          },
          {
            key: 'Permissions-Policy',
            value: 'camera=(), microphone=(self), geolocation=()',
          },
        ],
      },
      {
        // Cache static assets aggressively
        source: '/_next/static/(.*)',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable',
          },
        ],
      },
      {
        // Cache images
        source: '/images/(.*)',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=2592000, stale-while-revalidate=86400',
          },
        ],
      },
    ];
  },

  // Redirect www to root
  async redirects() {
    return [
      {
        source: '/:path*',
        has: [{ type: 'host', value: 'www.kidslearn.app' }],
        destination: 'https://kidslearn.app/:path*',
        permanent: true,
      },
    ];
  },
};

module.exports = nextConfig;

Amplify Configuration File

# amplify.yml — Amplify build configuration
version: 1
applications:
  - frontend:
      phases:
        preBuild:
          commands:
            - npm ci --cache .npm --prefer-offline
        build:
          commands:
            - env | grep -e NEXT_PUBLIC_ >> .env.production
            - npm run build
      artifacts:
        baseDirectory: .next
        files:
          - '**/*'
      cache:
        paths:
          - .npm/**/*
          - node_modules/**/*
          - .next/cache/**/*
    appRoot: .

Performance Optimization

Image Optimization Pipeline

Kids Learn serves thousands of lesson images. We need them fast and efficient.

// src/lib/media.ts — Media asset helper
const MEDIA_CDN_URL = process.env.NEXT_PUBLIC_MEDIA_URL;

interface ImageOptions {
  width?: number;
  height?: number;
  quality?: number;
  format?: 'webp' | 'avif' | 'png';
}

export function getMediaUrl(
  path: string, 
  options: ImageOptions = {}
): string {
  const params = new URLSearchParams();
  
  if (options.width) params.set('w', String(options.width));
  if (options.height) params.set('h', String(options.height));
  if (options.quality) params.set('q', String(options.quality));
  if (options.format) params.set('f', options.format);
  
  const queryString = params.toString();
  return `${MEDIA_CDN_URL}/${path}${queryString ? `?${queryString}` : ''}`;
}

// Usage in components
// <Image src={getMediaUrl('lessons/math/addition-1.png', { width: 600, format: 'webp' })} />

Cache Invalidation Strategy

When we deploy new content, we need CloudFront to start serving the updated version:

// lib/constructs/cache-invalidation.ts
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3n from 'aws-cdk-lib/aws-s3-notifications';
import { Construct } from 'constructs';

export interface CacheInvalidationProps {
  distribution: cloudfront.Distribution;
  bucket: s3.Bucket;
  paths: string[];
}

export class CacheInvalidation extends Construct {
  constructor(scope: Construct, id: string, props: CacheInvalidationProps) {
    super(scope, id);

    const invalidationFn = new lambda.Function(this, 'InvalidationFn', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromInline(`
        const { CloudFrontClient, CreateInvalidationCommand } = 
          require('@aws-sdk/client-cloudfront');
        
        const cf = new CloudFrontClient({});
        
        exports.handler = async (event) => {
          const distributionId = process.env.DISTRIBUTION_ID;
          const paths = JSON.parse(process.env.INVALIDATION_PATHS);
          
          const command = new CreateInvalidationCommand({
            DistributionId: distributionId,
            InvalidationBatch: {
              CallerReference: Date.now().toString(),
              Paths: {
                Quantity: paths.length,
                Items: paths,
              },
            },
          });
          
          await cf.send(command);
          console.log('Cache invalidated for paths:', paths);
        };
      `),
      environment: {
        DISTRIBUTION_ID: props.distribution.distributionId,
        INVALIDATION_PATHS: JSON.stringify(props.paths),
      },
      timeout: cdk.Duration.seconds(30),
    });

    // Grant CloudFront invalidation permissions
    props.distribution.grant(invalidationFn, 'cloudfront:CreateInvalidation');

    // Trigger on S3 object uploads
    props.bucket.addEventNotification(
      s3.EventType.OBJECT_CREATED,
      new s3n.LambdaDestination(invalidationFn),
    );
  }
}

Pre-rendering Lesson Pages

For lessons that don’t change frequently, we pre-render at build time:

// src/app/lessons/[subject]/[lessonId]/page.tsx
import { Metadata } from 'next';

// Generate static pages for all published lessons
export async function generateStaticParams() {
  const lessons = await fetch(`${process.env.API_URL}/lessons/published`, {
    next: { revalidate: 3600 }, // Revalidate every hour
  }).then(res => res.json());

  return lessons.map((lesson: { subject: string; id: string }) => ({
    subject: lesson.subject,
    lessonId: lesson.id,
  }));
}

// ISR: revalidate every 30 minutes
export const revalidate = 1800;

export async function generateMetadata(
  { params }: { params: { subject: string; lessonId: string } }
): Promise<Metadata> {
  const lesson = await getLesson(params.subject, params.lessonId);
  
  return {
    title: `${lesson.title} | Kids Learn`,
    description: lesson.description,
    openGraph: {
      title: lesson.title,
      description: lesson.description,
      images: [lesson.thumbnailUrl],
    },
  };
}

async function getLesson(subject: string, lessonId: string) {
  const res = await fetch(
    `${process.env.API_URL}/lessons/${subject}/${lessonId}`,
    { next: { revalidate: 1800 } }
  );
  return res.json();
}

export default async function LessonPage(
  { params }: { params: { subject: string; lessonId: string } }
) {
  const lesson = await getLesson(params.subject, params.lessonId);

  return (
    <main className="lesson-container">
      <h1>{lesson.title}</h1>
      {/* Lesson content rendered here */}
    </main>
  );
}

Uploading Media Assets

We use presigned URLs to let the admin dashboard upload media directly to S3 without routing through our servers:

// src/lambda/media/presigned-url.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { APIGatewayProxyHandler } from 'aws-lambda';

const s3 = new S3Client({});

export const handler: APIGatewayProxyHandler = async (event) => {
  const body = JSON.parse(event.body || '{}');
  const { fileName, contentType, folder } = body;

  // Validate content type
  const allowedTypes = [
    'image/png', 'image/jpeg', 'image/webp', 'image/svg+xml',
    'audio/mpeg', 'audio/wav', 'audio/ogg',
    'video/mp4', 'video/webm',
  ];

  if (!allowedTypes.includes(contentType)) {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: 'Unsupported content type' }),
    };
  }

  // Generate unique key
  const key = `${folder}/${Date.now()}-${fileName}`;

  const command = new PutObjectCommand({
    Bucket: process.env.MEDIA_BUCKET!,
    Key: key,
    ContentType: contentType,
    // Set cache headers at upload time
    CacheControl: 'public, max-age=2592000',
    // Server-side encryption
    ServerSideEncryption: 'AES256',
  });

  const url = await getSignedUrl(s3, command, { expiresIn: 300 });

  return {
    statusCode: 200,
    headers: {
      'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN!,
    },
    body: JSON.stringify({
      uploadUrl: url,
      key,
      cdnUrl: `${process.env.MEDIA_CDN_URL}/${key}`,
    }),
  };
};

Custom Domain Setup

Route 53 + ACM Certificate

// lib/constructs/custom-domain.ts
import * as cdk from 'aws-cdk-lib';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53targets from 'aws-cdk-lib/aws-route53-targets';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import { Construct } from 'constructs';

export interface CustomDomainProps {
  domainName: string;
  subdomain: string;
  distribution: cloudfront.Distribution;
}

export class CustomDomain extends Construct {
  public readonly certificate: acm.Certificate;

  constructor(scope: Construct, id: string, props: CustomDomainProps) {
    super(scope, id);

    const fqdn = `${props.subdomain}.${props.domainName}`;

    // Look up existing hosted zone
    const hostedZone = route53.HostedZone.fromLookup(this, 'Zone', {
      domainName: props.domainName,
    });

    // SSL certificate (must be in us-east-1 for CloudFront)
    this.certificate = new acm.Certificate(this, 'Certificate', {
      domainName: fqdn,
      validation: acm.CertificateValidation.fromDns(hostedZone),
    });

    // DNS record pointing to CloudFront
    new route53.ARecord(this, 'AliasRecord', {
      zone: hostedZone,
      recordName: props.subdomain,
      target: route53.RecordTarget.fromAlias(
        new route53targets.CloudFrontTarget(props.distribution)
      ),
    });
  }
}

The Bottom Line

The frontend deployment stack gives Kids Learn:

  • Sub-second page loads — static pages served from CloudFront edge locations
  • Dynamic SSR — personalized lesson content rendered on demand via Lambda
  • Global media delivery — images and audio served from the nearest edge location
  • Zero-downtime deployments — Git push triggers automatic build and deploy
  • Cost efficiency — pay only for compute time and data transfer

In Part 4, we build the backend — API Gateway routing requests to Lambda functions for fast operations, and ECS Fargate for the long-running adaptive learning engine.

See you in Part 4.


This is Part 3 of a 10-part series: AWS Full-Stack Mastery for Technical Leads.

Series outline:

  1. Why AWS & Getting Started — Decision framework, Well-Architected, account setup (Part 1)
  2. Infrastructure as Code (CDK) — CDK v2, constructs, multi-stack, VPC design (Part 2)
  3. Frontend (Amplify + CloudFront) — Next.js SSR, S3, CDN, custom domains (this post)
  4. Backend (API Gateway + Lambda + Fargate) — REST APIs, serverless compute, containers (Part 4)
  5. Database (Aurora + DynamoDB + ElastiCache) — PostgreSQL, NoSQL, caching (Part 5)
  6. AI/ML (Bedrock + SageMaker) — Content generation, custom models, RAG (Part 6)
  7. DevOps (CodePipeline + CodeBuild) — CI/CD, Docker, blue/green deploys (Part 7)
  8. Security (IAM + Cognito + WAF) — Least privilege, auth, COPPA compliance (Part 8)
  9. Observability (CloudWatch + X-Ray) — Metrics, tracing, cost management (Part 9)
  10. Production (Multi-Region + DR) — Scaling, disaster recovery, load testing (Part 10)

References

Export for reading

Comments