React + TypeScript + AWS Amplify: Build a Serverless Frontend
Connect a React/TypeScript frontend to AWS backends using Amplify ā configure Auth with Cognito, call API Gateway endpoints, manage environment configs, and deploy to S3 + CloudFront.
What Is AWS Amplify?
AWS Amplify is a set of libraries and hosting tools for connecting frontend apps to AWS services. You get:
@aws-amplify/authā Cognito sign-in / sign-up flows@aws-amplify/apiā REST and GraphQL calls with auto auth headers@aws-amplify/storageā S3 uploads/downloads- Amplify Hosting ā CI/CD + CDN hosting (alternative to manual S3/CloudFront)
This guide covers using the Amplify libraries with a Vite + React + TypeScript project ā not the Amplify CLI/backend. Your infrastructure stays in Terraform.
Project Setup
npm create vite@latest portal -- --template react-ts
cd portal
npm install aws-amplify @aws-amplify/ui-reactAmplify Configuration
Create a config file driven by environment variables:
// src/lib/amplify.ts
import { Amplify } from "aws-amplify";
Amplify.configure({
Auth: {
Cognito: {
userPoolId: import.meta.env.VITE_COGNITO_USER_POOL_ID,
userPoolClientId: import.meta.env.VITE_COGNITO_CLIENT_ID,
loginWith: { email: true },
},
},
API: {
REST: {
PortalAPI: {
endpoint: import.meta.env.VITE_API_URL,
region: import.meta.env.VITE_AWS_REGION,
},
},
},
});Call this once in your app entry:
// src/main.tsx
import "./lib/amplify"; // must be first
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode><App /></React.StrictMode>
);.env.local (never commit this):
VITE_COGNITO_USER_POOL_ID=us-east-1_xxxxxxx
VITE_COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxx
VITE_API_URL=https://api.clinic.com
VITE_AWS_REGION=us-east-1Authentication with Cognito
Pre-built UI Component
The fastest way to ship auth ā a complete sign-in/sign-up/MFA UI:
// src/App.tsx
import { Authenticator } from "@aws-amplify/ui-react";
import "@aws-amplify/ui-react/styles.css";
import { Dashboard } from "./pages/Dashboard";
export default function App() {
return (
<Authenticator>
{({ signOut, user }) => (
<div>
<header>
<span>Welcome, {user?.signInDetails?.loginId}</span>
<button onClick={signOut}>Sign out</button>
</header>
<Dashboard />
</div>
)}
</Authenticator>
);
}Custom Auth Hook
For full control over the sign-in UI:
// src/hooks/useAuth.ts
import { useState } from "react";
import { signIn, signOut, getCurrentUser, fetchAuthSession } from "aws-amplify/auth";
export interface AuthUser {
userId: string;
email: string;
}
export function useAuth() {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function login(email: string, password: string) {
setLoading(true);
setError(null);
try {
await signIn({ username: email, password });
const current = await getCurrentUser();
const session = await fetchAuthSession();
setUser({
userId: current.userId,
email: session.tokens?.idToken?.payload.email as string,
});
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function logout() {
await signOut();
setUser(null);
}
return { user, loading, error, login, logout };
}Getting the JWT Token
Amplify automatically attaches the Cognito token to REST API calls. To get it manually:
import { fetchAuthSession } from "aws-amplify/auth";
async function getAccessToken(): Promise<string> {
const session = await fetchAuthSession();
return session.tokens?.accessToken?.toString() ?? "";
}Calling API Gateway
Using Amplify REST API Client
Amplify auto-attaches the Cognito JWT to every request:
// src/lib/api.ts
import { get, post, put, del } from "aws-amplify/api";
export interface Appointment {
id: string;
clinic_id: string;
patient_name: string;
datetime: string;
status: "scheduled" | "completed" | "cancelled";
}
export async function getAppointments(clinicId: string, date?: string): Promise<Appointment[]> {
const { body } = await get({
apiName: "PortalAPI",
path: "/appointments",
options: {
queryParams: {
clinic_id: clinicId,
...(date ? { date } : {}),
},
},
}).response;
return body.json() as Promise<Appointment[]>;
}
export async function createAppointment(data: Omit<Appointment, "id">): Promise<Appointment> {
const { body } = await post({
apiName: "PortalAPI",
path: "/appointments",
options: { body: data },
}).response;
return body.json() as Promise<Appointment>;
}
export async function updateAppointmentStatus(
id: string,
status: Appointment["status"]
): Promise<void> {
await put({
apiName: "PortalAPI",
path: `/appointments/${id}`,
options: { body: { status } },
}).response;
}React Query Integration
Pair the API client with TanStack Query for caching, loading states, and background refetch:
// src/hooks/useAppointments.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getAppointments, createAppointment, updateAppointmentStatus } from "../lib/api";
export function useAppointments(clinicId: string, date?: string) {
return useQuery({
queryKey: ["appointments", clinicId, date],
queryFn: () => getAppointments(clinicId, date),
staleTime: 30_000, // re-fetch after 30s
});
}
export function useCreateAppointment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createAppointment,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["appointments", variables.clinic_id],
});
},
});
}TypeScript Patterns for AWS Data
Handling DynamoDB Number Types
DynamoDB returns numbers as strings in some cases. Define a utility:
// src/lib/utils.ts
export function parseNumber(value: string | number | undefined): number {
if (value === undefined) return 0;
return typeof value === "string" ? parseFloat(value) : value;
}Typed API Response
// src/types/api.ts
export interface PaginatedResponse<T> {
items: T[];
nextToken?: string;
total?: number;
}
export interface ApiError {
error: string;
code?: string;
}
export function isApiError(obj: unknown): obj is ApiError {
return typeof obj === "object" && obj !== null && "error" in obj;
}Protected Routes
// src/components/ProtectedRoute.tsx
import { useEffect, useState } from "react";
import { getCurrentUser } from "aws-amplify/auth";
import { Navigate } from "react-router-dom";
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const [auth, setAuth] = useState<"loading" | "authed" | "unauthed">("loading");
useEffect(() => {
getCurrentUser()
.then(() => setAuth("authed"))
.catch(() => setAuth("unauthed"));
}, []);
if (auth === "loading") return <div className="p-8 text-center">Loading...</div>;
if (auth === "unauthed") return <Navigate to="/login" replace />;
return <>{children}</>;
}S3 File Uploads
Upload call recordings, patient documents, or avatars directly from the browser:
// src/lib/storage.ts
import { uploadData, getUrl } from "aws-amplify/storage";
export async function uploadDocument(
file: File,
patientId: string
): Promise<string> {
const key = `patients/${patientId}/docs/${Date.now()}-${file.name}`;
await uploadData({
key,
data: file,
options: {
contentType: file.type,
accessLevel: "protected", // only the signed-in user can read it
onProgress: ({ loaded, total }) => {
console.log(`Upload progress: ${Math.round((loaded / total!) * 100)}%`);
},
},
}).result;
const { url } = await getUrl({ key, options: { accessLevel: "protected" } });
return url.toString();
}Environment-Specific Config
Use Vite's .env files for each environment:
.env.local ā local dev (gitignored)
.env.development ā dev AWS env
.env.staging ā staging
.env.production ā productionIn CI/CD (GitHub Actions), inject vars at build time:
# .github/workflows/deploy.yml
- name: Build frontend
env:
VITE_API_URL: ${{ secrets.PROD_API_URL }}
VITE_COGNITO_USER_POOL_ID: ${{ secrets.PROD_USER_POOL_ID }}
VITE_COGNITO_CLIENT_ID: ${{ secrets.PROD_CLIENT_ID }}
VITE_AWS_REGION: us-east-1
run: npm run buildDeploying to S3 + CloudFront
- name: Deploy to S3
run: aws s3 sync dist/ s3://${{ secrets.S3_BUCKET }} --delete
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} \
--paths "/*"For cache optimization, configure long TTLs for hashed assets and short TTL for index.html:
# In Terraform ā CloudFront behavior for index.html
ordered_cache_behavior {
path_pattern = "/index.html"
min_ttl = 0
default_ttl = 0
max_ttl = 0
# Force re-check on every request
forwarded_values { query_string = false; cookies { forward = "none" } }
}Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.