Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/beige-walls-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"amplify-adapter-react-router": minor
---

feat: add amplify-adapter-react-router pacakge
26 changes: 26 additions & 0 deletions amplify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
version: 1
applications:
- backend:
phases:
build:
commands:
- pwd
- "ls -al"
- echo "node-linker=hoisted" > ../../.npmrc
- corepack enable
- pnpm install --frozen-lockfile
- pnpm exec ampx pipeline-deploy --branch $AWS_BRANCH --app-id $AWS_APP_ID
frontend:
phases:
build:
commands:
- pnpm --workspace-root build
artifacts:
baseDirectory: .amplify-hosting
files:
- "**/*"
cache:
paths:
- node_modules/**/*
buildPath: examples/todo-app
appRoot: examples/todo-app
4 changes: 4 additions & 0 deletions examples/todo-app/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.react-router
build
node_modules
README.md
12 changes: 12 additions & 0 deletions examples/todo-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.DS_Store
/node_modules/

# React Router
/.react-router/
/build/

# Amplify
/.amplify/
/.amplify-hosting/
amplify_outputs.json

3 changes: 3 additions & 0 deletions examples/todo-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Todo app using React Router SSR and AWS Amplify

A Todo application using React Router and AWS Amplify.
7 changes: 7 additions & 0 deletions examples/todo-app/amplify/auth/resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineAuth } from '@aws-amplify/backend';

export const auth = defineAuth({
loginWith: {
email: true
}
});
9 changes: 9 additions & 0 deletions examples/todo-app/amplify/backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineBackend } from '@aws-amplify/backend';
import { auth } from './auth/resource';
import { data } from './data/resource';

defineBackend({
auth,
data
});

18 changes: 18 additions & 0 deletions examples/todo-app/amplify/data/resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { a, defineData, type ClientSchema } from '@aws-amplify/backend';

const schema = a.schema({
Todo: a.model({
content: a.string(),
isDone: a.boolean()
})
.authorization(allow => [allow.publicApiKey()])
});

export type Schema = ClientSchema<typeof schema>;
export const data = defineData({
schema,
authorizationModes: {
defaultAuthorizationMode: 'apiKey',
}
});

15 changes: 15 additions & 0 deletions examples/todo-app/app/app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@import "tailwindcss";

@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}

html,
body {
@apply bg-white dark:bg-gray-950;

@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
19 changes: 19 additions & 0 deletions examples/todo-app/app/components/TodoList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { TodoListItem, type TodoListItemProps } from "./TodoListItem";

export type TodoListProps = {
items: TodoListItemProps["item"][];
};

export function TodoList({ items }: TodoListProps) {
if (items.length === 0) {
return <p>No items</p>;
}

return (
<ul className="divide-y divide-gray-200 border-1 border-gray-200 rounded-xl">
{items.map((item) => (
<TodoListItem key={item.id} item={item} />
))}
</ul>
);
}
39 changes: 39 additions & 0 deletions examples/todo-app/app/components/TodoListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Link, useFetcher } from "react-router";
import type { Schema } from "../../amplify/data/resource";

export type Todo = Schema["Todo"]["type"];

export type TodoListItemProps = {
item: Todo;
};

export function TodoListItem({ item }: TodoListItemProps) {
const fetcher = useFetcher();
const busy = fetcher.state !== "idle";

return (
<li className="flex py-3">
<div className="flex min-w-0 gap-x-4 px-3">
<Link to={`/${item.id}/edit`}>
<span className="text-sm/6 font-semibold text-white-900">
{item.content}
</span>
</Link>
{item.isDone && <span>☑️</span>}
</div>
<fetcher.Form
method="delete"
action={`/${item.id}`}
className="flex flex-1 justify-end"
>
<button
type="submit"
className="py-1 px-3 mr-3 text-sm font-medium text-center text-white bg-red-600 rounded-lg hover:bg-red-700 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-500 dark:hover:bg-red-600 dark:focus:ring-red-900"
disabled={busy}
>
x
</button>
</fetcher.Form>
</li>
);
}
9 changes: 9 additions & 0 deletions examples/todo-app/app/lib/amplify-ssr-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Schema } from "amplify/data/resource";
import { parseAmplifyConfig } from "aws-amplify/utils";
import { generateClient } from "aws-amplify/api/server";
import config from "../../amplify_outputs.json";

const amplifyConfig = parseAmplifyConfig(config);
export const client = generateClient<Schema>({
config: amplifyConfig,
});
6 changes: 6 additions & 0 deletions examples/todo-app/app/lib/amplifyServerUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createServerRunner } from "amplify-adapter-react-router";
import outputs from "../../amplify_outputs.json";

export const { runWithAmplifyServerContext } = createServerRunner({
config: outputs,
});
80 changes: 80 additions & 0 deletions examples/todo-app/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";

import type { Route } from "./+types/root";
import "./app.css";

import config from "../amplify_outputs.json";
import { Amplify } from "aws-amplify";

Amplify.configure(config, { ssr: true });

export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function App() {
return <Outlet />
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;

if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}

return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}
8 changes: 8 additions & 0 deletions examples/todo-app/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
index("./routes/index.tsx"),
route("new", "./routes/new.tsx"),
route(":todoId", "./routes/$todoId/index.tsx"),
route(":todoId/edit", "./routes/$todoId/edit.tsx"),
] satisfies RouteConfig;
95 changes: 95 additions & 0 deletions examples/todo-app/app/routes/$todoId/edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { data, Form, redirect } from "react-router";
import type { Route } from "./+types/edit";
import { runWithAmplifyServerContext } from "~/lib/amplifyServerUtils";
import { client } from "~/lib/amplify-ssr-client";

export function meta() {
return [
{ title: "React Router Todo App" },
{ name: "description", content: "Add New Todo" },
];
}

export async function loader({ request, params }: Route.LoaderArgs) {
const { todoId } = params;
const responseHeaders = new Headers();
const { data: todo, errors } = await runWithAmplifyServerContext({
serverContext: { request, responseHeaders },
operation: (contextSpec) => client.models.Todo.get(contextSpec, { id: todoId }),
});
return data(
{
todo,
error: errors?.map((e) => e.message).join(", "),
},
{
headers: responseHeaders,
},
);
}

export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const id = formData.get("id") as string;;
const content = formData.get("content");
const isDone = formData.get("isDone") === "true";
if (typeof content !== "string") {
throw new Error("Invalid content");
}
const responseHeaders = new Headers();
await runWithAmplifyServerContext({
serverContext: { request, responseHeaders },
operation: (contextSpec) =>
client.models.Todo.update(contextSpec, {
id,
content,
isDone,
}),
});
return redirect("/", {
headers: responseHeaders,
});
}

export default function EditTodo({ loaderData }: Route.ComponentProps) {
const { todo, error } = loaderData;
if (error) {
return <div>Error: {error}</div>;
}
if (!todo) {
return <div>Todo not found</div>;
}
return (
<div className="flex flex-col gap-y-3 my-4 mx-2">
<h1 className="text-2xl font-bold">Edit Todo</h1>
<Form method="post">
<input type="hidden" name="id" value={todo.id} />
<textarea
name="content"
defaultValue={todo.content ?? ""}
placeholder="Content"
className="w-full h-32 p-2 border border-gray-300 rounded"
required
/>
<div className="flex items-center mb-4">
<input
name="isDone"
type="checkbox"
value="true"
defaultChecked={!!todo.isDone}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<label htmlFor="default-checkbox" className="ms-2 font-medium">
Done
</label>
</div>
<button
type="submit"
className="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded"
>
Save
</button>
</Form>
</div>
);
}
27 changes: 27 additions & 0 deletions examples/todo-app/app/routes/$todoId/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { runWithAmplifyServerContext } from "~/lib/amplifyServerUtils";
import type { Route } from "./+types/index";
import { client } from "~/lib/amplify-ssr-client";
import { redirect } from "react-router";

export async function action({ request, params }: Route.ActionArgs) {
if (request.method === "DELETE") {
const {
todoId,
} = params;
if (typeof todoId !== "string") {
throw new Error("Invalid todoId");
}
const responseHeaders = new Headers();
await runWithAmplifyServerContext({
serverContext: { request, responseHeaders },
operation: (contextSpec) =>
client.models.Todo.delete(contextSpec, {
id: todoId,
}),
});
return redirect("/", {
headers: responseHeaders,
});
}
}

Loading