Deploy static sites, SPAs, and SSR (Next.js) applications to AWS using CloudFront + S3 + Lambda.
Hosting must be defined in amplify/hosting.ts — a separate file from amplify/backend.ts. This is because hosting deploys as an independent CloudFormation stack, allowing you to deploy frontend and backend independently.
amplify/
├── backend.ts ← defineBackend() — auth, data, storage
├── hosting.ts ← defineHosting() — CloudFront, S3, Lambda
└── ...
// amplify/hosting.ts
import { defineHosting } from '@aws-amplify/hosting';
defineHosting({
framework: 'spa',
buildCommand: 'npm run build',
});
Note:
defineHosting()is designed to be invoked by the Amplify CLI (ampx deploy). It synthesizes a CloudFormation template only when it receives an internal'amplifySynth'IPC message from the CLI. Runningamplify/hosting.tsdirectly withnpx cdk synthornodewill not produce a CloudFormation template. If you need to use the hosting construct in a standalone CDK app, useAmplifyHostingConstructfrom@aws-amplify/hosting/constructsinstead — see Standalone CDK Usage below.
// amplify/hosting.ts
import { defineHosting } from '@aws-amplify/hosting';
defineHosting({
framework: 'nextjs',
buildCommand: 'npm run build',
});
Note: You do not need to configure
output: 'standalone'innext.config.js. The adapter uses @opennextjs/aws internally, which handles the build transformation automatically.
# Deploy everything (backend + frontend)
npx ampx deploy --identifier prod
# Deploy only backend (auth, data, storage)
npx ampx deploy --identifier prod --backend
# Deploy only frontend (hosting) — requires backend deployed first
npx ampx deploy --identifier prod --frontend
❌ WRONG — Do NOT add hosting to defineBackend:
// amplify/backend.ts — THIS IS WRONG
import { defineBackend } from '@aws-amplify/backend';
import { hosting } from './hosting/resource';
defineBackend({ hosting }); // ❌ Will not work
✅ CORRECT — Use a separate amplify/hosting.ts file:
// amplify/hosting.ts — THIS IS CORRECT
import { defineHosting } from '@aws-amplify/hosting';
defineHosting({
framework: 'spa',
buildCommand: 'npm run build',
});
Hosting is a standalone CDK entry point. The CLI discovers amplify/hosting.ts automatically and deploys it as a separate CloudFormation stack.
The hosting package uses a two-layer architecture:
DeployManifestThe construct is completely framework-agnostic. It never knows whether the manifest came from Next.js, Astro, or a custom adapter.
Implementation note: The framework adapters and the L3 CDK construct are provided by
@aws-blocks/hosting.@aws-amplify/hostingre-exports them (the construct under theAmplifyHostingConstructalias) and adds the Amplify-specific glue —defineHosting(),definePipeline(), and the backend-output integration.
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
│ Framework │ │ Deploy Manifest │ │ AWS Resources │
│ Build Output │ ──► │ (generic JSON) │ ──► │ (CDK L3 Construct) │
└─────────────────┘ └──────────────────┘ └─────────────────────┘
adapter() AmplifyHostingConstruct
| Adapter | Description |
|---|---|
| Next.js | Uses @opennextjs/aws to process Next.js build output. Supports App Router, Pages Router, ISR, middleware, image optimization, and response streaming. |
| Nitro | Nitro-based SSR output (.output/). The engine behind Nuxt and Astro's Node/Lambda targets. |
| Nuxt | Nuxt 3 apps (built on the Nitro adapter). |
| Astro | Astro SSR apps using the Node/Lambda adapter (built on the Nitro adapter). |
| SPA | Static single-page apps (React, Vue, Angular, etc.). All routes serve index.html with client-side routing. |
All of the above are exported from @aws-amplify/hosting/adapters (nextjsAdapter, nitroAdapter, nuxtAdapter, astroAdapter, spaAdapter) and auto-selected by framework detection; you only need a custom adapter for a framework not listed here.
Next.js ISR with revalidateTag() and revalidatePath() is fully supported. The adapter automatically provisions:
revalidateTag()No configuration required — if your Next.js app uses ISR, the infrastructure is provisioned automatically.
Next.js <Image> component and next/image optimization work out of the box. A dedicated Lambda function handles:
Next.js middleware runs as a Lambda@Edge function, supporting:
SSR responses are streamed using Lambda response streaming (via Function URLs), reducing Time to First Byte (TTFB) for server-rendered pages.
npm run build)DeployManifest (Next.js uses OpenNext internally)Timelines:
| Prop | Type | Default | Description |
|---|---|---|---|
framework |
'nextjs' | 'spa' | 'static' | string |
auto-detected | Framework type. Auto-detected from package.json. |
buildCommand |
string |
- | Build command to run before deployment. |
domain |
{ domainName, hostedZone } |
- | Custom domain with SSL. Requires Route53 hosted zone. |
waf |
{ enabled, rateLimit? } |
- | Enable AWS WAF with managed rules + rate limiting. Adds ~$5/month. |
customAdapter |
FrameworkAdapterFn |
- | Custom framework adapter for unsupported frameworks. |
compute |
{ memorySize?, timeout?, logRetention?, reservedConcurrency? } |
1024MB, 30s |
Lambda configuration for SSR. |
cdn.priceClass |
PriceClass |
PRICE_CLASS_100 |
CloudFront price class. Use PRICE_CLASS_ALL for global distribution. |
cdn.contentSecurityPolicy |
string |
restrictive default | Custom CSP header value. |
cdn.geoRestriction |
{ type, countries } |
- | Geo-restriction for CloudFront distribution. |
storage.retainOnDelete |
boolean |
false |
Retain S3 bucket on stack deletion. |
storage.encryption |
'S3_MANAGED' | 'KMS' |
'S3_MANAGED' |
Encryption type for the hosting bucket. |
logging.enabled |
boolean |
false |
Enable CloudFront access logging to S3. |
logging.retentionDays |
number |
90 |
Days to retain access logs. |
⚠️ Production warning: By default
storage.retainOnDeleteisfalse, which means the S3 bucket and all hosted assets are permanently deleted when the CloudFormation stack is destroyed. This is convenient for dev/test but risky for production. For production stacks, setstorage: { retainOnDelete: true }to preserve the bucket on stack deletion. In standalone CDK usage, you can also setremovalPolicy: RemovalPolicy.RETAINon the construct's bucket directly.
Requires a Route53 hosted zone in the same AWS account. ACM certificate is automatically created and validated via DNS.
// amplify/hosting.ts
import { defineHosting } from '@aws-amplify/hosting';
defineHosting({
domain: {
domainName: 'app.example.com',
hostedZone: 'example.com',
},
});
First deploy with a custom domain takes longer (2-5 extra minutes with Route53, potentially much longer with external DNS) because ACM certificate validation blocks the CloudFormation stack until the certificate is issued.
External DNS users: You must manually create a CNAME record for ACM validation. CloudFormation will wait up to 72 hours for certificate validation before timing out and rolling back.
Recommendation: Keep your Route53 hosted zone in the same AWS account for the smoothest experience. DNS validation records are created automatically.
Changing domainName after initial deploy causes 5-30 minutes of downtime while the certificate is replaced and CloudFront is reconfigured. For zero-downtime domain migration:
Enables AWS Managed Rules (Common Rule Set + Known Bad Inputs) and IP-based rate limiting (1000 req/5min/IP default).
// amplify/hosting.ts
import { defineHosting } from '@aws-amplify/hosting';
defineHosting({
waf: { enabled: true, rateLimit: 1000 },
});
Cost: ~$5/month base + $1/million requests. Use for production apps with security requirements.
WAF with CloudFront scope requires deployment in us-east-1. Deploying with WAF enabled in other regions will fail with a clear error message.
ampx deploy uses a two-phase deployment model:
ampx deploy --backend): Deploys auth, data, and storage resources. Generates amplify_outputs.json.ampx deploy --frontend): Runs your build command (with amplify_outputs.json available), then deploys hosting resources.Running ampx deploy without flags deploys both phases sequentially.
| Flag | Behavior |
|---|---|
| (none) | Deploy backend + frontend |
--backend |
Deploy backend only (skip hosting) |
--frontend |
Deploy frontend only (requires backend to be deployed first) |
Note: --backend and --frontend are mutually exclusive. Specifying both is an error.
defineHosting is not supported in ampx sandbox. Hosting resources are silently skipped during sandbox development. Use ampx deploy --identifier <name> for full hosting deployment.
defineHosting is not supported with ampx pipeline-deploy (branch deployments). Use ampx deploy for standalone hosting deployment.
HEAD request Content-Length parityA HEAD request to an SSR Lambda returns Content-Length: 0 instead of the would-be GET body length. This is a Nitro / Lambda Function URL upstream limitation: the framework's HTTP server doesn't pre-compute the body for a HEAD request, and AWS's response-streaming wrapper passes through whatever the framework sets.
Affected: download managers, CDN HEAD pre-flights, RFC 9110 §9.3.2 strict clients.
Workaround: clients that need an exact length should issue GET with Range: bytes=0-0 (returns the first byte + Content-Range) instead of HEAD.
When two Amplify Hosting deployments front the same domain (e.g. shop on / + blog on /blog/* via cross-zone rewrites), navigations between zones currently emit Cache-Control: private, no-cache and re-roundtrip both Lambdas on every navigation.
Workaround: set explicit s-maxage=N headers on the cross-zone routes via your framework's headers() config. Future versions will surface a per-route adapter knob.
Range requests on streaming endpointsStreaming SSR routes (Nitro nitro.awsLambda.streaming: true, Astro 5, Next.js RSC) silently ignore the Range header — Lambda Function URL streaming buffers the full body before sending. Use a non-streaming endpoint or fall back to a static Range-capable origin (S3 directly, separate file-server Lambda).
For frameworks not built in (Remix, SvelteKit, etc. — note Nuxt and Astro are already built in), provide a custom adapter that returns a DeployManifest:
// amplify/hosting.ts
import { defineHosting } from '@aws-amplify/hosting';
import type { FrameworkAdapterFn } from '@aws-amplify/hosting/adapters';
import type { DeployManifest } from '@aws-amplify/hosting';
import * as path from 'path';
const myAdapter: FrameworkAdapterFn = (projectDir): DeployManifest => ({
version: 1,
compute: {},
staticAssets: { directory: path.join(projectDir, 'dist') },
routes: [{ pattern: '/*', target: 'static' }],
});
defineHosting({
customAdapter: myAdapter,
buildCommand: 'npm run build',
});
import type { DeployManifest } from '@aws-amplify/hosting';
import type { FrameworkAdapterFn } from '@aws-amplify/hosting/adapters';
import * as path from 'path';
const astroSSRAdapter: FrameworkAdapterFn = (projectDir): DeployManifest => ({
version: 1,
compute: {
server: {
type: 'handler',
bundle: path.join(projectDir, 'dist/server'),
handler: 'entry.handler',
placement: 'regional',
streaming: true,
runtime: 'nodejs20.x',
memorySize: 1024,
timeout: 30,
},
},
staticAssets: {
directory: path.join(projectDir, 'dist/client'),
cacheControl: 'public, max-age=31536000, immutable',
},
routes: [
{ pattern: '/_astro/*', target: 'static' },
{ pattern: '/favicon.ico', target: 'static' },
{ pattern: '/*', target: 'server' },
],
});
The DeployManifest is the contract between framework adapters and the L3 construct. Custom adapters must return this shape:
type DeployManifest = {
version: 1;
/** Named compute resources */
compute: Record<string, ComputeResource>;
/** Static asset configuration */
staticAssets: {
directory: string;
cacheControl?: string;
};
/** Route behaviors — maps URL patterns to compute or static */
routes: RouteBehavior[];
/** Cache infrastructure (auto-provisions S3 + DynamoDB + SQS) */
cache?: CacheConfig;
/** Image optimization (auto-provisions a separate Lambda) */
imageOptimization?: ImageConfig;
/** Middleware (deploys to Lambda@Edge) */
middleware?: MiddlewareConfig;
/** Redirects, rewrites, custom headers */
redirects?: Redirect[];
rewrites?: Rewrite[];
headers?: CustomHeader[];
/** Build ID for atomic deployments (auto-generated if omitted) */
buildId?: string;
};
type ComputeResource = {
type: 'handler' | 'http-server' | 'edge';
bundle: string;
handler?: string; // for type: 'handler'
entrypoint?: string; // for type: 'http-server'
port?: number; // for type: 'http-server'
placement: 'regional' | 'global';
streaming?: boolean;
runtime?: string;
memorySize?: number;
timeout?: number;
environment?: Record<string, string>;
};
type RouteBehavior = {
pattern: string; // URL pattern (glob only — CloudFront wildcards * and ?)
target: string; // compute resource name, or 'static'
fallback?: string; // fallback target on error
};
type CacheConfig = {
computeResource: string;
tagRevalidation: boolean; // provisions DynamoDB
revalidationQueue: boolean; // provisions SQS
};
type ImageConfig = {
bundle: string;
handler: string;
formats: string[];
sizes: number[];
};
type MiddlewareConfig = {
bundle: string;
handler: string;
matchers: string[];
};
| Type | Description | AWS Resource |
|---|---|---|
handler |
Native Lambda handler (fastest cold start) | Lambda with Function URL |
http-server |
HTTP server wrapped with Lambda Web Adapter | Lambda + Web Adapter layer |
edge |
Edge function for low-latency global execution | Lambda@Edge |
Customize SSR Lambda settings:
// amplify/hosting.ts
import { defineHosting } from '@aws-amplify/hosting';
defineHosting({
framework: 'nextjs',
compute: {
memorySize: 1024, // MB (default: 1024)
timeout: 60, // seconds (default: 30)
reservedConcurrency: 50, // concurrent executions (default: none)
},
});
The default CSP is intentionally restrictive:
default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; media-src 'self'; object-src 'none'; frame-ancestors 'self'
unsafe-eval is NOT included in the default policy. This was a deliberate security decision — eval() and new Function() are common XSS vectors. Most modern frameworks (including Next.js) work without unsafe-eval.
However, some libraries (e.g., certain template engines, older chart libraries) require eval() at runtime. If your app needs it, use the cdn.contentSecurityPolicy prop to override:
// amplify/hosting.ts
import { defineHosting } from '@aws-amplify/hosting';
defineHosting({
cdn: {
contentSecurityPolicy:
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; media-src 'self'; object-src 'none'; frame-ancestors 'self'",
},
});
AmplifyHostingConstruct works as a standard CDK L3 construct in any CDK project — no Amplify CLI, no defineHosting(), no amplify/ directory required.
Use sub-path imports to pull in only what you need:
// The construct itself
import { AmplifyHostingConstruct } from '@aws-amplify/hosting/constructs';
// Adapters for framework detection and build output transformation
import {
spaAdapter,
nextjsAdapter,
detectFramework,
getAdapter,
} from '@aws-amplify/hosting/adapters';
// Error type for catch clauses
import { HostingError } from '@aws-amplify/hosting/error';
// Manifest types for custom adapters
import type {
DeployManifest,
RouteBehavior,
ComputeResource,
} from '@aws-amplify/hosting';
Dependency note: The sub-path imports (
/constructs,/adapters,/error) do not import any@aws-amplify/*packages at runtime. Only the main entry point (@aws-amplify/hosting) re-exports thedefineHosting()factory which depends on@aws-amplify/plugin-typesand other Amplify packages. If you only use sub-path imports, your project does not need any Amplify packages installed.
import { App, Stack } from 'aws-cdk-lib';
import { AmplifyHostingConstruct } from '@aws-amplify/hosting/constructs';
import type { DeployManifest } from '@aws-amplify/hosting';
import * as path from 'path';
const app = new App();
const stack = new Stack(app, 'MySpaStack', {
env: { account: '123456789012', region: 'us-east-1' },
});
const manifest: DeployManifest = {
version: 1,
compute: {},
staticAssets: { directory: './dist' },
routes: [{ pattern: '/*', target: 'static' }],
};
const hosting = new AmplifyHostingConstruct(stack, 'Hosting', {
manifest,
});
// Access created resources for composition with other constructs
console.log(hosting.distributionUrl); // https://d111111abcdef8.cloudfront.net
console.log(hosting.bucket.bucketName); // auto-generated bucket name
import { App, Stack } from 'aws-cdk-lib';
import { AmplifyHostingConstruct } from '@aws-amplify/hosting/constructs';
import { nextjsAdapter } from '@aws-amplify/hosting/adapters';
const app = new App();
const stack = new Stack(app, 'MyNextjsStack', {
env: { account: '123456789012', region: 'us-east-1' },
});
// Run the OpenNext adapter to produce a DeployManifest
const manifest = nextjsAdapter({ projectDir: process.cwd() });
new AmplifyHostingConstruct(stack, 'Hosting', {
manifest,
compute: {
memorySize: 1024,
timeout: 60,
},
});
The Next.js adapter uses @opennextjs/aws internally to process the build output and produces a manifest with:
import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
import { AmplifyHostingConstruct } from '@aws-amplify/hosting/constructs';
// Certificate MUST be in us-east-1 — CloudFront requirement
const cert = Certificate.fromCertificateArn(
stack,
'MyCert',
'arn:aws:acm:us-east-1:123456789012:certificate/abc-123',
);
new AmplifyHostingConstruct(stack, 'Hosting', {
manifest,
domain: {
domainName: 'app.example.com',
hostedZone: 'example.com',
certificate: cert, // BYO cert — skips deprecated DnsValidatedCertificate
},
});
Important: CloudFront requires ACM certificates to be in
us-east-1. If you provide a certificate from another region, the construct throws anInvalidCertificateRegionErrorat synth time (for concrete ARNs). For cross-stack token ARNs, CloudFront will reject the certificate at deploy time.
The construct is driven by a DeployManifest. Built-in adapters (spaAdapter, nextjsAdapter, nitroAdapter, nuxtAdapter, astroAdapter) process framework build output and produce this manifest. You can write your own adapter for any framework not covered above (Remix, SvelteKit, etc.).
Skeleton adapter for a custom framework:
import * as fs from 'fs';
import * as path from 'path';
import type { DeployManifest, RouteBehavior } from '@aws-amplify/hosting';
/**
* Custom adapter for Astro (example).
* Scans Astro's build output and returns a DeployManifest.
*/
export const astroAdapter = (projectDir: string): DeployManifest => {
const buildOutputDir = path.join(projectDir, 'dist');
if (!fs.existsSync(buildOutputDir)) {
throw new Error(`Build output not found at ${buildOutputDir}`);
}
const hasServerDir = fs.existsSync(path.join(buildOutputDir, 'server'));
const routes: RouteBehavior[] = [];
// Static assets with aggressive caching
routes.push({
pattern: '/_astro/*',
target: 'static',
});
if (hasServerDir) {
// SSR: catch-all goes to compute
routes.push({ pattern: '/*', target: 'server' });
return {
version: 1,
compute: {
server: {
type: 'handler',
bundle: path.join(buildOutputDir, 'server'),
handler: 'entry.handler',
placement: 'regional',
streaming: true,
runtime: 'nodejs20.x',
},
},
staticAssets: {
directory: path.join(buildOutputDir, 'client'),
cacheControl: 'public, max-age=31536000, immutable',
},
routes,
};
}
// Static-only: all routes from S3
routes.push({ pattern: '/*', target: 'static' });
return {
version: 1,
compute: {},
staticAssets: { directory: path.join(buildOutputDir, 'client') },
routes,
};
};
Using a custom adapter with the construct:
import { AmplifyHostingConstruct } from '@aws-amplify/hosting/constructs';
import { astroAdapter } from './astro-adapter';
const manifest = astroAdapter(process.cwd());
new AmplifyHostingConstruct(stack, 'Hosting', {
manifest,
});
The key insight is that any framework can be supported by writing an adapter that returns a DeployManifest. The construct handles all AWS resource creation (S3, CloudFront, Lambda, OAC, etc.) based on the manifest.
Run your build command locally (npm run build) to see full output. The deploy shows first 1000 + last 1000 characters.
This should not happen with the OAC bucket policy. If it does, check that the S3 bucket policy includes the CloudFront distribution ARN.
First deploy creates a CloudFront distribution (~15-20 min). Subsequent deploys are faster (~5 min).
Ensure your Next.js app uses the App Router with revalidateTag() or revalidatePath(). Pages Router ISR (revalidate: N in getStaticProps) is also supported via time-based revalidation.
Check that the image optimization Lambda has sufficient memory (default: 1024MB). Large images may require 1024MB+. Increase via the compute configuration.