Back to blog
Frontend Engineeringintermediate

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.

LearnixoApril 16, 20266 min read
ReactTypeScriptAWS AmplifyCognitoFrontendServerlessVite
Share:š•

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

Bash
npm create vite@latest portal -- --template react-ts
cd portal
npm install aws-amplify @aws-amplify/ui-react

Amplify Configuration

Create a config file driven by environment variables:

TYPESCRIPT
// 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:

TYPESCRIPT
// 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-1

Authentication with Cognito

Pre-built UI Component

The fastest way to ship auth — a complete sign-in/sign-up/MFA UI:

TSX
// 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:

TYPESCRIPT
// 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:

TYPESCRIPT
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:

TYPESCRIPT
// 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:

TYPESCRIPT
// 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:

TYPESCRIPT
// 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

TYPESCRIPT
// 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

TSX
// 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:

TYPESCRIPT
// 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     ← production

In CI/CD (GitHub Actions), inject vars at build time:

YAML
# .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 build

Deploying to S3 + CloudFront

YAML
- 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:

HCL
# 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?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.