7 | Whoa, this is a nested route! We render the /about layout
8 | route component, and its Outlet renders our route
9 | component. 🤯
10 |
11 |
12 |
13 |
14 | Go back to the /about index.
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx", "server/index.ts"],
3 | "exclude": ["node_modules", "cdk.out"],
4 | "compilerOptions": {
5 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "node",
9 | "resolveJsonModule": true,
10 | "target": "ES2019",
11 | "strict": true,
12 | "paths": {
13 | "~/*": ["./app/*"]
14 | },
15 |
16 | // Remix takes care of building everything in `remix build`.
17 | "noEmit": true
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/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 | let 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/utils.server.tsx:
--------------------------------------------------------------------------------
1 | // Sometimes some modules don't work in the browser, Remix will generally be
2 | // able to remove server-only code automatically as long as you don't import it
3 | // directly from a route module (that's where the automatic removal happens). If
4 | // you're ever still having trouble, you can skip the remix remove-server-code
5 | // magic and drop your code into a file that ends with `.server` like this one.
6 | // Remix won't even try to figure things out on its own, it'll just completely
7 | // ignore it for the browser bundles. On a related note, crypto can't be
8 | // imported directly into a route module, but if it's in this file you're fine.
9 | import { createHash } from "crypto";
10 |
11 | export function hash(str: string) {
12 | return createHash("sha1").update(str).digest("hex").toString();
13 | }
14 |
--------------------------------------------------------------------------------
/app/styles/demos/about.css:
--------------------------------------------------------------------------------
1 | /*
2 | * Whoa whoa whoa, wait a sec...why are we overriding global CSS selectors?
3 | * Isn't that kind of scary? How do we know this won't have side effects?
4 | *
5 | * In Remix, CSS that is included in a route file will *only* show up on that
6 | * route (and for nested routes, its children). When the user navigates away
7 | * from that route the CSS files linked from those routes will be automatically
8 | * unloaded, making your styles much easier to predict and control.
9 | *
10 | * Read more about styling routes in the docs:
11 | * https://remix.run/guides/styling
12 | */
13 |
14 | :root {
15 | --color-foreground: hsl(0, 0%, 7%);
16 | --color-background: hsl(56, 100%, 50%);
17 | --color-links: hsl(345, 56%, 39%);
18 | --color-links-hover: hsl(345, 51%, 49%);
19 | --color-border: rgb(184, 173, 20);
20 | --font-body: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
21 | Liberation Mono, Courier New, monospace;
22 | }
23 |
24 | .about__intro {
25 | max-width: 500px;
26 | }
27 |
--------------------------------------------------------------------------------
/bin/cdk-remix-app.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import "source-map-support/register";
3 | import * as cdk from "aws-cdk-lib";
4 | import { CdkRemixAppStack } from "../lib/cdk-remix-app-stack";
5 |
6 | const app = new cdk.App();
7 | new CdkRemixAppStack(app, "CdkRemixAppStack", {
8 | /* If you don't specify 'env', this stack will be environment-agnostic.
9 | * Account/Region-dependent features and context lookups will not work,
10 | * but a single synthesized template can be deployed anywhere. */
11 |
12 | /* Uncomment the next line to specialize this stack for the AWS Account
13 | * and Region that are implied by the current CLI configuration. */
14 | // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
15 |
16 | /* Uncomment the next line if you know exactly what Account and Region you
17 | * want to deploy the stack to. */
18 | env: { region: "us-east-1" },
19 |
20 | /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
21 | });
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "remix-app-template",
4 | "description": "",
5 | "license": "",
6 | "scripts": {
7 | "build": "remix build",
8 | "dev": "remix dev",
9 | "postinstall": "remix setup node",
10 | "start": "remix-serve build"
11 | },
12 | "dependencies": {
13 | "@remix-run/node": "^1.0.6",
14 | "@remix-run/react": "^1.0.6",
15 | "@remix-run/serve": "^1.0.6",
16 | "react": "^17.0.2",
17 | "react-dom": "^17.0.2",
18 | "remix": "^1.0.6"
19 | },
20 | "devDependencies": {
21 | "@remix-run/dev": "^1.0.6",
22 | "@types/aws-lambda": "^8.10.85",
23 | "@types/jest": "^27.0.3",
24 | "@types/react-dom": "^17.0.11",
25 | "@types/react": "^17.0.37",
26 | "aws-cdk-lib": "2.0.0",
27 | "aws-cdk": "2.0.0",
28 | "constructs": "^10.0.0",
29 | "jest": "^27.4.3",
30 | "ts-jest": "^27.0.7",
31 | "ts-node": "^10.4.0",
32 | "typescript": "^4.5.2"
33 | },
34 | "engines": {
35 | "node": ">=14"
36 | },
37 | "sideEffects": false
38 | }
39 |
--------------------------------------------------------------------------------
/app/routes/demos/params.tsx:
--------------------------------------------------------------------------------
1 | import { useCatch, Link, json, useLoaderData, Outlet } from "remix";
2 |
3 | export function meta() {
4 | return { title: "Boundaries Demo" };
5 | }
6 |
7 | export default function Boundaries() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to Remix!
2 |
3 | - [Remix Docs](https://remix.run/docs)
4 |
5 | ## Development
6 |
7 | From your terminal:
8 |
9 | ```sh
10 | npm run dev
11 | ```
12 |
13 | This starts your app in development mode, rebuilding assets on file changes.
14 |
15 | ## Deployment
16 |
17 | First, build your app for production:
18 |
19 | ```sh
20 | npm run build
21 | ```
22 |
23 | Then run the app in production mode:
24 |
25 | ```sh
26 | npm start
27 | ```
28 |
29 | Now you'll need to pick a host to deploy it to.
30 |
31 | ### DIY
32 |
33 | If you're familiar with deploying node applications, the built-in Remix app server is production-ready.
34 |
35 | Make sure to deploy the output of `remix build`
36 |
37 | - `build/`
38 | - `public/build/`
39 |
40 | ### Using a Template
41 |
42 | When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server.
43 |
44 | ```sh
45 | cd ..
46 | # create a new project, and pick a pre-configured host
47 | npx create-remix@latest
48 | cd my-new-remix-app
49 | # remove the new project's app (not the old one!)
50 | rm -rf app
51 | # copy your app over
52 | cp -R ../my-old-remix-app/app app
53 | ```
54 |
--------------------------------------------------------------------------------
/app/routes/demos/about.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "remix";
2 | import type { MetaFunction, LinksFunction } from "remix";
3 |
4 | import stylesUrl from "~/styles/demos/about.css";
5 |
6 | export let meta: MetaFunction = () => {
7 | return {
8 | title: "About Remix",
9 | };
10 | };
11 |
12 | export let links: LinksFunction = () => {
13 | return [{ rel: "stylesheet", href: stylesUrl }];
14 | };
15 |
16 | export default function Index() {
17 | return (
18 |
19 |
20 |
About Us
21 |
22 | Ok, so this page isn't really about us, but we did want to
23 | show you a few more things Remix can do.
24 |
25 |
26 | Did you notice that things look a little different on this page? The
27 | CSS that we import in the route file and include in its{" "}
28 | links export is only included on this route and its
29 | children.
30 |
9 | When you name a route segment with $ like{" "}
10 | routes/users/$userId.js, the $ segment will be parsed from
11 | the URL and sent to your loaders and actions by the same name.
12 |
13 |
Errors
14 |
15 | When a route throws and error in it's action, loader, or component,
16 | Remix automatically catches it, won't even try to render the component,
17 | but it will render the route's ErrorBoundary instead. If the route
18 | doesn't have one, it will bubble up to the routes above it until it hits
19 | the root.
20 |
21 |
So be as granular as you want with your error handling.
31 | Loaders and Actions can throw a Response instead of an
32 | error and Remix will render the CatchBoundary instead of the component.
33 | This is great when loading data from a database isn't found. As soon as
34 | you know you can't render the component normally, throw a 404 response
35 | and send your app into the catch boundary. Just like error boundaries,
36 | catch boundaries bubble, too.
37 |
67 | Feel free to take a look around the code to see how Remix does things,
68 | it might be a bit different than what you’re used to. When you're
69 | ready to dive deeper, we've got plenty of resources to get you
70 | up-and-running quickly.
71 |
72 |
73 | Check out all the demos in this starter, and then just delete the{" "}
74 | app/routes/demos and app/styles/demos{" "}
75 | folders when you're ready to turn this into your next project.
76 |
77 |
78 |
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/server/createRequestHandler.ts:
--------------------------------------------------------------------------------
1 | import { URL } from "url";
2 | import {
3 | Headers as NodeHeaders,
4 | Request as NodeRequest,
5 | formatServerError,
6 | } from "@remix-run/node";
7 | import type {
8 | CloudFrontRequestEvent,
9 | CloudFrontRequestHandler,
10 | CloudFrontHeaders,
11 | } from "aws-lambda";
12 | import type {
13 | AppLoadContext,
14 | ServerBuild,
15 | ServerPlatform,
16 | } from "@remix-run/server-runtime";
17 | import { createRequestHandler as createRemixRequestHandler } from "@remix-run/server-runtime";
18 | import type { Response as NodeResponse } from "@remix-run/node";
19 | import { installGlobals } from "@remix-run/node";
20 |
21 | installGlobals();
22 |
23 | export interface GetLoadContextFunction {
24 | (event: CloudFrontRequestEvent): AppLoadContext;
25 | }
26 |
27 | export type RequestHandler = ReturnType;
28 |
29 | export function createRequestHandler({
30 | build,
31 | getLoadContext,
32 | mode = process.env.NODE_ENV,
33 | }: {
34 | build: ServerBuild;
35 | getLoadContext?: GetLoadContextFunction;
36 | mode?: string;
37 | }): CloudFrontRequestHandler {
38 | let platform: ServerPlatform = { formatServerError };
39 | let handleRequest = createRemixRequestHandler(build, platform, mode);
40 |
41 | return async (event, context) => {
42 | let request = createRemixRequest(event);
43 |
44 | let loadContext =
45 | typeof getLoadContext === "function" ? getLoadContext(event) : undefined;
46 |
47 | let response = (await handleRequest(
48 | request as unknown as Request,
49 | loadContext
50 | )) as unknown as NodeResponse;
51 |
52 | return {
53 | status: String(response.status),
54 | headers: createCloudFrontHeaders(response.headers),
55 | bodyEncoding: "text",
56 | body: await response.text(),
57 | };
58 | };
59 | }
60 |
61 | export function createCloudFrontHeaders(
62 | responseHeaders: NodeHeaders
63 | ): CloudFrontHeaders {
64 | let headers: CloudFrontHeaders = {};
65 | let rawHeaders = responseHeaders.raw();
66 |
67 | for (let key in rawHeaders) {
68 | let value = rawHeaders[key];
69 | for (let v of value) {
70 | headers[key] = [...(headers[key] || []), { key, value: v }];
71 | }
72 | }
73 |
74 | return headers;
75 | }
76 |
77 | export function createRemixHeaders(
78 | requestHeaders: CloudFrontHeaders
79 | ): NodeHeaders {
80 | let headers = new NodeHeaders();
81 |
82 | for (let [key, values] of Object.entries(requestHeaders)) {
83 | for (let { value } of values) {
84 | if (value) {
85 | headers.append(key, value);
86 | }
87 | }
88 | }
89 |
90 | return headers;
91 | }
92 |
93 | export function createRemixRequest(event: CloudFrontRequestEvent): NodeRequest {
94 | let request = event.Records[0].cf.request;
95 |
96 | let host = request.headers["host"]
97 | ? request.headers["host"][0].value
98 | : undefined;
99 | let search = request.querystring.length ? `?${request.querystring}` : "";
100 | let url = new URL(request.uri + search, `https://${host}`);
101 |
102 | return new NodeRequest(url.toString(), {
103 | method: request.method,
104 | headers: createRemixHeaders(request.headers),
105 | body: request.body?.data
106 | ? request.body.encoding === "base64"
107 | ? Buffer.from(request.body.data, "base64").toString()
108 | : request.body.data
109 | : undefined,
110 | });
111 | }
112 |
--------------------------------------------------------------------------------
/lib/cdk-remix-app-stack.ts:
--------------------------------------------------------------------------------
1 | import { Bucket } from "aws-cdk-lib/aws-s3";
2 | import {
3 | BucketDeployment,
4 | CacheControl,
5 | Source,
6 | } from "aws-cdk-lib/aws-s3-deployment";
7 | import { Construct } from "constructs";
8 | import { Duration, RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
9 | import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
10 | import { RetentionDays } from "aws-cdk-lib/aws-logs";
11 | import { S3Origin } from "aws-cdk-lib/aws-cloudfront-origins";
12 | import {
13 | AllowedMethods,
14 | CachePolicy,
15 | Distribution,
16 | LambdaEdgeEventType,
17 | OriginAccessIdentity,
18 | OriginRequestCookieBehavior,
19 | OriginRequestHeaderBehavior,
20 | OriginRequestPolicy,
21 | OriginRequestQueryStringBehavior,
22 | ViewerProtocolPolicy,
23 | } from "aws-cdk-lib/aws-cloudfront";
24 |
25 | export class CdkRemixAppStack extends Stack {
26 | constructor(scope: Construct, id: string, props?: StackProps) {
27 | super(scope, id, props);
28 |
29 | const assetsBucket = new Bucket(this, "AssetsBucket", {
30 | autoDeleteObjects: true,
31 | publicReadAccess: false,
32 | removalPolicy: RemovalPolicy.DESTROY,
33 | });
34 |
35 | const assetsBucketOriginAccessIdentity = new OriginAccessIdentity(
36 | this,
37 | "AssetsBucketOriginAccessIdentity"
38 | );
39 |
40 | const assetsBucketS3Origin = new S3Origin(assetsBucket, {
41 | originAccessIdentity: assetsBucketOriginAccessIdentity,
42 | });
43 |
44 | assetsBucket.grantRead(assetsBucketOriginAccessIdentity);
45 |
46 | const edgeFn = new NodejsFunction(this, "EdgeFn", {
47 | currentVersionOptions: {
48 | removalPolicy: RemovalPolicy.DESTROY,
49 | },
50 | entry: "server/index.ts",
51 | logRetention: RetentionDays.THREE_DAYS,
52 | memorySize: 1024,
53 | timeout: Duration.seconds(10),
54 | });
55 |
56 | const distribution = new Distribution(this, "Distribution", {
57 | defaultBehavior: {
58 | allowedMethods: AllowedMethods.ALLOW_ALL,
59 | cachePolicy: CachePolicy.CACHING_DISABLED,
60 | compress: true,
61 | edgeLambdas: [
62 | {
63 | eventType: LambdaEdgeEventType.ORIGIN_REQUEST,
64 | functionVersion: edgeFn.currentVersion,
65 | includeBody: true,
66 | },
67 | ],
68 | origin: assetsBucketS3Origin,
69 | originRequestPolicy: new OriginRequestPolicy(
70 | this,
71 | "OriginRequestPolicy",
72 | {
73 | headerBehavior: OriginRequestHeaderBehavior.all(),
74 | queryStringBehavior: OriginRequestQueryStringBehavior.all(),
75 | cookieBehavior: OriginRequestCookieBehavior.all(),
76 | }
77 | ),
78 | viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
79 | },
80 | additionalBehaviors: {
81 | "build/*": {
82 | allowedMethods: AllowedMethods.ALLOW_GET_HEAD,
83 | cachePolicy: CachePolicy.CACHING_OPTIMIZED,
84 | compress: true,
85 | origin: assetsBucketS3Origin,
86 | viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
87 | },
88 | },
89 | });
90 |
91 | new BucketDeployment(this, "AssetsDeployment", {
92 | destinationBucket: assetsBucket,
93 | distribution,
94 | prune: true,
95 | sources: [Source.asset("public")],
96 | cacheControl: [
97 | CacheControl.maxAge(Duration.days(365)),
98 | CacheControl.sMaxAge(Duration.days(365)),
99 | ],
100 | });
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/app/routes/demos/params/$id.tsx:
--------------------------------------------------------------------------------
1 | import { useCatch, Link, json, useLoaderData } from "remix";
2 | import type { LoaderFunction, MetaFunction } from "remix";
3 |
4 | // The `$` in route filenames becomes a pattern that's parsed from the URL and
5 | // passed to your loaders so you can look up data.
6 | // - https://remix.run/api/conventions#loader-params
7 | export let loader: LoaderFunction = async ({ params }) => {
8 | // pretend like we're using params.id to look something up in the db
9 |
10 | if (params.id === "this-record-does-not-exist") {
11 | // If the record doesn't exist we can't render the route normally, so
12 | // instead we throw a 404 reponse to stop running code here and show the
13 | // user the catch boundary.
14 | throw new Response("Not Found", { status: 404 });
15 | }
16 |
17 | // now pretend like the record exists but the user just isn't authorized to
18 | // see it.
19 | if (params.id === "shh-its-a-secret") {
20 | // Again, we can't render the component if the user isn't authorized. You
21 | // can even put data in the response that might help the user rectify the
22 | // issue! Like emailing the webmaster for access to the page. (Oh, right,
23 | // `json` is just a Response helper that makes it easier to send JSON
24 | // responses).
25 | throw json({ webmasterEmail: "hello@remix.run" }, { status: 401 });
26 | }
27 |
28 | // Sometimes your code just blows up and you never anticipated it. Remix will
29 | // automatically catch it and send the UI to the error boundary.
30 | if (params.id === "kaboom") {
31 | lol();
32 | }
33 |
34 | // but otherwise the record was found, user has access, so we can do whatever
35 | // else we needed to in the loader and return the data. (This is boring, we're
36 | // just gonna return the params.id).
37 | return { param: params.id };
38 | };
39 |
40 | export default function ParamDemo() {
41 | let data = useLoaderData();
42 | return (
43 |
99 | (Isn't it cool that the user gets to stay in context and try a different
100 | link in the parts of the UI that didn't blow up?)
101 |
102 | >
103 | );
104 | }
105 |
106 | export let meta: MetaFunction = ({ data }) => {
107 | return {
108 | title: data ? `Param: ${data.param}` : "Oops...",
109 | };
110 | };
111 |
--------------------------------------------------------------------------------
/app/routes/demos/actions.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import type { ActionFunction } from "remix";
3 | import { hash } from "~/utils.server";
4 | import { Form, json, useActionData, useTransition, redirect } from "remix";
5 |
6 | export function meta() {
7 | return { title: "Actions Demo" };
8 | }
9 |
10 | // When your form sends a POST, the action is called on the server.
11 | // - https://remix.run/api/conventions#action
12 | // - https://remix.run/guides/data-updates
13 | export let action: ActionFunction = async ({ request }) => {
14 | let formData = await request.formData();
15 | let answer = formData.get("answer");
16 |
17 | // Typical action workflows start with validating the form data that just came
18 | // over the network. Clientside validation is fine, but you definitely need it
19 | // server side. If there's a problem, return the the data and the component
20 | // can render it.
21 | if (typeof answer !== "string") {
22 | return json("Come on, at least try!", { status: 400 });
23 | }
24 |
25 | let rightAnswers = [
26 | "4fa6024f12494d3a99d8bda9b7a55f7d140f328a",
27 | "ce3659ad235ca6d1e12dec21465aff3f9a62bb8c",
28 | "bd111dcb4b343de4ec0a79d2d5ec55a3919c79c4",
29 | ];
30 |
31 | let encrypted = hash(answer);
32 |
33 | if (!rightAnswers.includes(encrypted)) {
34 | return json(`Sorry, ${answer} is not right.`, { status: 400 });
35 | }
36 |
37 | // Finally, if the data is valid, you'll typically write to a database or send or
38 | // email or log the user in, etc. It's recommended to redirect after a
39 | // successful action, even if it's to the same place so that non-JavaScript workflows
40 | // from the browser doesn't repost the data if the user clicks back.
41 | return redirect("/demos/correct");
42 | };
43 |
44 | export default function ActionsDemo() {
45 | // https://remix.run/api/remix#useactiondata
46 | let actionMessage = useActionData();
47 | let answerRef = useRef(null);
48 |
49 | // This form works without JavaScript, but when we have JavaScript we can make
50 | // the experience better by selecting the input on wrong answers! Go ahead, disable
51 | // JavaScript in your browser and see what happens.
52 | useEffect(() => {
53 | if (actionMessage && answerRef.current) {
54 | answerRef.current.select();
55 | }
56 | }, [actionMessage]);
57 |
58 | return (
59 |
60 |
61 |
Actions!
62 |
63 | This form submission will send a post request that we handle in our
64 | `action` export. Any route can export an action to handle data
65 | mutations.
66 |