Building a Full-Stack App with Bun, React, Shadcn, and PostgreSQL

I've been storing recipes in my notes app and bookmarks, but it became messy as my collection grew. I needed a better way to organize them. That's why I built RecipeBudd - a full-stack web application that makes recipe management simple and efficient. In this post, I'll walk through my process of building it.

View the complete project on GitHub.

Key Features

The goal is to create something minimal yet powerful—focusing on the features I actually needed without the bloat of commercial recipe apps.

  • Simple Recipe Storage: Add, edit, and delete recipes easily
  • Search: Find recipes by title, ingredients, or instructions
  • External Links: Save links to original recipes
  • Images: Add photos of your dishes
  • Responsive Design: Works on desktop and mobile

Tech Stack

For this project, I chose a modern tech stack that would allow for rapid development while maintaining performance and scalability:

Backend

  • Bun - Fast JavaScript runtime and package manager
  • Hono - Lightweight, high-performance web framework

Frontend

  • React - UI library for building component-based interfaces
  • Vite - Lightning-fast build tool and development server
  • React Router v7 - Client-side routing with the latest features
  • Tailwind CSS v4 - Utility-first CSS framework for rapid styling
  • shadcn/ui - Beautifully designed, accessible UI components
  • Framer Motion - Animation library for smooth transitions
  • TypeScript - Static typing for improved developer experience

Database

  • Drizzle ORM - TypeScript-first ORM with a great developer experience
  • PostgreSQL - Reliable, open-source relational database
RecipeBudd Demo

Project Setup

Let's walk through the complete setup process for RecipeBudd, breaking down each step in detail.

Creating the Project Structure

First, we create the root project directory:

bash
# Create and enter the project root directory
mkdir recipebudd
cd recipebudd

RecipeBudd is organized as a monorepo with separate frontend and backend packages, allowing for independent development while maintaining a cohesive codebase.

Root Project Structure
recipebudd/                    # Root project directory
├── frontend/                  # React frontend application
├── backend/                   # Hono API backend
├── package.json               # Root package.json for project-wide scripts
├── README.md                  # Project documentation
└──  .gitignore                 # Git ignore file

Setting Up the Backend

The backend of RecipeBudd is built with Bun, Hono, Better-Auth and Drizzle ORM, providing a fast and type-safe API for the frontend to interact with. Let's explore the backend architecture in detail:

Backend Directory Structure

Backend Structure
backend/
├── drizzle/                   # Drizzle ORM migrations
├── src/
│   ├── auth.ts                # Authentication middleware
│   ├── db.ts                  # Database connection
│   ├── index.ts               # API entry point and route definitions
│   ├── schema.ts              # Database schema definitions
│   └── migrate.ts             # Database migration script
├── .env                       # Environment variables
├── .env.example               # Example environment variables
├── drizzle.config.ts          # Drizzle ORM configuration
├── package.json               # Dependencies
└── tsconfig.json              # TypeScript configuration

Create and initialize the backend

bash
# Navigate back to the project root 
cd .. 

# Create the backend directory and enter it
mkdir backend
cd backend 

# Initialize the backend
bun init

# Add Hono web framework
bun add hono

# Add database dependencies
bun add drizzle-orm postgres

# Add development dependencies
bun add -d drizzle-kit

# Add authentication dependencies
bun add better-auth

Setting up database migrations

The drizzle.config.ts file configures how Drizzle ORM interacts with our PostgreSQL database, including where to find schema definitions and where to output migrations.

This file should be created in the backend directory:

backend/drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./src/schema.ts",
  out: "./drizzle",
  dbCredentials: {
    url: "postgresql://postgres:postgres@localhost:5432/recipebudd",
  },
});

The src directory contains all the source code for the backend application. Here's a breakdown of each file in this directory:

Create a src directory in your backend folder:

backend
mkdir src
cd src

This is the main server file that:

  • Sets up the Hono web framework
  • Configures CORS for cross-origin requests
  • Defines all API endpoints for recipe CRUD operations
  • Connects to the PostgreSQL database using Drizzle ORM
  • Handles request validation and error responses
  • Uses the auth middleware to protect routes

This script sets up the database connection and initializes the Better Auth configuration.

backend/src/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthMiddleware } from "better-auth/api";
import db from "./db"; // Your Drizzle database instance
import { user, session, verification, account } from "./schema"; // Import your schema

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg", // PostgreSQL
    schema: {
      user,
      session,
      verification,
      account, // Add the account table to the schema
    },
  }),
  emailAndPassword: {
    enabled: false,
  },
  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID as string,
      clientSecret: process.env.GITHUB_CLIENT_SECRET as string
    },
  },
  verification: {
    modelName: "verification", // Match your table name
    fields: {
      identifier: "identifier" // Match your field name
    },
    disableCleanup: false
  },
  advanced: {
    crossSubDomainCookies: {
      enabled: true,
    },
  },
  baseURL: "http://localhost:3001", // Ensure this matches your server URL
  trustedOrigins: ["http://localhost:3000" ],
  hooks: {
    // Add a before hook to log authentication requests
    before: createAuthMiddleware(async (ctx) => {
      console.log("Auth before hook:", ctx.path, ctx.body);
      
      // If this is a GitHub callback, log more details
      if (ctx.path === "/callback/github") {
        console.log("GitHub callback details:", {
          query: ctx.query,
          headers: ctx.headers,
        });
      }
    }),
    
    // Add an after hook to log the authentication process and handle redirects
    after: createAuthMiddleware(async (ctx) => {
      console.log("Auth after hook:", ctx.path);
      
      // Check if this is a social sign-in callback path
      if (ctx.path === "/callback/github") {
        const newSession = ctx.context.newSession;
        
        if (newSession) {
          console.log("New session created:", {
            userId: newSession.user.id,
            userName: newSession.user.name,
            userEmail: newSession.user.email,
          });
          
          // Redirect to frontend after successful authentication
          throw ctx.redirect("http://localhost:3000");
        } else {
          console.error("No session created after social login");
          
          // Redirect to frontend error page if authentication failed
          throw ctx.redirect("http://localhost:3000/auth-error");
        }
      }
    }),
  },
});

This is where the endpoints are that the client will interact with for CRUD operations. It also includes the auth middleware that will be used to protect routes.

backend/src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
import { drizzle } from "drizzle-orm/postgres-js";
import { eq, and } from "drizzle-orm";
import postgres from "postgres";
import { recipes } from "./schema";
import { auth } from "./auth"; // Import the auth configuration

// Create a PostgreSQL connection
const sql = postgres(process.env.DATABASE_URL!);
// Initialize drizzle
const db = drizzle(sql);

// Create Hono app with session type definitions
const app = new Hono<{
  Variables: {
    user: typeof auth.$Infer.Session.user | null;
    session: typeof auth.$Infer.Session.session | null;
  };
}>();

// Add CORS middleware with more permissive settings
app.use(
  "/*",
  cors({
    origin: ["http://localhost:3000"], // Your frontend URL
    allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
    allowHeaders: ["Content-Type", "Authorization", "Cookie", "Set-Cookie", "*"],
    exposeHeaders: ["Content-Length", "Set-Cookie", "*"],
    maxAge: 600,
    credentials: true,
  })
);

// Add auth session middleware
app.use("*", async (c, next) => {
  const session = await auth.api.getSession({ headers: c.req.raw.headers });

  if (!session) {
    c.set("user", null);
    c.set("session", null);
    return next();
  }

  c.set("user", session.user);
  c.set("session", session.session);
  return next();
});

// Mount the Better Auth handler (only once!) 
app.on(["GET", "POST", "OPTIONS", "PUT", "DELETE", "PATCH"], "/api/auth/*", (c) => {
  console.log("Auth request:", c.req.method, c.req.path, {
    headers: Object.fromEntries(c.req.raw.headers.entries()),
    url: c.req.url,
  });
  return auth.handler(c.req.raw);
});

// Get session info
app.get("/api/session", async (c) => {
  const session = c.get("session");
  const user = c.get("user");

  if (!user) return c.json({ error: "Unauthorized" }, 401);

  return c.json({
    session,
    user,
  });
});

// Get all recipes
app.get("/api/recipes", async (c) => {
  try {
    const user = c.get("user");
    
    // Check if user is authenticated
    if (!user) {
      return c.json({ error: "Authentication required" }, 401);
    }
    
    // Return only the authenticated user's recipes
    const items = await db.select().from(recipes).where(eq(recipes.userId, user.id));
    return c.json({ recipes: items });
  } catch (error) {
    console.error("Error fetching recipes:", error);
    return c.json({ error: "Failed to fetch recipes" }, 500);
  }
});

// Get a single recipe by ID
app.get("/api/recipes/:id", async (c) => {
  const id = Number(c.req.param("id"));

  if (isNaN(id)) {
    return c.json({ error: "Invalid ID format" }, 400);
  }

  try {
    const user = c.get("user");
    
    // Check if user is authenticated
    if (!user) {
      return c.json({ error: "Authentication required" }, 401);
    }
    
    const item = await db
      .select()
      .from(recipes)
      .where(and(eq(recipes.id, id), eq(recipes.userId, user.id)))
      .limit(1);

    if (item.length === 0) {
      return c.json({ error: "Recipe not found" }, 404);
    }

    return c.json({ recipe: item[0] });
  } catch (error) {
    console.error("Error fetching recipe:", error);
    return c.json({ error: "Failed to fetch recipe" }, 500);
  }
});

// Create a new recipe
app.post("/api/recipes", async (c) => {
  try {
    const user = c.get("user");
    
    // Check if user is authenticated
    if (!user) {
      return c.json({ error: "Authentication required" }, 401);
    }
    
    const body = await c.req.json();

    // Validate input
    if (!body.title || typeof body.title !== "string") {
      return c.json({ error: "Title is required and must be a string" }, 400);
    }

    if (!body.ingredients || typeof body.ingredients !== "string") {
      return c.json(
        { error: "Ingredients are required and must be a string" },
        400
      );
    }

    if (!body.instructions || typeof body.instructions !== "string") {
      return c.json(
        { error: "Instructions are required and must be a string" },
        400
      );
    }

    // Validate optional fields if provided
    if (body.website_url && typeof body.website_url !== "string") {
      return c.json({ error: "Website URL must be a string" }, 400);
    }

    if (body.image_url && typeof body.image_url !== "string") {
      return c.json({ error: "Image URL must be a string" }, 400);
    }

    // Insert into database with user ID
    const newRecipe = await db
      .insert(recipes)
      .values({
        title: body.title,
        ingredients: body.ingredients,
        instructions: body.instructions,
        website_url: body.website_url || null,
        image_url: body.image_url || null,
        userId: user.id, // Associate recipe with the authenticated user
      })
      .returning();

    return c.json({ recipe: newRecipe[0] }, 201);
  } catch (error) {
    console.error("Error creating recipe:", error);
    return c.json({ error: "Failed to create recipe" }, 500);
  }
});

// Update a recipe
app.put("/api/recipes/:id", async (c) => {
  const id = Number(c.req.param("id"));

  if (isNaN(id)) {
    return c.json({ error: "Invalid ID format" }, 400);
  }

  try {
    const user = c.get("user");
    
    // Check if user is authenticated
    if (!user) {
      return c.json({ error: "Authentication required" }, 401);
    }
    
    // Check if the recipe belongs to the user
    const existingRecipe = await db
      .select()
      .from(recipes)
      .where(and(eq(recipes.id, id), eq(recipes.userId, user.id)))
      .limit(1);
      
    if (existingRecipe.length === 0) {
      return c.json({ error: "Recipe not found or you don't have permission to update it" }, 404);
    }
    
    const body = await c.req.json();

    // Validate input
    if (
      (!body.title &&
        !body.ingredients &&
        !body.instructions &&
        !body.website_url &&
        !body.image_url) ||
      (body.title && typeof body.title !== "string") ||
      (body.ingredients && typeof body.ingredients !== "string") ||
      (body.instructions && typeof body.instructions !== "string") ||
      (body.website_url && typeof body.website_url !== "string") ||
      (body.image_url && typeof body.image_url !== "string")
    ) {
      return c.json(
        {
          error:
            "At least one valid field (title, ingredients, instructions, website_url, or image_url) is required",
        },
        400
      );
    }

    // Build update object with only provided fields
    const updateData: any = {};
    if (body.title) updateData.title = body.title;
    if (body.ingredients) updateData.ingredients = body.ingredients;
    if (body.instructions) updateData.instructions = body.instructions;
    if (body.website_url !== undefined)
      updateData.website_url = body.website_url;
    if (body.image_url !== undefined) updateData.image_url = body.image_url;

    // Always update the updated_at timestamp when modifying a recipe
    updateData.updated_at = new Date();

    const updatedRecipe = await db
      .update(recipes)
      .set(updateData)
      .where(eq(recipes.id, id))
      .returning();

    return c.json({ recipe: updatedRecipe[0] });
  } catch (error) {
    console.error("Error updating recipe:", error);
    return c.json({ error: "Failed to update recipe" }, 500);
  }
});

// Delete a recipe
app.delete("/api/recipes/:id", async (c) => {
  const id = Number(c.req.param("id"));

  if (isNaN(id)) {
    return c.json({ error: "Invalid ID format" }, 400);
  }

  try {
    const user = c.get("user");
    
    // Check if user is authenticated
    if (!user) {
      return c.json({ error: "Authentication required" }, 401);
    }
    
    // Check if the recipe belongs to the user
    const existingRecipe = await db
      .select()
      .from(recipes)
      .where(and(eq(recipes.id, id), eq(recipes.userId, user.id)))
      .limit(1);
      
    if (existingRecipe.length === 0) {
      return c.json({ error: "Recipe not found or you don't have permission to delete it" }, 404);
    }

    const deletedRecipe = await db
      .delete(recipes)
      .where(eq(recipes.id, id))
      .returning();

    return c.json({ success: true, recipe: deletedRecipe[0] });
  } catch (error) {
    console.error("Error deleting recipe:", error);
    return c.json({ error: "Failed to delete recipe" }, 500);
  }
});

// Start the server
const port = process.env.PORT || 3001;
console.log(`Server is running on port ${port}`);

export default {
  port,
  fetch: app.fetch,
};

This file defines the database schema using Drizzle ORM. It creates a recipes table with fields for storing recipe information.

backend/src/migrate.ts
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";

// Connect to database with a single connection
const sql = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(sql);

// Run migrations
console.log("Running migrations...");
migrate(db, { migrationsFolder: "drizzle" })
  .then(() => {
    console.log("Migrations completed successfully");
    process.exit(0);
  })
  .catch((error) => {
    console.error("Migration failed:", error);
    process.exit(1);
  });

This script runs database migrations using Drizzle ORM. It applies any pending migrations from the drizzle folder to keep your database schema up-to-date.

backend/src/schema.ts
import { pgTable, text, timestamp, serial, boolean, date} from "drizzle-orm/pg-core";

export const recipes = pgTable("recipes", {
  id: serial("id").primaryKey(),
  title: text("title").notNull(),
  ingredients: text("ingredients").notNull(),
  instructions: text("instructions").notNull(),
  website_url: text("website_url"),
  image_url: text("image_url"),
  userId: text("userId").notNull().references(() => user.id),
  created_at: timestamp("created_at").defaultNow().notNull(),
  updated_at: timestamp("updated_at").defaultNow().notNull(),
});

// Better Auth required tables
export const user = pgTable("user", {
  id: text("id").primaryKey(),
  name: text("name"),
  email: text("email"),
  emailVerified: boolean("emailVerified"),
  image: text("image"),
  createdAt: timestamp("createdAt").defaultNow().notNull(),
  updatedAt: timestamp("updatedAt").defaultNow().notNull(),
});

export const session = pgTable("session", {
  id: text("id").primaryKey(),
  userId: text("userId").notNull(),
  token: text("token").notNull(),
  expiresAt: date("expiresAt").notNull(),
  ipAddress: text("ipAddress"),
  userAgent: text("userAgent"),
  createdAt: timestamp("createdAt").defaultNow().notNull(),
  updatedAt: timestamp("updatedAt").defaultNow().notNull(),
});

// Updated verification table to match Better Auth documentation
export const verification = pgTable("verification", {
  id: text("id").primaryKey(),
  identifier: text("identifier").notNull(),
  value: text("value").notNull(),
  expiresAt: date("expiresAt").notNull(),
  createdAt: timestamp("createdAt").defaultNow().notNull(),
  updatedAt: timestamp("updatedAt").defaultNow().notNull(),
});

// Add account table as required by Better Auth
export const account = pgTable("account", {
  id: text("id").primaryKey(),
  userId: text("userId").notNull(),
  accountId: text("accountId").notNull(),
  providerId: text("providerId").notNull(),
  accessToken: text("accessToken"),
  refreshToken: text("refreshToken"),
  accessTokenExpiresAt: date("accessTokenExpiresAt"),
  refreshTokenExpiresAt: date("refreshTokenExpiresAt"),
  scope: text("scope"),
  idToken: text("idToken"),
  password: text("password"),
  createdAt: timestamp("createdAt").defaultNow().notNull(),
  updatedAt: timestamp("updatedAt").defaultNow().notNull(),
});

This initializes the database connection and exports it as a module.

backend/src/db.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

const db = drizzle(pool);

export default db;

Database Migration Commands

After setting up your backend files, you'll need to manage your database schema using Drizzle's migration commands. These commands help you create, track, and apply database changes in a controlled manner.

Drizzle Migration Commands

Database Migration Commands
# Generate migration files based on your schema
bun drizzle-kit generate

# Apply pending migrations to the database
bun drizzle-kit migrate

# Push schema changes directly to the database
bun drizzle-kit push

Setting Up the Frontend

The frontend of RecipeBudd is built with React, Vite, and Tailwind CSS, using shadcn/ui components for a polished user interface. Let's explore the key parts of the implementation:

We use Bun with Vite to create a React TypeScript application. Then we will use React Router v7 for routing. As well as shadcn-ui and tailwindcss for styling. Followed this resource for setting up tailwindcss, and for this one setting up shadcn-ui.

Frontend Directory Structure

Frontend Structure
frontend/
├── public/                           # Static assets
├── src/
│   ├── components/                   # Reusable UI components
│   │   ├── ui                        # UI components 
│   │   ├── GitHubLoginButton.tsx     # GitHub login button component
│   │   ├── ProtectedRoute.tsx        # Protected route component
│   │   ├── RecipeCard.tsx            # Recipe card component
│   │   └── RecipeList.tsx            # Recipe list component
│   ├── config/                       
│   │   └── api.ts                    # API configuration
│   ├── lib/                          
│   │   ├── auth-client.ts            # Authentication client
│   │   ├── auth-utils.ts             # Authentication utilities
│   │   └── utils.ts                  # Utility functions
│   ├── pages/                        # Page components
│   │   ├── AddRecipePage.tsx         # Add recipe page
│   │   ├── EditRecipePage.tsx        # Edit recipe page
│   │   ├── HomePage.tsx              # Home page
│   │   ├── LoginPage.tsx             # Login page
│   │   ├── RecipeDetailPage.tsx      # Recipe detail page
│   │   └── RecipesPage.tsx           # Recipes page
│   ├── styles/                       # Global styles
│   │   └── globals.css               # Global styles
│   ├── types/                        # TypeScript interfaces
│   │   └── recipe.ts                 # Recipe types
│   ├── App.tsx                       # Main application component
│   └── main.tsx                      # Application entry point
├── .env                              # Environment variables
├── package.json                      # Dependencies
├── tsconfig.json                     # TypeScript configuration
├── components.json                   # shadcn/ui configuration
├── vite.config.ts                    # Vite configuration
└── ...                   

Create and initialize the frontend

bash
# Create the frontend directory and enter it
bun create vite frontend --template react-ts  
cd frontend 

# Install dependencies
bun install

Then we want to add the following packages:

bash
# Add React Router v7
bun add react-router-dom@7 

# Add Tailwind CSS and Vite plugin 
bun add tailwindcss @tailwindcss/vite

# Add development dependencies
bun add -D @types/node

# Initialize Tailwind configuration
bunx tailwindcss init -p

Install required dependencies for shadcn/ui:

bash
# Add dependencies for shadcn/ui
bun add tailwindcss-animate class-variance-authority clsx tailwind-merge lucide-react
 
# Add button component 
bunx --bun shadcn@latest add button 

# Add card component
bunx --bun shadcn@latest add card

# Add input component
bunx --bun shadcn@latest add input

# Add dialog component
bunx --bun shadcn@latest add dialog

# Add form components
bunx --bun shadcn@latest add form

# Add all components at once (alternative)
bunx --bun shadcn@latest add -a

And add additional frontend useful libraries:

bash
# Add Framer Motion for animations
bun add framer-motion

# Add React Helmet for document head management
bun add react-helmet-async

This is where the endpoints are that the client will interact with for CRUD operations. It also includes the auth middleware that will be used to protect routes.

src/config/api.ts
// src/config/api.ts
export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3001";

// Helper functions for API calls
export const apiService = {
  get: (endpoint: string) => 
    fetch(`${API_URL}${endpoint}`, {
      credentials: 'include', // Include cookies in the request
    }),

  post: (endpoint: string, data: any) =>
    fetch(`${API_URL}${endpoint}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
      credentials: 'include', // Include cookies in the request
    }),

  put: (endpoint: string, data: any) =>
    fetch(`${API_URL}${endpoint}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
      credentials: 'include', // Include cookies in the request
    }),

  delete: (endpoint: string) =>
    fetch(`${API_URL}${endpoint}`, {
      method: "DELETE",
      credentials: 'include', // Include cookies in the request
    }),
};

This initializes the API client and exports it as a module.

src/lib/auth-client.ts
import { createAuthClient } from "better-auth/react"
export const authClient =  createAuthClient({
    baseURL: "http://localhost:3001" // the base url of your auth server
})

This creates a client for the Better Auth server and exports it as a module.

src/lib/auth-utils.ts
import { authClient } from "./auth-client";
import { NavigateFunction } from "react-router-dom";

// Create a navigation function that can be used with the router
let navigate: NavigateFunction | null = null;

// Function to set the navigate function from a component
export const setNavigate = (navigateFunction: NavigateFunction) => {
  navigate = navigateFunction;
};

export const authUtils = {
  // Check if user is logged in
  isAuthenticated: async () => {
    try {
      const session = await authClient.getSession();
      // Check if session exists and has data property
      return !!session && "data" in session && !!session.data?.user;
    } catch (error) {
      return false;
    }
  },

  // Get current user
  getCurrentUser: async () => {
    try {
      const session = await authClient.getSession();
      // Access user through the data property if it exists
      return session && "data" in session ? session.data?.user : null;
    } catch (error) {
      return null;
    }
  },

  // Login with GitHub
  login: () => {
    authClient.signIn.social({
      provider: "github",
      // Don't specify callbackURL here - let the backend handle redirects
    });
  },

  // Simplified logout to prevent backend crashes
  logout: async () => {
    try {
      // First, invalidate the session on the client side
      localStorage.removeItem("auth_session");
      sessionStorage.removeItem("auth_session");
      
      // Then send the signOut request with proper error handling
      await authClient.signOut({
        fetchOptions: {
          onSuccess: () => {
            console.log("Successfully signed out");
            window.location.href = "/login";
          },
          onError: (error) => {
            console.error("Sign-out error:", error);
            // Still redirect to login page on error
            window.location.href = "/login";
          }
        }
      });
    } catch (error) {
      console.error("Exception during sign-out:", error);
      window.location.href = "/login";
    }
  }
};

This is a React component that checks if the user is authenticated and redirects them to the login page if they're not. It also handles the redirection after successful authentication.

src/components/ProtectedRoute.tsx
import { useState, useEffect } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { authUtils } from "@/lib/auth-utils";

export function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
  const location = useLocation();

  useEffect(() => {
    const checkAuth = async () => {
      const authenticated = await authUtils.isAuthenticated();
      setIsAuthenticated(authenticated);
    };

    checkAuth();
  }, []);

  // Still checking authentication
  if (isAuthenticated === null) {
    return <div className="flex justify-center p-8">Loading...</div>;
  }

  // Not authenticated, redirect to login
  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  // Authenticated, render children
  return <>{children}</>;
}

This button component allows users to log in with GitHub. It uses the Better Auth client to handle the authentication process and displays an error message if the login fails.

src/components/GithubLoginButton.tsx
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
import { Github } from "lucide-react";
import { useState } from "react";

export function GitHubLoginButton() {
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleLogin = async () => {
    setIsLoading(true);
    setError(null);
    try {
      // Use the social method as specified in the Better Auth GitHub documentation
      const result = await authClient.signIn.social({
        provider: "github",
        callbackURL: "http://localhost:3000/recipes", // Specify where to redirect after successful authentication
      });

      console.log("Auth result:", result);
      // Note: No need to handle setIsLoading(false) here since we're redirecting
    } catch (error) {
      console.error("Login error:", error);
      setError("Failed to authenticate with GitHub. Please try again.");
      setIsLoading(false);
    }
  };

  return (
    <div className="w-full">
      <Button
        onClick={handleLogin}
        className="w-full flex items-center gap-2"
        variant="outline"
        disabled={isLoading}
      >
        {isLoading ? (
          <div className="h-4 w-4 animate-spin rounded-full border-b-2 border-current"></div>
        ) : (
          <Github className="h-4 w-4" />
        )}
        {isLoading ? "Signing in..." : "Sign in with GitHub"}
      </Button>
      {error && <p className="text-red-500 text-sm mt-2">{error}</p>}
    </div>
  );
}

This is the main application component that sets up the router and renders the Toaster component for displaying notifications. Also it sets up the ProtectedRoute component for authentication.

src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import App from "./App";
import "./styles/globals.css";
import { Toaster } from "@/components/ui/sonner";
import { ProtectedRoute } from "@/components/ProtectedRoute";
import LoginPage from "./pages/LoginPage";

// Import pages
import HomePage from "./pages/HomePage";
import RecipesPage from "./pages/RecipesPage";
import RecipeDetailPage from "./pages/RecipeDetailPage";
import AddRecipePage from "./pages/AddRecipePage";
import NotFoundPage from "./pages/NotFoundPage";
import EditRecipePage from "./pages/EditRecipePage";

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
    children: [
      { index: true, element: <HomePage /> },
      { path: "login", element: <LoginPage /> },
      {
        path: "recipes",
        element: (
          <ProtectedRoute>
            <RecipesPage />
          </ProtectedRoute>
        ),
      },
      {
        path: "recipes/add",
        element: (
          <ProtectedRoute>
            <AddRecipePage />
          </ProtectedRoute>
        ),
      },
      {
        path: "recipes/:id/edit",
        element: (
          <ProtectedRoute>
            <EditRecipePage />
          </ProtectedRoute>
        ),
      },
      { path: "recipes/:id", element: <RecipeDetailPage /> },
      { path: "*", element: <NotFoundPage /> },
    ],
  },
]);

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <RouterProvider router={router} />
    <Toaster />
  </React.StrictMode>
);
RecipeBudd Demo

This is the form where users can create new recipes. It uses the API client to send requests to the Better Auth server for authentication. It also uses the authUtils module to check if the user is authenticated and displays an error message if they're not. An example of how to use the authUtils module is shown below:

  • Check if the user is authenticated
  • If not, display an error message
  • If yes, proceed with the form submission
src/pages/AddRecipePage.tsx
import { useState, useEffect } from "react";
import { useNavigate, Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChevronLeft, Upload, X } from "lucide-react";
import { toast } from "sonner";
import { Label } from "@/components/ui/label";
import { apiService } from "@/config/api";
import { authUtils } from "@/lib/auth-utils";

export default function AddRecipePage() {
  const navigate = useNavigate();
  const [loading, setLoading] = useState(false);
  const [recipe, setRecipe] = useState({
    title: "",
    ingredients: "",
    instructions: "",
    imageUrl: "",
    website_url: "",
  });
  const [previewImage, setPreviewImage] = useState<string | null>(null);

  // Check authentication when component mounts
  useEffect(() => {
    const checkAuth = async () => {
      const isAuthenticated = await authUtils.isAuthenticated();
      if (!isAuthenticated) {
        toast.error("Authentication required", {
          description: "Please log in to create recipes",
        });
        navigate("/login");
      }
    };
    
    checkAuth();
  }, [navigate]);

  const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    // Check file size (limit to 5MB)
    if (file.size > 5 * 1024 * 1024) {
      toast.error("Image too large", {
        description: "Please select an image smaller than 5MB",
      });
      return;
    }

    // Create a preview URL
    const imageUrl = URL.createObjectURL(file);
    setPreviewImage(imageUrl);

    // In a real app, you would upload the image to a server or convert to base64
    // For this example, we'll simulate storing the image locally
    const reader = new FileReader();
    reader.onloadend = () => {
      setRecipe({
        ...recipe,
        imageUrl: reader.result as string,
      });
    };
    reader.readAsDataURL(file);
  };

  const clearImage = () => {
    setPreviewImage(null);
    setRecipe({
      ...recipe,
      imageUrl: "",
    });
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!recipe.title.trim() || !recipe.ingredients.trim() || !recipe.instructions.trim()) {
      toast.error("Missing information", {
        description: "Please fill in all required fields",
      });
      return;
    }

    setLoading(true);

    try {
      const response = await apiService.post("/api/recipes", {
        ...recipe,
        image_url: recipe.imageUrl,
      });

      // Handle authentication errors
      if (response.status === 401) {
        toast.error("Authentication required", {
          description: "Please log in to create recipes",
        });
        navigate("/login");
        return;
      }

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.error || "Failed to create recipe");
      }

      const data = await response.json();

      if (data.recipe) {
        toast.success("Recipe created", {
          description: `"${data.recipe.title}" has been added to your collection`,
        });
        navigate(`/recipes/${data.recipe.id}`);
      } else {
        throw new Error("Unexpected response format");
      }
    } catch (error) {
      console.error("Error creating recipe:", error);
      toast.error("Failed to create recipe", {
        description: error instanceof Error ? error.message : "Please try again later",
      });
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="max-w-3xl mx-auto">
      <div className="mb-6">
        <Link to="/recipes">
          <Button variant="ghost" className="gap-2">
            <ChevronLeft className="h-4 w-4" /> Back to Recipes
          </Button>
        </Link>
      </div>

      <Card>
        <CardHeader>
          <CardTitle>Add New Recipe</CardTitle>
        </CardHeader>
        <CardContent>
          <form onSubmit={handleSubmit} className="space-y-6">
            <div className="space-y-2">
              <Label htmlFor="title">
                Recipe Name <span className="text-red-500">*</span>
              </Label>
              <Input
                id="title"
                value={recipe.title}
                onChange={(e) => setRecipe({ ...recipe, title: e.target.value })}
                placeholder="Enter recipe name"
                required
              />
            </div>

            <div className="space-y-2">
              <Label htmlFor="website_url">Website URL (Optional)</Label>
              <Input
                id="website_url"
                type="url"
                value={recipe.website_url}
                onChange={(e) => setRecipe({ ...recipe, website_url: e.target.value })}
                placeholder="https://example.com/recipe"
              />
            </div>

            <div className="space-y-2">
              <Label htmlFor="image">Recipe Image (Optional)</Label>
              <div className="flex items-center gap-4">
                <Button
                  type="button"
                  variant="outline"
                  onClick={() => document.getElementById("image")?.click()}
                  className="gap-2"
                >
                  <Upload className="h-4 w-4" /> Upload Image
                </Button>
                <Input
                  id="image"
                  type="file"
                  accept="image/*"
                  onChange={handleImageChange}
                  className="hidden"
                />
                {previewImage && (
                  <Button
                    type="button"
                    variant="ghost"
                    size="sm"
                    onClick={clearImage}
                    className="text-red-500 hover:text-red-600"
                  >
                    <X className="h-4 w-4 mr-1" /> Remove
                  </Button>
                )}
              </div>
              {previewImage && (
                <div className="mt-4 relative rounded-md overflow-hidden">
                  <img
                    src={previewImage}
                    alt="Recipe preview"
                    className="max-h-[200px] w-auto object-contain"
                  />
                </div>
              )}
            </div>

            <div className="space-y-2">
              <Label htmlFor="ingredients">
                Ingredients <span className="text-red-500">*</span>
              </Label>
              <Textarea
                id="ingredients"
                value={recipe.ingredients}
                onChange={(e) => setRecipe({ ...recipe, ingredients: e.target.value })}
                placeholder="Enter ingredients (one per line)"
                rows={6}
                required
              />
            </div>

            <div className="space-y-2">
              <Label htmlFor="instructions">
                Instructions <span className="text-red-500">*</span>
              </Label>
              <Textarea
                id="instructions"
                value={recipe.instructions}
                onChange={(e) => setRecipe({ ...recipe, instructions: e.target.value })}
                placeholder="Enter step-by-step instructions"
                rows={8}
                required
              />
            </div>

            <div className="flex justify-end gap-4">
              <Button type="button" variant="outline" onClick={() => navigate("/recipes")}>
                Cancel
              </Button>
              <Button type="submit" disabled={loading}>
                {loading ? "Creating..." : "Create Recipe"}
              </Button>
            </div>
          </form>
        </CardContent>
      </Card>
    </div>
  );
}

Conclusion

In this tutorial, we've built RecipeBudd - a full-stack web application using Bun, React, Shadcn UI, and PostgreSQL. This modern tech stack offers numerous advantages for developers:

  • Development Speed: Bun's all-in-one toolkit accelerates the development workflow by replacing multiple tools with a single runtime
  • Performance: Bun's JavaScript runtime is significantly faster than Node.js, providing quicker startup times and better overall application performance
  • Modern Authentication: Better-Auth provides a secure, flexible solution that simplifies social authentication

Can view the full project on GitHub.

Next Steps

  • Deploy with SST for seamless cloud infrastructure
  • Leverage Bun's advanced performance features
  • Add recipe link scraping to automatically extract ingredients from URLs
  • Implement collaborative features like meal planning and shopping lists
Published: Mar 14, 2025
Get the latest from me on my newsletter! I'll share resources I've come across and keep you up to date on my latest projects.