├── .gitignore
├── README.md
├── app
├── entry.client.tsx
├── entry.server.tsx
├── root.tsx
└── routes
│ └── index.tsx
├── package.json
├── patches
├── @web-std+blob+3.0.1.patch
├── @web-std+file+3.0.0.patch
└── @web-std+stream+1.0.0.patch
├── public
└── favicon.ico
├── remix.config.js
├── remix.env.d.ts
├── server
└── index.ts
├── serverless.yml
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /server/build
5 | /public/static
6 |
7 | .serverless
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to Remix!
2 |
3 | - [Remix Docs](https://remix.run/docs)
4 | - [Serverless Docs](https://www.serverless.com/framework/docs)
5 |
6 | This package is heavily inspired by Remix's Architect starter (`arc` deployment option in `npx create-remix`).
7 |
8 |
9 | ## Serverless Setup
10 |
11 | When deploying to AWS Lambda with the Serverless framework, you'll need:
12 |
13 | - Serverless CLI (`sls` or `serverless`)
14 | - [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html)
15 |
16 | Serverless recommends installing the serverless cli globally:
17 |
18 | ```sh
19 | npm i -g serverless
20 | ```
21 |
22 | ## Development
23 |
24 | During development, we will use the standard `remix dev` command.
25 |
26 | ```sh
27 | npm run dev
28 | ```
29 |
30 | If you want to use `serverless-offline` with some aspects of your project, you'll need to run that as a separate script, or use something like [concurrently](https://npm.im/concurrently) to run both processes in one command.
31 |
32 | Open up [http://localhost:3000](http://localhost:3000) and you should be ready to go!
33 |
34 | ## Deploying
35 |
36 | Before you can deploy, you'll need to [install the AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html).
37 |
38 | If you want to use the Serverless Dashboard, you'll need to [configure the Serverless CLI](https://www.serverless.com/framework/docs/getting-started/) and add the `app` key to your `serverless.yml` file.
39 |
40 | If you make it through all of that, you're ready to deploy!
41 |
42 | 1. build the app for production:
43 |
44 | ```sh
45 | npm run build
46 | ```
47 |
48 | 2. Deploy with `sls`
49 |
50 | ```sh
51 | sls deploy
52 | ```
53 |
54 | You're in business!
55 |
56 | There's a `deploy` script in `package.json` already that will handle both parts if that's your thing.
57 |
58 | ```sh
59 | # stage defaults to "dev"
60 | npm run deploy
61 | # or
62 | npm run deploy -- --stage production
63 | # or
64 | yarn deploy --stage production
65 | ```
66 |
67 | The first deployment will take a little while longer because it has to set up the CloudFront distribution, so expect it to take about 5 minutes.
68 |
69 | After deploying, if you want to visit your CloudFront endpoint, you can run `sls info --verbose` to get the `WebsiteDomain` output from the Cloudformation stack.
70 |
71 | ## The Result
72 |
73 | After deploying, you'll have an S3 bucket for storing static assets, your Remix app running in AWS Lambda behind API Gateway, and CloudFront as a CDN in front of both services.
74 |
75 | ## Limitations and First-deployment configuration
76 |
77 | There is a limitation when using CloudFront with API Gateway that CloudFront cannot forward the `Host` header. This means that there isn't a default way to know which
78 | domain a user is requesting from by default. This makes `request.url` in Remix show the API Gateway domain instead of the CloudFront domain, which will be noticeable
79 | when using redirects from actions and loaders or building URLs from the request URL. In order to address this,you need to add the `X-Forwarded-Host` to the origin cache
80 | policy for API Gateway. The `@remix-run/architect` adapter will read this header when transforming the API Gateway request for the Remix request handler.
81 |
82 | ### Overview
83 |
84 | 1. Deploy the stack
85 | 2. run `sls info --verbose` to get the `WebsiteDomain` from "Stack Outputs"
86 | 2. Copy the WebsiteDomain into `serverless.yml` under `custom > dev > HOST` and uncomment the `OriginCustomHeaders` block for the `RemixOrigin`.
87 | 3. Deploy again
88 |
89 | ### Deploy the stack for the first time
90 |
91 | Follow the [deployment](#Deploying) steps.
92 |
93 | ### Get the `WebsiteDomain`
94 | _If you [use a custom domain](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html) for your CloudFront distribution, you can skip to the next section._
95 |
96 | After the deployment completes, run the following in your terminal at the project root:
97 |
98 | ```sh
99 | sls info --verbose
100 | ```
101 |
102 | You'll get an output similar to the following:
103 |
104 | ```
105 | Serverless: Running "serverless" installed locally (in service node_modules)
106 | Service Information
107 | service: remix-serverless-app
108 | stage: dev
109 | region: us-east-1
110 | stack: remix-serverless-app-dev
111 | resources: 16
112 | api keys:
113 | None
114 | endpoints:
115 | ANY - https://xxxxxxxxxxxxx.execute-api.us-east-1.amazonaws.com/{proxy+}
116 | functions:
117 | remix: remix-serverless-app-dev-remix
118 | layers:
119 | None
120 |
121 | Stack Outputs
122 | WebsiteDomain: xxxxxxxxxxxx.cloudfront.net
123 | WebsiteBucketName: remix-serverless-app-dev-websitebucket-somerandomid
124 | DistributionID: XXXXXXXXXXXX
125 | HttpApiId: xxxxxxxxxxxxx
126 | RemixLambdaFunctionQualifiedArn: arn:aws:lambda:us-east-1:xxx:function:remix-serverless-app-dev-remix:1
127 | ServerlessDeploymentBucketName: remix-serverless-app-dev-serverlessdeploymentbuck-xxxxxxxx
128 | HttpApiUrl: https://xxxxxxxxxxxxx.execute-api.us-east-1.amazonaws.com
129 | ```
130 |
131 | From this output, copy the `WebsiteDomain` value from the "Stack Outputs" section.
132 |
133 |
134 | ### Add the X-Forwarded-Host header
135 |
136 | In [serverless.yml](serverless.yml#L15), add paste the copied WebsiteDomain value.
137 |
138 | ```yml
139 | custom:
140 | dev:
141 | HOST: xxxxxxxxxxxx.cloudfront.net # Replace this value
142 | ```
143 |
144 | Then, uncomment the [`OriginCustomHeaders` block](serverless.yml#L148):
145 |
146 | ```yml
147 | OriginCustomHeaders:
148 | - HeaderName: X-Forwarded-Host
149 | HeaderValue: ${self:${self:provider.stage}.HOST}
150 | ```
151 |
152 | ### Deploy again!
153 |
154 | ```sh
155 | sls deploy
156 | # or npm run deploy
157 | ```
158 |
159 | After deploying, your Remix app will use the domain from the X-Forwarded-Host header as the domain.
160 |
161 | You'll want to add a domain for the `prod` or any other deployment stages you intend to use as well. If you [configure CloudFront to use a custom domain](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html), you will need to use your custom domain as the value instead of the CloudFront default.
162 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { hydrate } from "react-dom";
2 | import { RemixBrowser } from "remix";
3 |
4 | hydrate(, document);
5 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { renderToString } from "react-dom/server";
2 | import { RemixServer } from "remix";
3 | import type { EntryContext } from "remix";
4 |
5 | export default function handleRequest(
6 | request: Request,
7 | responseStatusCode: number,
8 | responseHeaders: Headers,
9 | remixContext: EntryContext
10 | ) {
11 | const markup = renderToString(
12 |
13 | );
14 |
15 | responseHeaders.set("Content-Type", "text/html");
16 |
17 | return new Response("" + markup, {
18 | status: responseStatusCode,
19 | headers: responseHeaders
20 | });
21 | }
22 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Links,
3 | LiveReload,
4 | Meta,
5 | Outlet,
6 | Scripts,
7 | ScrollRestoration
8 | } from "remix";
9 | import type { MetaFunction } from "remix";
10 |
11 | export const meta: MetaFunction = () => {
12 | return { title: "New Remix App" };
13 | };
14 |
15 | export default function App() {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | {process.env.NODE_ENV === "development" && }
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/app/routes/index.tsx:
--------------------------------------------------------------------------------
1 | export default function Index() {
2 | return (
3 |
4 |
Welcome to Remix
5 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "remix-app-template",
4 | "description": "Remix starter kit for the Serverless Framework",
5 | "license": "MIT",
6 | "scripts": {
7 | "prebuild": "rimraf ./server/build ./public/static",
8 | "build": "remix build",
9 | "dev": "remix dev",
10 | "postinstall": "remix setup node",
11 | "predeploy": "npm run build",
12 | "deploy": "sls deploy"
13 | },
14 | "dependencies": {
15 | "@remix-run/architect": "^1.1.1",
16 | "@remix-run/react": "^1.1.1",
17 | "react": "^17.0.2",
18 | "react-dom": "^17.0.2",
19 | "remix": "^1.1.1"
20 | },
21 | "devDependencies": {
22 | "@remix-run/dev": "^1.1.1",
23 | "@remix-run/serve": "^1.1.1",
24 | "@types/react": "^17.0.24",
25 | "@types/react-dom": "^17.0.9",
26 | "aws-sdk": "^2.1048.0",
27 | "esbuild": "^0.14.9",
28 | "patch-package": "^6.4.7",
29 | "rimraf": "^3.0.2",
30 | "serverless": "^2.70.0",
31 | "serverless-esbuild": "^1.23.2",
32 | "serverless-s3-sync": "^1.17.3",
33 | "typescript": "^4.1.2"
34 | },
35 | "engines": {
36 | "node": ">=14"
37 | },
38 | "sideEffects": false
39 | }
40 |
--------------------------------------------------------------------------------
/patches/@web-std+blob+3.0.1.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/@web-std/blob/tsconfig.json b/node_modules/@web-std/blob/tsconfig.json
2 | deleted file mode 100644
3 | index 2fc2e4e..0000000
4 | --- a/node_modules/@web-std/blob/tsconfig.json
5 | +++ /dev/null
6 | @@ -1,16 +0,0 @@
7 | -{
8 | - "extends": "../../tsconfig.json",
9 | - "compilerOptions": {
10 | - "outDir": "dist",
11 | - "paths": {
12 | - "@web-std/blob": ["packages/blob/src/lib.js"]
13 | - }
14 | - },
15 | - "include": [
16 | - "src",
17 | - "test"
18 | - ],
19 | - "references": [
20 | - { "path": "../fetch"}
21 | - ]
22 | -}
23 |
--------------------------------------------------------------------------------
/patches/@web-std+file+3.0.0.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/@web-std/file/tsconfig.json b/node_modules/@web-std/file/tsconfig.json
2 | deleted file mode 100644
3 | index a9e4037..0000000
4 | --- a/node_modules/@web-std/file/tsconfig.json
5 | +++ /dev/null
6 | @@ -1,21 +0,0 @@
7 | -{
8 | - "extends": "../../tsconfig.json",
9 | - "compilerOptions": {
10 | - "outDir": "dist",
11 | - "paths": {
12 | - "@web-std/file": ["packages/file/src/lib.js"]
13 | - }
14 | - },
15 | - "references": [
16 | - {
17 | - "path": "../blob",
18 | - },
19 | - {
20 | - "path": "../fetch"
21 | - }
22 | - ],
23 | - "include": [
24 | - "src",
25 | - "test"
26 | - ]
27 | -}
28 |
--------------------------------------------------------------------------------
/patches/@web-std+stream+1.0.0.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/@web-std/stream/tsconfig.json b/node_modules/@web-std/stream/tsconfig.json
2 | deleted file mode 100644
3 | index c80f2de..0000000
4 | --- a/node_modules/@web-std/stream/tsconfig.json
5 | +++ /dev/null
6 | @@ -1,12 +0,0 @@
7 | -{
8 | - "extends": "../../tsconfig.json",
9 | - "compilerOptions": {
10 | - "emitDeclarationOnly": false,
11 | - "noEmit": true,
12 | - "outDir": "dist"
13 | - },
14 | - "include": [
15 | - "src",
16 | - "test",
17 | - ]
18 | -}
19 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shamsup/remix-starter-serverless/1f42c0ab4c16eb8c2df825630a68cc88c3c84e04/public/favicon.ico
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('@remix-run/dev/config').AppConfig}
3 | */
4 | module.exports = {
5 | appDirectory: "app",
6 | assetsBuildDirectory: "public/static",
7 | publicPath: "/static/",
8 | serverBuildDirectory: "server/build",
9 | devServerPort: 8002,
10 | };
11 |
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/server/index.ts:
--------------------------------------------------------------------------------
1 | import { createRequestHandler } from "@remix-run/architect";
2 | import type { ServerBuild } from "remix";
3 |
4 | const build = require('./build') as ServerBuild;
5 |
6 | exports.handler = createRequestHandler({
7 | build,
8 | getLoadContext(event) {
9 | // use lambda event to generate a context for loaders
10 | return {};
11 | }
12 | });
13 |
--------------------------------------------------------------------------------
/serverless.yml:
--------------------------------------------------------------------------------
1 | service: 'remix-serverless-app'
2 |
3 | frameworkVersion: '2'
4 |
5 | package:
6 | individually: true
7 |
8 | plugins:
9 | - serverless-esbuild
10 | - serverless-s3-sync
11 |
12 | custom:
13 | # stage-specific variables
14 | dev:
15 | HOST: my-cloudfront-domain.cloudfront.net # replace with the WebsiteDomain from stack outputs
16 | prod:
17 | HOST: my-domain.com # replace with your production url
18 | # serverless-esbuild config, bundles your code and allows you to use typescript or es-next features in your handler code
19 | esbuild:
20 | bundle: true
21 | minify: false
22 | sourcemap: true
23 | sourcesContent: false
24 | exclude: ['aws-sdk']
25 | target: 'node14'
26 | define:
27 | 'require.resolve': null
28 | platform: 'node'
29 | # serverless-s3-sync config, handles uploading static files to S3
30 | s3Sync:
31 | buckets:
32 | - bucketNameKey: WebsiteBucketName
33 | bucketPrefix: website/
34 | localDir: public
35 | params:
36 | - favicon.png:
37 | CacheControl: 'public, max-age=3600'
38 | - 'static/**/*':
39 | CacheControl: 'public, max-age=31536000, immutable'
40 |
41 | provider:
42 | name: aws
43 | runtime: nodejs14.x
44 | stage: ${opt:stage, 'dev'}
45 | region: ${opt:region, 'us-east-1'}
46 | environment:
47 | AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1'
48 | NODE_OPTIONS: '--enable-source-maps --stack-trace-limit=1000'
49 | lambdaHashingVersion: '20201221'
50 |
51 | functions:
52 | remix:
53 | handler: server/index.handler
54 | description: My Remix app!
55 | events:
56 | - httpApi:
57 | method: any
58 | path: '/{proxy+}'
59 | resources:
60 | Resources:
61 | # Created by serverless
62 | # HttpApi:
63 | # Type: AWS::ApiGatewayV2::Api
64 | # Properties:
65 | # Name: ${self:provider.stage}-${self:service}
66 | # ProtocolType: HTTP
67 |
68 | # S3 bucket for static assets
69 | WebsiteBucket:
70 | Type: AWS::S3::Bucket
71 | Properties: {}
72 |
73 | # Allow CloudFront to use GetObject in the WebsiteBucket
74 | WebsiteOriginAccessIdentity:
75 | Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
76 | Properties:
77 | CloudFrontOriginAccessIdentityConfig:
78 | Comment: Origin Access Identity to Access ${self:service} Website Bucket ${self:provider.stage}
79 | WebsiteBucketPolicy:
80 | Type: AWS::S3::BucketPolicy
81 | Properties:
82 | Bucket: !Ref WebsiteBucket
83 | PolicyDocument:
84 | Statement:
85 | - Effect: Allow
86 | Action:
87 | - s3:GetObject
88 | Resource:
89 | Fn::Join:
90 | - /
91 | - - Fn::GetAtt:
92 | - WebsiteBucket
93 | - Arn
94 | - '*'
95 | Principal:
96 | CanonicalUser:
97 | Fn::GetAtt:
98 | - WebsiteOriginAccessIdentity
99 | - S3CanonicalUserId
100 |
101 | # Use CloudFront in front of the Remix API Gateway endpoint
102 | RemixCachePolicy:
103 | Type: AWS::CloudFront::CachePolicy
104 | Properties:
105 | CachePolicyConfig:
106 | Name: RemixCachePolicy
107 | DefaultTTL: 60
108 | MinTTL: 0
109 | MaxTTL: 60
110 | ParametersInCacheKeyAndForwardedToOrigin:
111 | HeadersConfig:
112 | HeaderBehavior: none
113 | EnableAcceptEncodingGzip: true
114 | QueryStringsConfig:
115 | QueryStringBehavior: all
116 | CookiesConfig:
117 | CookieBehavior: all
118 |
119 | # CloudFront Configuration
120 | CDN:
121 | Type: AWS::CloudFront::Distribution
122 | DependsOn:
123 | - WebsiteBucket
124 | - HttpApi
125 | Properties:
126 | DistributionConfig:
127 | Origins:
128 | - DomainName:
129 | Fn::GetAtt:
130 | - WebsiteBucket
131 | - DomainName
132 | Id: StaticOrigin
133 | S3OriginConfig:
134 | OriginAccessIdentity:
135 | Fn::Join:
136 | - /
137 | - - origin-access-identity
138 | - cloudfront
139 | - !Ref WebsiteOriginAccessIdentity
140 | OriginPath: '/website'
141 | - DomainName:
142 | Fn::Join:
143 | - ''
144 | - - Ref: HttpApi
145 | - '.execute-api.${self:provider.region}.amazonaws.com'
146 | Id: RemixOrigin
147 | # UNCOMMENT THIS TO SET THE X-Forwarded-Host header for API Gateway
148 | # OriginCustomHeaders:
149 | # - HeaderName: X-Forwarded-Host
150 | # HeaderValue: ${self:${self:provider.stage}.HOST}
151 | CustomOriginConfig:
152 | OriginProtocolPolicy: https-only
153 | OriginSSLProtocols: [TLSv1.2]
154 |
155 | # missing static assets will give a 403 from s3, so you can override the error response here
156 | # CustomErrorResponses:
157 | # - ErrorCachingMinTTL: 60
158 | # ErrorCode: 403
159 | # ResponseCode: 404
160 | # ResponsePagePath: /404
161 | # By default, all requests go to remix
162 | DefaultCacheBehavior:
163 | AllowedMethods: [GET, HEAD, OPTIONS, PUT, PATCH, POST, DELETE]
164 | CachedMethods: [GET, HEAD, OPTIONS]
165 | Compress: true
166 | CachePolicyId:
167 | Ref: RemixCachePolicy
168 | TargetOriginId: RemixOrigin
169 | ViewerProtocolPolicy: redirect-to-https
170 | # Requests to /static go to S3
171 | CacheBehaviors:
172 | - PathPattern: 'static/*'
173 | AllowedMethods:
174 | - GET
175 | - HEAD
176 | CachedMethods:
177 | - GET
178 | - HEAD
179 | Compress: true
180 | ForwardedValues:
181 | QueryString: true
182 | Cookies:
183 | Forward: none
184 | TargetOriginId: StaticOrigin
185 | ViewerProtocolPolicy: redirect-to-https
186 | # Special rule for browser favicon requests to also go to S3 origin
187 | - PathPattern: '/favicon.*'
188 | AllowedMethods:
189 | - GET
190 | - HEAD
191 | CachedMethods:
192 | - GET
193 | - HEAD
194 | Compress: false
195 | ForwardedValues:
196 | QueryString: false
197 | Cookies:
198 | Forward: none
199 | TargetOriginId: StaticOrigin
200 | ViewerProtocolPolicy: redirect-to-https
201 | Comment: ${self:service}-${self:provider.stage}
202 | Enabled: true
203 | HttpVersion: http2
204 | PriceClass: PriceClass_100
205 | ViewerCertificate:
206 | CloudFrontDefaultCertificate: true
207 | Outputs:
208 | WebsiteBucketName:
209 | Value:
210 | Ref: WebsiteBucket
211 | DistributionID:
212 | Value:
213 | Ref: CDN
214 | WebsiteDomain:
215 | Value:
216 | Fn::GetAtt: [CDN, DomainName]
217 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
5 | "esModuleInterop": true,
6 | "jsx": "react-jsx",
7 | "moduleResolution": "node",
8 | "resolveJsonModule": true,
9 | "target": "ES2019",
10 | "strict": true,
11 | "baseUrl": "./",
12 | "paths": {
13 | "~/*": ["./app/*"]
14 | },
15 |
16 | // Remix takes care of building everything in `remix build`.
17 | "noEmit": true
18 | }
19 | }
20 |
--------------------------------------------------------------------------------