Host a Framer site on AWS S3 and CloudFront

Host a converted Framer site on AWS: build a static Vite site, upload dist/ to a private S3 bucket, serve it with CloudFront, clean URLs, 404s and redirects.

Published
Reading time
5 min
Deframer cover: “S3 and a CDN, clean URLs.” Beside it, a list of dist/, S3 bucket, Clean URLs and CloudFront with CloudFront selected
On this page11 sections

To host a Framer site on AWS S3 and CloudFront, convert the published site to a static Vite project, run npm run build to prerender every page into dist/, upload that folder to a private S3 bucket, and serve it through a CloudFront distribution. Two things need a little setup that other static hosts do for you: a small CloudFront Function that maps clean URLs such as /about to about.html, and an error response that serves the site's 404.html with a 404 status.

Deframer is an independent tool and is not affiliated with, endorsed or sponsored by Framer or Amazon Web Services.

Why static, and why Vite

S3 stores files and CloudFront delivers them; neither runs your Node.js code. Of Deframer's five targets, only Vite produces a site that is nothing but files:

  • Every page is prerendered at build time, CMS items included, into a file at its URL: index.html for /, about.html for /about, blog/index.html where the site publishes /blog/ with a trailing slash.
  • The site's 404 page is 404.html.
  • The site's redirects come as public/_redirects (for Netlify and Cloudflare Pages), vercel.json, and a small redirect page at each old URL for hosts with neither, which is the case on S3.
  • Forms post to a form service you choose at build time, since there's no server.

If you need server features, use a Node.js target on a container service instead; self-hosting with Docker covers that.

Step 1: Convert and build

  1. On deframer.co, paste your published site's address and pick Vite. Converting is free.
  2. Choose Assets in the project if you want AWS to serve images, fonts and videos too. The default keeps them on Framer's CDN.
  3. Verify the site is yours (a meta tag in Framer's Custom Code, then publish) and download. One verified download is 1 credit; see pricing. Downloads are only for sites you own or have permission to convert.

Then, with Node.js 22.18 or later:

sh
npm install
VITE_FORM_ENDPOINT=https://your-form-service.example/f/abc npm run build
npm run preview

Leave out VITE_FORM_ENDPOINT if the site has no forms. The README lists a per-form variable for each form if you want them to go to different places.

Step 2: Create a private bucket

Create an S3 bucket in the region you prefer and keep Block all public access on. CloudFront reads from it with Origin Access Control, so the bucket itself never needs to be public, and S3's static website hosting feature stays off.

Upload the build:

sh
aws s3 sync dist/ s3://my-site-bucket/ --delete

The AWS CLI sets each file's Content-Type from its extension. Check that .html, .js, .css, .woff2 and .svg files come out right in the console if anything renders oddly.

Step 3: Create the CloudFront distribution

  1. Origin: the S3 bucket (its REST endpoint, not the website endpoint), with Origin Access Control. Let CloudFront update the bucket policy, or paste the policy it shows.
  2. Viewer protocol policy: redirect HTTP to HTTPS.
  3. Default root object: index.html.
  4. Alternate domain names: your domain, plus a certificate from AWS Certificate Manager. For CloudFront, request the certificate in the us-east-1 region.
  5. Compression on.

Step 4: Clean URLs with a CloudFront Function

A request for /about asks S3 for an object called about, which doesn't exist; the file is about.html. A viewer-request CloudFront Function fixes that by rewriting the URI before it reaches S3:

js
function handler(event) {
  var request = event.request;
  var uri = request.uri;
  if (uri.endsWith("/")) {
    request.uri = uri + "index.html";
  } else if (!uri.split("/").pop().includes(".")) {
    request.uri = uri + ".html";
  }
  return request;
}

Attach it to the default behavior as a viewer request function. / becomes /index.html, /blog/ becomes /blog/index.html, /about becomes /about.html, and files with an extension (scripts, images, sitemap.xml) pass through untouched.

Step 5: The 404 page

With Origin Access Control, S3 answers a missing object with 403, not 404. In the distribution's error pages, add custom error responses for both 403 and 404 that return /404.html with the HTTP status 404. Search engines then see a real 404, and visitors see the site's own 404 design.

On a multi-language site, each locale has its own 404.html in its folder. CloudFront's error responses take one page per status code, so either accept the default locale's 404 page everywhere, or extend the function above to check the locale prefix with your own logic.

Step 6: Redirects

The redirects you set in Framer's site settings are handled two ways in the build:

  • Redirect pages. For each redirected URL, the project writes a tiny HTML page (old.html for /old) that sends the browser to the new address. It works on S3 with no setup, but it's a page load with status 200, not an HTTP redirect.
  • _redirects. The same list, in the Netlify format, is in dist/_redirects. S3 doesn't read it, but it's a handy source.

If the old URLs have backlinks or rankings, use real 301s. Add them to the CloudFront Function before the clean-URL rewrite:

js
var redirects = { "/old-page": "/new-page", "/pricing-2024": "/pricing" };

function handler(event) {
  var request = event.request;
  var to = redirects[request.uri];
  if (to) {
    return { statusCode: 301, statusDescription: "Moved Permanently", headers: { location: { value: to } } };
  }
  // … the clean-URL rewrite from step 4
  return request;
}

Copy the pairs from dist/_redirects. Keep the list short; CloudFront Functions have a size limit. Keeping SEO when moving off Framer explains why real redirects matter.

Step 7: Caching

Vite writes scripts and styles with content hashes in their names, so they can be cached for a long time; HTML should be revalidated so updates show up quickly. Upload them in two passes:

sh
aws s3 sync dist/ s3://my-site-bucket/ --delete \
  --exclude "*.html" --cache-control "public, max-age=31536000, immutable"
aws s3 sync dist/ s3://my-site-bucket/ \
  --exclude "*" --include "*.html" --cache-control "public, max-age=0, must-revalidate"
aws cloudfront create-invalidation --distribution-id YOUR_ID --paths "/*"

Don't mark unhashed files in public/ (your own images, robots.txt) as immutable if you'll change them; give them a shorter max age, or rename them when they change.

Step 8: Point the domain

If your DNS is in Route 53, create an alias A (and AAAA) record to the distribution. Elsewhere, add a CNAME for www to the distribution's domain; an apex domain needs a provider that supports ALIAS or flattening. Lower TTLs a day ahead, test the *.cloudfront.net address first, and remove the domain from Framer after the switch. Moving a domain off Framer without downtime has the full order.

Limitations to know

  • New CMS items added in Framer after conversion have no prerendered page until you convert again; they render in the browser from 404.html, with the 404 status.
  • Split tests don't run on a static site; visitors get each tested page's original.
  • You maintain the function and error pages yourself. A host that reads _redirects (Netlify, Cloudflare Pages) needs less setup.

FAQ

Can I host a Framer site on S3 alone, without CloudFront?

S3's static website endpoint can serve the files, but only over HTTP, and it needs a public bucket. CloudFront adds HTTPS on your domain, caching and the URL rewrite. Use both.

Why do I get Access Denied for pages like /about?

S3 is being asked for an object named about. Add the CloudFront Function that rewrites /about to /about.html, and map 403 errors to 404.html with a 404 status.

Do forms work on S3?

Yes, through a form service set with VITE_FORM_ENDPOINT at build time. Nothing runs on AWS for them.

How do I update the site?

Convert again after you republish in Framer, rebuild, sync dist/ and invalidate CloudFront. A CI job (for example GitHub Actions with AWS credentials) can do it on every push.

Can I host a Next.js export on S3 instead?

A converted Next.js project uses redirects and API routes, which a Next.js static export doesn't support. Converting to Vite is the simpler route for S3.

Keep reading

All posts