DEV Community

Cover image for Building a Todo App with Auth0 + AWS Amplify Gen2 + Next.js 16

Building a Todo App with Auth0 + AWS Amplify Gen2 + Next.js 16

Building a Todo App with Auth0 + AWS Amplify Gen2 + Next.js 16

Introduction

AWS Amplify Gen2 offers a robust data layer supported by AppSync and DynamoDB, but what if you prefer using an external identity provider like Auth0 instead of Cognito User Pools for authentication?

In this tutorial, we'll build a real-time Todo application that combines:

  • Auth0 for authentication (Universal Login with social providers)
  • AWS Amplify Gen2 for the data layer (AppSync GraphQL + DynamoDB)
  • Next.js 16 with App Router (Server Components + Client Components)
  • React 19 form actions for modern form handling

By the end, you'll have a fully functional app where users can log in with Auth0, and their todos are safely stored in DynamoDB with owner-based authorization through AppSync's OIDC auth mode.

Screenshot of the finished app

Prerequisites

  • AWS account
  • Auth0 account (free tier works)
  • Node.js 20+
  • pnpm (or npm/yarn)

Tech Stack

Category Technology Version
Framework Next.js (App Router) 16.2.9
UI React 19.2.4
Auth @auth0/nextjs-auth0 4.22.0
Backend AWS Amplify Gen2 6.18.0
Data AppSync (GraphQL) + DynamoDB
Identity Cognito Identity Pool (OIDC Federation)

Architecture

Project Structure

.
├─ amplify/
│   ├─ auth/resource.ts          # Cognito Identity Pool definition
│   ├─ backend.ts                # IAM OIDC provider + Identity Pool config
│   └─ data/resource.ts          # AppSync schema + OIDC auth mode
├─ app/
│   ├─ lib/auth0.ts              # Auth0Client instance
│   ├─ page.tsx                  # Server Component (session retrieval)
│   ├─ layout.tsx                # Root layout
│   ├─ CustomCredentialsProvider.ts  # Cognito Identity federation
│   └─ components/
│       ├─ TodoList.tsx           # Client Component (Amplify data + real-time)
│       ├─ LoginBox.tsx           # Login UI container
│       ├─ LoginButton.tsx        # <a href="/auth/login">
│       ├─ LogoutButton.tsx       # <a href="/auth/logout">
│       └─ Profile.tsx            # User profile display
├─ proxy.ts                       # Auth0 middleware (handles /auth/* routes)
├─ .env                           # Environment variables
└─ amplify_outputs.json           # Generated by `ampx sandbox`
Enter fullscreen mode Exit fullscreen mode

System Diagram

Browser
  └─ Next.js App (localhost:3000)
       ├─ Server Component (page.tsx)
       │    └─ Auth0 session retrieval
       ├─ Client Component (TodoList.tsx)
       │    ├─ Amplify configure (OIDC token + credentials provider)
       │    ├─ AppSync GraphQL (observeQuery / create)
       │    └─ Cognito Identity Pool (temporary AWS credentials)
       └─ proxy.ts (Auth0 middleware)
            ├─ /auth/login → Auth0 authorize
            ├─ /auth/callback → token exchange
            └─ /auth/logout → Auth0 logout

AWS Amplify Gen2
  ├─ AppSync (GraphQL API, OIDC auth)
  ├─ DynamoDB (Todo table)
  └─ Cognito Identity Pool (Auth0 OIDC federation)

Auth0
  └─ Universal Login (Google OAuth, etc.)
Enter fullscreen mode Exit fullscreen mode

Step 1: Auth0 Setup

Create a Single Page Web Application or Regular Web Application in the Auth0 Dashboard.

Configure the following:

  • Allowed Callback URLs: http://localhost:3000/auth/callback
  • Allowed Logout URLs: http://localhost:3000

Note down these values:

  • Domain (e.g., dev-xxx.us.auth0.com)
  • Client ID
  • Client Secret

Step 2: Project Initialization

Install Vite+ CLI

macOS with Homebrew

brew install vite-plus
Enter fullscreen mode Exit fullscreen mode

macOS/Linux with curl

curl -fsSL https://vite.plus | bash
Enter fullscreen mode Exit fullscreen mode

Windows

irm https://vite.plus/ps1 | iex
Enter fullscreen mode Exit fullscreen mode

Create a new Next.js project

vp create create-next-app -- my-app
Enter fullscreen mode Exit fullscreen mode

Add dependencies

pnpm add aws-amplify @aws-amplify/backend @auth0/nextjs-auth0 @aws-sdk/client-cognito-identity dotenv
Enter fullscreen mode Exit fullscreen mode

If you see [ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: core-js@2.6.12, core-js@3.50.0, run the following:

pnpm approve-builds

Step 3: Environment Variables

Create a .env file at the project root:

# Auth0 NextJS SDK
AUTH0_DOMAIN=https://dev-xxx.us.auth0.com
AUTH0_CLIENT_ID=your_client_id
AUTH0_CLIENT_SECRET=your_client_secret
AUTH0_SECRET=a_random_32_char_string
APP_BASE_URL=http://localhost:3000

# Frontend
NEXT_AUTH0_DOMAIN=dev-xxx.us.auth0.com
NEXT_AUTH0_CLIENT_ID=your_client_id
Enter fullscreen mode Exit fullscreen mode

Important: AUTH0_DOMAIN must include the https:// prefix. AppSync's OIDC configuration requires a full URL as the Issuer URI.

Step 4: Amplify Backend

4-1. Auth Resource (amplify/auth/resource.ts)

import { defineAuth } from "@aws-amplify/backend";

export const auth = defineAuth({
  loginWith: {
    email: true,
  },
});
Enter fullscreen mode Exit fullscreen mode

This creates a Cognito Identity Pool that we'll use for federated access.

4-2. Backend Definition (amplify/backend.ts)

import { defineBackend } from "@aws-amplify/backend";
import { auth } from "./auth/resource";
import { data } from "./data/resource";
import * as iam from "aws-cdk-lib/aws-iam";
import { config } from "dotenv";

config();

const backend = defineBackend({ auth, data });

const auth0Domain = process.env.AUTH0_DOMAIN;
const auth0ClientId = process.env.AUTH0_CLIENT_ID;

if (!auth0Domain || !auth0ClientId) {
  throw new Error("AUTH0_DOMAIN and AUTH0_CLIENT_ID must be set");
}

// Create IAM OIDC provider for Auth0
const oidcProvider = new iam.OpenIdConnectProvider(
  backend.auth.resources.cfnResources.cfnIdentityPool.stack,
  "Auth0OIDCProvider",
  {
    url: auth0Domain,
    clientIds: [auth0ClientId],
  },
);

// Register Auth0 with the Identity Pool
const identityPool = backend.auth.resources.cfnResources.cfnIdentityPool;
identityPool.openIdConnectProviderArns = [
  ...(identityPool.openIdConnectProviderArns || []),
  oidcProvider.openIdConnectProviderArn,
];
Enter fullscreen mode Exit fullscreen mode

4-3. Data Resource (amplify/data/resource.ts)

import { type ClientSchema, a, defineData } from "@aws-amplify/backend";
import { config } from "dotenv";

config();

const auth0Domain = process.env.AUTH0_DOMAIN || "";
const oidcIssuerUrl = auth0Domain.startsWith("https://") ? auth0Domain : `https://${auth0Domain}`;

const schema = a.schema({
  Todo: a
    .model({
      content: a.string(),
    })
    .authorization((allow) => [allow.owner("oidc").identityClaim("sub")]),
});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: "oidc",
    oidcAuthorizationMode: {
      oidcProviderName: "Auth0",
      oidcIssuerUrl: oidcIssuerUrl,
      clientId: process.env.AUTH0_CLIENT_ID,
      tokenExpiryFromAuthInSeconds: 0,
      tokenExpireFromIssueInSeconds: 3600,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Note: tokenExpiryFromAuthInSeconds: 0 disables the auth_time check in AppSync, since Auth0 tokens don't include this claim by default.

Note: We call dotenv.config() directly in this file because ES module import order means backend.ts's config() hasn't run yet when data/resource.ts is evaluated.

Step 5: Auth0 SDK Configuration

5-1. Auth0 Client (app/lib/auth0.ts)

import { Auth0Client } from "@auth0/nextjs-auth0/server";

export const auth0 = new Auth0Client();
Enter fullscreen mode Exit fullscreen mode

5-2. Middleware (proxy.ts)

import { auth0 } from "./app/lib/auth0";

export async function proxy(request: Request) {
  return await auth0.middleware(request);
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)"],
};
Enter fullscreen mode Exit fullscreen mode

This automatically handles /auth/login, /auth/callback, and /auth/logout routes.

Step 6: Server Component (app/page.tsx)

import { auth0 } from "./lib/auth0";
import TodoList from "./components/TodoList";
import LoginBox from "./components/LoginBox";

export default async function Home() {
  const session = await auth0.getSession();
  const user = session?.user;

  return (
    <main>
      {user ? (
        <TodoList
          idToken={session?.tokenSet.idToken || ""}
          auth0Domain={process.env.NEXT_AUTH0_DOMAIN || ""}
        />
      ) : (
        <LoginBox />
      )}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key design decision: The Server Component retrieves the Auth0 session and passes the idToken down to the Client Component as a prop. This avoids accessing browser-only APIs on the server.

Step 7: Client Component with Real-time Subscriptions

This is the core of the app. The TodoList component:

  1. Configures Amplify with a custom credentials provider (Cognito Identity Pool federation)
  2. Subscribes to real-time updates via observeQuery
  3. Uses React 19's useActionState for form submission
"use client";

import { useEffect, useState, useActionState, useRef } from "react";
import { Amplify } from "aws-amplify";
import { decodeJWT, TokenProvider } from "aws-amplify/auth";
import { generateClient } from "aws-amplify/api";
import outputs from "../../amplify_outputs.json";
import type { Schema } from "@/amplify/data/resource";

// ... CustomCredentialsProvider class (see full source)

export default function TodoList({ idToken, auth0Domain }) {
  const [todos, setTodos] = useState([]);
  const [isConfigured, setIsConfigured] = useState(false);
  const [client, setClient] = useState(null);
  const formRef = useRef(null);

  // React 19 form action pattern
  const [_state, addTodoAction, isPending] = useActionState(
    async (_prev, formData) => {
      const content = formData.get("content")?.trim();
      if (!content || !client) return null;

      await client.models.Todo.create({ content }, { authMode: "oidc" });
      formRef.current?.reset();
      return null;
    },
    null,
  );

  useEffect(() => {
    // Configure Amplify with Auth0 tokens
    const customCredentialsProvider = new CustomCredentialsProvider();
    const tokenProvider = {
      async getTokens() {
        if (!idToken) return null;
        return {
          accessToken: decodeJWT(idToken),
          idToken: decodeJWT(idToken),
        };
      },
    };

    customCredentialsProvider.loadFederatedLogin({
      domain: auth0Domain,
      token: idToken,
    });

    Amplify.configure(outputs, {
      Auth: {
        credentialsProvider: customCredentialsProvider,
        tokenProvider,
      },
    });

    // IMPORTANT: Share a single client instance
    setClient(generateClient());
    setIsConfigured(true);
  }, [idToken, auth0Domain]);

  useEffect(() => {
    if (!isConfigured || !client) return;

    const sub = client.models.Todo.observeQuery({ authMode: "oidc" }).subscribe({
      next: ({ items }) => setTodos([...items]),
    });

    return () => sub.unsubscribe();
  }, [isConfigured, client]);

  return (
    <div>
      <form ref={formRef} action={addTodoAction}>
        <input type="text" name="content" disabled={isPending} />
        <button type="submit" disabled={isPending}>
          {isPending ? "..." : "Add"}
        </button>
      </form>

      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.content}</li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why share the client instance?

If you call generateClient() in both the subscription setup and the mutation handler, they create separate instances. The subscription won't receive mutation events from a different client. It's best to share a single instance through the state to ensure everything works smoothly.

Step 8: Login/Logout Buttons

For authentication routes that redirect externally, use plain <a> tags instead of next/link:

// LoginButton.tsx
export default function LoginButton() {
  return (
    <a href="/auth/login">Login</a>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why not next/link? The <Link> component attempts a client-side navigation by fetching an RSC payload first. Since /auth/login redirects to Auth0's domain via middleware, the fetch follows the redirect cross-origin and hits a CORS error. Plain <a> triggers a full browser navigation, which handles redirects correctly.

Gotchas and Troubleshooting

Cookie Size (HTTP 431)

Auth0 session cookies can grow large (encrypted JWTs with lengthy profile data). If you see 431 Request Header Fields Too Large:

  1. Clear cookies for localhost:3000
  2. Increase the header size limit:
{
  "scripts": {
    "dev": "NODE_OPTIONS='--max-http-header-size=32768' next dev"
  }
}
Enter fullscreen mode Exit fullscreen mode

dotenv Loading Order

In ES modules, imports are evaluated before module-level code runs. If backend.ts imports data/resource.ts before calling dotenv.config(), environment variables will be undefined in data/resource.ts. Solution: call config() at the top of each file that needs env vars.

Server vs. Client Component Boundary

Operation Where
Auth0 session retrieval Server Component
Amplify configure Client Component
AppSync queries/mutations Client Component
Real-time subscriptions Client Component
sessionStorage / localStorage Client Component

Running the App

# Deploy the Amplify sandbox
npx ampx sandbox

# Start the dev server (in another terminal)
pnpm dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000, log in with Auth0, and start adding todos. They'll appear in real-time thanks to AppSync subscriptions.

Conclusion

Integrating Auth0 with AWS Amplify Gen2 is very much possible, though it requires understanding a few boundaries:

  1. OIDC auth mode in AppSync works well with Auth0 tokens
  2. Server/Client Component separation is critical — auth session on the server, Amplify data on the client
  3. React 19 form actions simplify async form handling without FormEvent
  4. Shared client instances are necessary for real-time subscriptions to work correctly

The full source code is available on GitHub:

This is a Next.js project bootstrapped with create-next-app.

Getting Started

First, run the development server:

npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000 with your browser to see the result.

You can start editing the page by modifying app/page.tsx. The page auto-updates as you edit the file.

This project uses next/font to automatically optimize and load Geist, a new font family for Vercel.

Learn More

To learn more about Next.js, take a look at the following resources:

You can check out the Next.js GitHub repository - your feedback and contributions are welcome!

Deploy on Vercel

The easiest way to deploy your Next.js app is to use the Vercel Platform from the creators of Next.js.

Check out our Next.js deployment documentation for more…





Reference

Top comments (0)