Skip to content

Repository files navigation

🚀 React Router + Fastify: Pure Google OAuth Monorepo Template

A lightweight, secure, and developer-friendly template for implementing Google OAuth 2.0 (Authorization Code Flow with PKCE) across a React Router (v7) frontend and a Fastify backend.

The Philosophy: Zero bloated wrappers. No Auth0, no Supabase, no Firebase. Just pure HTTP-only cookies, JSON Web Tokens (JWT), and the official Fastify plugins, giving you 100% control over your authentication flow and user data.


✨ Features

  • Monorepo Ready: Designed for pnpm workspaces (apps/web, apps/backend).
  • Bulletproof Security: Uses HttpOnly, Secure, and SameSite=lax cookies. Tokens are never exposed to client-side JavaScript.
  • Seamless SSR: React Router loader functions safely forward session cookies to the backend for server-side route protection.
  • Type-Safe: Full TypeScript support, including Fastify plugin namespace augmentation.

🛠 Tech Stack

  • Frontend: React Router (Framework Mode), Tailwind CSS, TypeScript
  • Backend: Fastify, @fastify/oauth2, @fastify/jwt, @fastify/cookie
  • Package Manager: pnpm

🚦 Getting Started: Step-by-Step Guide

Step 1: Google Cloud Console Setup

  1. Go to the Google Cloud Console.
  2. Create a new project and navigate to APIs & Services > Credentials.
  3. Click Create Credentials > OAuth client ID (Application type: Web application).
  4. Add your frontend URL to Authorized JavaScript origins:
  • http://localhost:5190
  1. Add your backend callback URL to Authorized redirect URIs:
  • http://localhost:8080/auth/google/callback
  1. Save the generated Client ID and Client Secret.

Step 2: Environment Variables

Create a .env file in your backend directory:

NODE_ENV=development
VITE_ENV=development

BACKEND_PORT=8080

WEB_URL=http://localhost:5170
API_BASE_URL=http://localhost:8080

GOOGLE_CLIENT_ID=YOUR_GOOGLE_GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_GOOGLE_CLIENT_SECRET

JWT_SECRET=edd00844d336c590bc83083d399a41e3183edacfed9704387992faf1a3c46a405420046f4a0e3009c82ea2b915201ba9f3693de57bb449aab3aeea847d55a512

💻 Implementation Details

1. The Fastify Backend (apps/backend)

Install the required plugins:

cd apps/backend
pnpm add @fastify/oauth2 @fastify/cookie @fastify/jwt @fastify/cors

TypeScript Augmentation

To ensure TypeScript recognizes the @fastify/oauth2 plugin, create a type declaration file (e.g., types/fastify.d.ts):

import "fastify";
import type { OAuth2Namespace } from "@fastify/oauth2";

declare module "fastify" {
    interface FastifyInstance {
        googleOAuth2: OAuth2Namespace;
    }
    interface FastifyReply {
        success<D>(data: D, message?: string, statusCode?: number): FastifyReply;
    }
}

Server Configuration (server.ts)

Set up the plugins and the OAuth routes. The backend handles the callback, signs a JWT, and issues an HttpOnly cookie.

import type { FastifyInstance } from "fastify";
import fastifyCookie from "@fastify/cookie";
import fastifyCors from "@fastify/cors";
import jwt from "@fastify/jwt";
import oauthPlugin from "@fastify/oauth2";
import { ApiError } from "~/utils/ApiError";

export async function server(fastify: FastifyInstance) {
    // 1. CORS Setup (Critical for cross-origin cookies)
    await fastify.register(fastifyCors, {
        origin: [process.env.WEB_URL || "http://localhost:5190"],
        credentials: true,
    });

    await fastify.register(fastifyCookie);

    // 2. JWT Setup (Configured to read from the 'session' cookie)
    await fastify.register(jwt, {
        secret: process.env.JWT_SECRET || "super-secret-key-change-in-prod",
        cookie: { cookieName: "session", signed: false },
    });

    // 3. OAuth2 Configuration
    await fastify.register(oauthPlugin, {
        name: "googleOAuth2",
        credentials: {
            client: {
                id: process.env.GOOGLE_CLIENT_ID as string,
                secret: process.env.GOOGLE_CLIENT_SECRET as string,
            },
            auth: oauthPlugin.GOOGLE_CONFIGURATION,
        },
        startRedirectPath: "/auth/google",
        callbackUri: "http://localhost:8080/auth/google/callback",
        scope: ["profile", "email"],
        pkce: "S256",
    });

    // 4. Handle Google Callback
    fastify.get("/auth/google/callback", async (req, reply) => {
        const { token } = await fastify.googleOAuth2.getAccessTokenFromAuthorizationCodeFlow(req);

        const userInfoResponse = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
            headers: { Authorization: `Bearer ${token.access_token}` },
        });
        const userInfo = await userInfoResponse.json();

        const sessionToken = fastify.jwt.sign({
            id: userInfo.id,
            email: userInfo.email,
            name: userInfo.name,
            picture: userInfo.picture,
        });

        reply.setCookie("session", sessionToken, {
            path: "/",
            httpOnly: true,
            secure: process.env.NODE_ENV === "production",
            sameSite: "lax",
            maxAge: 60 * 60 * 24 * 7, // 1 week
        });

        reply.redirect("http://localhost:5190/");
    });

    // 5. Verify Session Route
    fastify.get("/api/me", async (req, reply) => {
        try {
            await req.jwtVerify({ onlyCookie: true });
            return reply.success(req.user, "Success");
        } catch (err) {
            throw new ApiError("Unauthorized", 401, "UNAUTHORIZED");
        }
    });

    // 6. Logout Route
    fastify.post("/api/logout", async (_req, reply) => {
        reply.clearCookie("session", { path: "/" });
        return reply.success(null, "Logged out successfully");
    });
}

2. The React Router Frontend (apps/web)

In React Router framework mode, loader functions run on the server. To authorize a user before rendering, the loader must extract the incoming cookie from the browser's request and forward it to the Fastify backend.

// apps/web/app/routes/home.tsx
import { useLoaderData, useRevalidator } from "react-router";
import type { Route } from "./+types/home";

export function meta({}: Route.MetaArgs) {
    return [{ title: "Google OAuth Template" }];
}

// 1. SSR Loader: Forwards the browser's cookie to Fastify to verify the session
export const loader = async ({ request }: Route.LoaderArgs) => {
    const cookie = request.headers.get("Cookie") || "";

    const userResponse = await fetch("http://localhost:8080/api/me", {
        method: "GET",
        headers: {
            "Content-Type": "application/json",
            Cookie: cookie, // Forwarding the HttpOnly cookie
        },
    });

    const user = await userResponse.json();
    return { user };
};

export default function Home() {
    const { user } = useLoaderData<typeof loader>();
    const revalidator = useRevalidator();

    // 2. Logout Handler: Clears the HttpOnly cookie via the API
    const handleLogout = async () => {
        try {
            await fetch("http://localhost:8080/api/logout", {
                method: "POST",
                credentials: "include", // Required to send the cookie across ports
            });
            // Re-run the loader to update UI state
            revalidator.revalidate();
        } catch (error) {
            console.error("Failed to logout:", error);
        }
    };

    return (
        <div className="min-h-screen flex items-center justify-center bg-gray-50">
            {user.success ? (
                <div className="flex items-center justify-center flex-col gap-4">
                    <img src={user.data.picture} className="w-16 h-16 rounded-full shadow-md" alt="Profile" />
                    <div className="text-center">
                        <h1 className="text-2xl font-bold text-gray-900">Hello, {user.data.name}</h1>
                        <p className="text-gray-500">{user.data.email}</p>
                    </div>
                    <button
                        onClick={handleLogout}
                        className="px-4 py-2 mt-2 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all"
                    >
                        Logout
                    </button>
                </div>
            ) : (
                // 3. Login Trigger: Direct link to the backend OAuth initialization
                <a href="http://localhost:8080/auth/google">
                    <button className="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all">
                        <GoogleIcon />
                        <span className="text-sm font-medium text-gray-700">Continue with Google</span>
                    </button>
                </a>
            )}
        </div>
    );
}

const GoogleIcon = () => (
    <svg className="w-5 h-5 mr-3" viewBox="0 0 533.5 544.3" xmlns="http://www.w3.org/2000/svg">
        <path fill="#4285F4" d="M533.5 278.4c0-18.5-1.5-36.2-4.3-53.4H272v101.1h146.9c-6.3 34.4-25.7 63.5-54.6 83v68h88.2c51.6-47.6 81-117.5 81-198.7z" />
        <path fill="#34A853" d="M272 544.3c73.7 0 135.6-24.3 180.8-66.1l-88.2-68c-24.6 16.5-56.2 26.3-92.6 26.3-71 0-131.2-47.9-152.7-112.2H29.5v70.6C74.8 480.2 167.6 544.3 272 544.3z" />
        <path fill="#FBBC05" d="M119.3 323.3c-10.9-32.7-10.9-67.9 0-100.6V152.1H29.5c-39.4 76.9-39.4 168.4 0 245.3l89.8-74.1z" />
        <path fill="#EA4335" d="M272 107.7c39.9 0 75.8 13.7 104.1 40.6l78-78C407.6 24 345.7 0 272 0 167.6 0 74.8 64.1 29.5 152.1l89.8 70.6C140.8 155.6 201 107.7 272 107.7z" />
    </svg>
);

🧠 Architectural Flow Summary

  1. Trigger: The user clicks "Continue with Google," making a standard HTTP navigation directly to the Fastify backend (/auth/google).
  2. Handshake: Fastify redirects to Google's consent screen, generating a secure PKCE state.
  3. Callback: Google redirects back to Fastify (/auth/google/callback) with an authorization code.
  4. Exchange & Sign: Fastify exchanges the code for tokens, fetches the profile, creates an internal JWT, and sets it as an HttpOnly cookie.
  5. Session Verification: On any React Router route, the SSR loader acts as a proxy, intercepting the cookie from the browser and passing it to Fastify (/api/me) to validate the user before rendering the page.

About

A pure, wrapper-free pnpm monorepo template featuring React Router v7 and Fastify, secured by native Google OAuth 2.0 (PKCE) and HttpOnly JWT cookies.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages