A production-ready, full-stack video sharing platform built with Next.js 16, featuring secure authentication, real-time video uploads to BunnyCDN, database integration, and advanced security with rate limiting. This platform enables users to upload, share, and manage video content with granular privacy controls.
- 📹 Video Upload & Streaming: Upload videos directly to BunnyCDN with real-time progress tracking
- 🖼️ Thumbnail Management: Automatic thumbnail upload and CDN delivery
- 👁️ View Tracking: Intelligent view counting system with 3-second delay to prevent false counts
- 🔒 Privacy Controls: Toggle videos between public and private visibility
- 🗑️ Video Management: Delete videos with ownership verification
- 🔗 Link Sharing: Copy video links to clipboard with visual feedback
- 🔍 Search & Filter: Search videos by title with multiple sorting options (Most Viewed, Most Recent, Oldest First, Least Viewed)
- 📄 Pagination: Efficient video browsing with server-side pagination
- 🔐 Google OAuth Integration: Secure sign-in with Better Auth
- 🛡️ Rate Limiting: Arcjet-powered rate limiting to prevent abuse (2 uploads per minute)
- 🚪 Protected Routes: Middleware-based authentication for all protected pages
- 👤 Session Management: Secure cookie-based sessions with automatic expiration
- 🔑 Ownership Verification: Server-side checks for video deletion and visibility updates
- 👥 User Profiles: View user-specific video collections
- 🎨 Modern UI: Custom design system with Tailwind CSS v4
- 📱 Responsive Design: Mobile-first approach with adaptive layouts
- ⚡ Fast Navigation: Optimized routing with Next.js App Router
- 🎭 Empty States: Helpful UI when no content is available
- 📊 Video Cards: Rich video previews with thumbnails, metadata, and duration
- 🗄️ Database ORM: Type-safe queries with Drizzle ORM
- 🌐 CDN Integration: BunnyCDN for video streaming and thumbnail delivery
- 🔄 Real-time Updates: Automatic page revalidation after data mutations
- 🎯 TypeScript: End-to-end type safety
- 🧩 Modular Architecture: Clean separation of concerns with server actions
- 🔧 Error Handling: Comprehensive error handling with user-friendly messages
- Tech Stack
- Architecture Overview
- Project Structure
- Prerequisites
- Installation & Setup
- Environment Variables
- Database Schema
- Authentication Flow
- Video Upload Flow
- Security Implementation
- API Documentation
- Deployment
- Troubleshooting
- Framework: Next.js 16 (App Router)
- Language: TypeScript 5.9
- Styling: Tailwind CSS v4
- UI Components: Custom React components with client/server separation
- Fonts: Karla & Satoshi (Custom Google Fonts)
- Runtime: Node.js (forced in middleware for Better Auth compatibility)
- Database: PostgreSQL (via Neon)
- ORM: Drizzle ORM 0.45
- Authentication: Better Auth 1.4
- CDN: BunnyCDN (Video Stream & Storage)
- Security: Arcjet (Rate limiting & fingerprinting)
- Package Manager: npm
- Database Migrations: Drizzle Kit
- Linting: ESLint with Next.js config
- Type Checking: TypeScript strict mode
SnapCast follows a modern, scalable architecture with clear separation of concerns:
┌─────────────────────────────────────────────────────────────┐
│ Client (Browser) │
│ ┌────────────┐ ┌────────────┐ ┌─────────────────────┐ │
│ │ Pages │ │ Components │ │ Client Actions │ │
│ │ (TSX/JSX) │ │ (React) │ │ (State Management) │ │
│ └────────────┘ └────────────┘ └─────────────────────┘ │
└───────────────────────────┬─────────────────────────────────┘
│ HTTP/HTTPS
┌───────────────────────────▼─────────────────────────────────┐
│ Next.js Server (Edge + Node) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Middleware (Node.js Runtime) │ │
│ │ • Authentication Check • Session Validation │ │
│ │ • Route Protection • Request Logging │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ App Router (RSC) │ │
│ │ • Server Components • Streaming │ │
│ │ • Data Fetching • Metadata Generation │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Server Actions (lib/actions) │ │
│ │ • Video Operations • User Management │ │
│ │ • Database Queries • CDN Interactions │ │
│ └──────────────────────────────────────────────────────┘ │
└───────────┬─────────────────────────┬────────────────────────┘
│ │
┌───────────▼──────────┐ ┌──────────▼──────────┐
│ PostgreSQL (Neon) │ │ BunnyCDN Services │
│ ┌────────────────┐ │ │ ┌──────────────┐ │
│ │ Drizzle ORM │ │ │ │ Video Stream │ │
│ │ Schema & Rels │ │ │ │ Storage API │ │
│ └────────────────┘ │ │ └──────────────┘ │
│ • Users │ │ • Video Files │
│ • Videos │ │ • Thumbnails │
│ • Sessions │ │ • CDN Delivery │
│ • Accounts │ │ │
└──────────────────────┘ └─────────────────────┘
│
┌───────────▼──────────┐
│ External Services │
│ • Google OAuth │
│ • Arcjet Security │
│ • Better Auth │
└──────────────────────┘
- Server-First Approach: All data mutations happen via Server Actions for enhanced security
- CDN-First Media: All video and thumbnail assets served via BunnyCDN for global performance
- Type-Safe Database: Drizzle ORM provides full TypeScript support with zero runtime overhead
- Middleware Protection: Authentication checks at the edge before reaching application code
- Rate Limiting: Arcjet fingerprinting prevents abuse at the infrastructure level
loom/
├── app/ # Next.js App Router
│ ├── (auth)/ # Authentication routes (unprotected)
│ │ └── sign-in/
│ │ └── page.tsx # Google OAuth sign-in page
│ ├── (root)/ # Main application (protected)
│ │ ├── layout.tsx # Protected layout with navbar
│ │ ├── page.tsx # Home page with public video grid
│ │ ├── upload/
│ │ │ └── page.tsx # Video upload interface
│ │ ├── videos/
│ │ │ └── [videoId]/
│ │ │ └── page.tsx # Video player & details page
│ │ └── profile/
│ │ └── [id]/
│ │ └── page.tsx # User profile with video collection
│ ├── api/
│ │ └── auth/
│ │ └── [...all]/
│ │ └── route.ts # Better Auth catch-all endpoint
│ ├── layout.tsx # Root layout with fonts & metadata
│ └── globals.css # Tailwind config & custom styles
│
├── components/ # Reusable React components
│ ├── DropdownList.tsx # Filter dropdown (Most Viewed, etc.)
│ ├── EmptyState.tsx # Empty state UI
│ ├── FileInput.tsx # File upload input with drag & drop
│ ├── FormField.tsx # Form input wrapper
│ ├── header.tsx # Page header with title/subtitle
│ ├── navbar.tsx # Navigation bar with user menu
│ ├── RecordString.tsx # Video recording component
│ ├── VideoCard.tsx # Video thumbnail card with metadata
│ ├── VideoDetailHeader.tsx # Video page header with actions
│ ├── VideoPlayer.tsx # BunnyCDN iframe player
│ └── VideoViewTracker.tsx # Client-side view tracking
│
├── constants/
│ └── index.ts # App-wide constants & dummy data
│
├── drizzle/ # Database layer
│ ├── db.ts # Database client instance
│ ├── schema.ts # Table schemas & relations
│ └── migrations/ # SQL migration files (auto-generated)
│
├── fonts/
│ └── font.ts # Custom font configuration
│
├── lib/ # Core business logic
│ ├── actions/
│ │ └── video.ts # Server actions for video operations
│ ├── hooks/
│ │ ├── useFileInput.ts # File input state management
│ │ └── useScreenRecording.ts # Screen recording logic
│ ├── arcjet.ts # Arcjet security client
│ ├── auth.ts # Better Auth server config
│ ├── auth-client.ts # Better Auth client config
│ └── utils.ts # Helper functions & utilities
│
├── public/
│ └── assets/
│ ├── icons/ # SVG icons
│ ├── images/ # User images & static assets
│ └── samples/ # Sample thumbnails
│
├── drizzle.config.ts # Drizzle Kit configuration
├── middleware.ts # Route protection middleware
├── next.config.ts # Next.js configuration
├── index.d.ts # Global TypeScript declarations
├── package.json # Dependencies & scripts
└── tsconfig.json # TypeScript compiler options
app/(root)/page.tsx- Fetches videos server-sideapp/(root)/videos/[videoId]/page.tsx- Server-side video data fetchingapp/(root)/profile/[id]/page.tsx- User profile data fetching
VideoViewTracker- Tracks video views after 3 secondsVideoDetailHeader- Handles delete/visibility toggleVideoCard- Interactive video cards with hover statesnavbar- User menu dropdown- All form components for interactivity
Before you begin, ensure you have:
- Node.js 18+ and npm installed
- PostgreSQL database (we recommend Neon for serverless PostgreSQL)
- Google Cloud Console project for OAuth credentials
- BunnyCDN account with:
- Video Stream library created
- Storage zone for thumbnails
- API keys for both services
- Arcjet account for rate limiting (optional but recommended)
- Git for version control
- Code editor (VS Code recommended)
- Terminal/Command line access
git clone https://github.com/DevSsChar/SnapCast.git
cd loomnpm installWhat gets installed:
Core Dependencies:
next@16.0.8- React framework with App Routerreact@19.2.1&react-dom@19.2.1- React librarytypescript@5.9.3- TypeScript compiler
Database & ORM:
drizzle-orm@0.45.0- TypeScript-first ORMdrizzle-kit@0.31.8- Database migration tool@neondatabase/serverless@1.0.2- Neon PostgreSQL driverpg@8.16.3- PostgreSQL client
Authentication & Security:
better-auth@1.4.6- Modern authentication library@arcjet/next@1.0.0-beta.15- Rate limiting & security
Styling:
tailwindcss@4.1.17- Utility-first CSS framework@tailwindcss/postcss@4- PostCSS pluginclsx@2.1.1&tailwind-merge@3.4.0- Class name utilities
Utilities:
dotenv@17.2.3- Environment variable loading
Create a .env file in the root directory with the following variables:
# ========================================# Database Configuration# ========================================DATABASE_URL=postgresql://username:password@hostname:5432/database?sslmode=require# ========================================# Authentication (Better Auth)# ========================================BETTER_AUTH_SECRET=your_32_character_random_secretNEXT_PUBLIC_BASE_URL=http://localhost:3000# ========================================# OAuth Providers# ========================================GOOGLE_CLIENT_ID=your_google_client_id.apps.googleusercontent.comGOOGLE_CLIENT_SECRET=your_google_client_secret# ========================================# BunnyCDN Configuration# ========================================BUNNY_LIBRARY_ID=your_bunny_library_idBUNNY_STREAM_ACCESS_KEY=your_stream_api_keyBUNNY_STORAGE_ACCESS_KEY=your_storage_api_key# ========================================# Security (Optional but Recommended)# ========================================ARCJET_KEY=ajkey_your_arcjet_keyYour PostgreSQL connection string.
Format:postgresql://USER:PASSWORD@HOST:PORT/DATABASE?sslmode=require
How to get it:
- Sign up at Neon
- Create a new project
- Copy the connection string from dashboard
- Ensure it includes
?sslmode=requirefor secure connections
Example:
postgresql://user:pass@ep-cool-name-123456.us-east-2.aws.neon.tech:5432/neondb?sslmode=require
A random secret key used to encrypt session cookies and tokens.
How to generate:
# On macOS/Linux
openssl rand -base64 32
# On Windows (PowerShell)
[Convert]::ToBase64String((1..32| ForEach-Object { Get-Random -Maximum 256 }))Security Notes:
- Must be at least 32 characters
- Keep this secret and never commit to Git
- Different secret for production vs development
- Changing this invalidates all active sessions
The base URL of your application (with protocol, no trailing slash).
- Development:
http://localhost:3000 - Production:
https://your-domain.com
Important: This is used for OAuth callbacks and must match exactly.
How to obtain:
Go to Google Cloud Console
- Visit console.cloud.google.com
- Create a new project or select existing one
Enable Google+ API
- Navigate to "APIs & Services" → "Library"
- Search for "Google+ API"
- Click "Enable"
Create OAuth 2.0 Credentials
- Go to "APIs & Services" → "Credentials"
- Click "Create Credentials" → "OAuth 2.0 Client ID"
- Choose "Web application"
- Add Authorized JavaScript origins:
http://localhost:3000(development)https://your-domain.com(production)
- Add Authorized redirect URIs:
http://localhost:3000/api/auth/callback/google(development)https://your-domain.com/api/auth/callback/google(production)
Copy Credentials
- Copy the Client ID and Client Secret
- Add them to your
.envfile
Your BunnyCDN Video Stream library ID.
How to get:
- Login to BunnyCDN Dashboard
- Go to "Stream" → "Libraries"
- Create a new library or select existing
- Copy the Library ID (numeric value)
API key for BunnyCDN Video Stream operations.
How to get:
- In your Stream library
- Go to "API" tab
- Copy the API Key
- This key allows video upload, management, and deletion
API key for BunnyCDN Storage (for thumbnails).
How to get:
- Go to "Storage" in BunnyCDN dashboard
- Create a storage zone or select existing
- Go to "FTP & API Access"
- Copy the Storage API Password
- This key allows thumbnail uploads to storage
Storage Setup:
- Create a folder named
thumbnailsin your storage zone - This is where all video thumbnails will be uploaded
- Configure CDN hostname for thumbnail delivery
API key for Arcjet rate limiting and security features.
How to get:
- Sign up at Arcjet
- Create a new project
- Copy the API key
- This enables rate limiting (2 uploads per minute per user)
If not using Arcjet:
- Comment out rate limiting code in
lib/actions/video.ts - Remove
validateWithArcjet()calls from server actions
The drizzle.config.ts file is already configured:
import'dotenv/config';import{config}from'dotenv';import{defineConfig}from'drizzle-kit';config({path: './.env'});// Load environment variables from .env fileexportdefaultdefineConfig({out: './drizzle/migrations',// Where migration files will be storedschema: './drizzle/schema.ts',// Your database schema definitiondialect: 'postgresql',// Database typedbCredentials: {url: process.env.DATABASE_URL!,// Connection string from .env},});What this does:
- Tells Drizzle Kit where your schema is located
- Configures the output directory for migration files
- Sets up the database connection using your environment variable
The schema is defined in drizzle/schema.ts with the following tables:
User Table - Stores user information
exportconstuser=pgTable("user",{id: text("id").primaryKey(),// Unique user IDname: text("name").notNull(),// User's display nameemail: text("email").notNull().unique(),// Email (must be unique)emailVerified: boolean("email_verified").default(false),image: text("image"),// Profile picture URLcreatedAt: timestamp("created_at").defaultNow(),updatedAt: timestamp("updated_at").defaultNow(),});Session Table - Manages user sessions
exportconstsession=pgTable("session",{id: text("id").primaryKey(),expiresAt: timestamp("expires_at").notNull(),// When session expirestoken: text("token").notNull().unique(),// Session tokenuserId: text("user_id").references(()=>user.id,{onDelete: "cascade"}),// ... other fields});Account Table - Stores OAuth provider information
exportconstaccount=pgTable("account",{id: text("id").primaryKey(),accountId: text("account_id").notNull(),// Provider's user IDproviderId: text("provider_id").notNull(),// e.g., "google"userId: text("user_id").references(()=>user.id),accessToken: text("access_token"),// OAuth access tokenrefreshToken: text("refresh_token"),// OAuth refresh token// ... other fields});Verification Table - Email verification codes
exportconstverification=pgTable("verification",{id: text("id").primaryKey(),identifier: text("identifier").notNull(),// Email addressvalue: text("value").notNull(),// Verification codeexpiresAt: timestamp("expires_at").notNull(),});Run the following command to create tables in your database:
npx drizzle-kit pushWhat this does:
- Reads your
drizzle/schema.tsfile - Connects to your database using
DATABASE_URL - Creates all tables (user, session, account, verification)
- Sets up indexes, foreign keys, and constraints
- No migration files needed for initial setup
Alternative: Generate and Run Migrations
For production or team environments, use migrations:
# Generate migration files
npx drizzle-kit generate
# Apply migrations
npx drizzle-kit migrateThe lib/auth.ts file sets up Better Auth:
import{betterAuth}from"better-auth";import{drizzleAdapter}from"better-auth/adapters/drizzle";import{db}from"@/drizzle/db";import{schema}from"@/drizzle/schema";import{nextCookies}from"better-auth/next-js";exportconstauth=betterAuth({// Connect Better Auth to your database using Drizzledatabase: drizzleAdapter(db,{provider: 'pg',// PostgreSQL provider
schema // Your database schema}),// Configure OAuth providerssocialProviders: {google: {clientId: process.env.GOOGLE_CLIENT_ID!,clientSecret: process.env.GOOGLE_CLIENT_SECRET!,}},// Enable Next.js cookie handlingplugins: [nextCookies()],// Base URL for callbacksbaseUrl: process.env.NEXT_PUBLIC_BASE_URL!,});Line-by-line explanation:
betterAuth(): Initializes the authentication systemdrizzleAdapter(): Connects Better Auth to your Drizzle databasesocialProviders: Configures Google OAuth with your credentialsnextCookies(): Enables secure cookie-based sessions in Next.jsbaseUrl: Required for OAuth callback URLs
The drizzle/db.ts file creates your database client:
import{drizzle}from'drizzle-orm/neon-http';// Create database instance using Neon's HTTP driverconstdb=drizzle(process.env.DATABASE_URL!);export{db};What this does:
- Uses Neon's serverless HTTP driver (no connection pooling needed)
- Connects to your PostgreSQL database
- Exports
dbfor use throughout your application
The middleware.ts file protects routes:
import{NextRequest,NextResponse}from"next/server";import{auth}from"./lib/auth";import{headers}from"next/headers";// Force Node.js runtime (Better Auth requires it)exportconstruntime='nodejs';exportasyncfunctionmiddleware(request: NextRequest){// Get current session from Better Authconstsession=awaitauth.api.getSession({headers: awaitheaders()});// Redirect to sign-in if no session existsif(!session){returnNextResponse.redirect(newURL('/sign-in',request.url));}// Allow request to continue if authenticatedreturnNextResponse.next();}// Apply middleware to all routes except theseexportconstconfig={matcher: ["/((?!api|_next/static|_next/image|favicon.ico|sign-in|assets).*)"]};Line-by-line explanation:
runtime = 'nodejs': Required because Better Auth uses Node.js APIsauth.api.getSession(): Checks if user has valid sessionNextResponse.redirect(): Sends user to sign-in page if not authenticatedmatcher: Array of routes to protect (excludes API routes, static files, sign-in page, and assets)
npx drizzle-kit pushThis command:
- Reads your schema from
drizzle/schema.ts - Connects to your database using
DATABASE_URL - Creates all required tables and relations
- Sets up indexes and constraints
Expected output:
✔ Pulling schema from database...
✔ Changes detected
✔ Applying changes...
✓ Tables created successfully
npm run devThe application will be available at http://localhost:3000
Development server features:
- Hot module replacement (HMR)
- Fast refresh for React components
- Automatic TypeScript compilation
- Error overlay in browser
SnapCast uses Drizzle ORM with PostgreSQL for type-safe database operations. The schema includes four main tables with proper relations and constraints.
┌─────────────────┐
│ user │
│─────────────────│
│ id (PK) │
│ name │◄────────┐
│ email (unique) │ │
│ emailVerified │ │
│ image │ │
│ createdAt │ │
│ updatedAt │ │
└────────┬────────┘ │
│ │
│ 1:N │ N:1
│ │
┌────────▼────────┐ ┌───┴──────────┐
│ session │ │ videos │
│─────────────────│ │──────────────│
│ id (PK) │ │ id (PK) │
│ expiresAt │ │ title │
│ token (unique) │ │ description │
│ userId (FK) │ │ videoUrl │
│ ipAddress │ │ videoId │
│ userAgent │ │ thumbnailUrl│
│ createdAt │ │ visibility │
│ updatedAt │ │ userId (FK) │
└─────────────────┘ │ views │
│ duration │
┌─────────────────┐ │ createdAt │
│ account │ │ updatedAt │
│─────────────────│ └──────────────┘
│ id (PK) │
│ accountId │
│ providerId │ ┌──────────────────┐
│ userId (FK) │◄────┐ │ verification │
│ accessToken │ │ │──────────────────│
│ refreshToken │ │ │ id (PK) │
│ idToken │ │ │ identifier │
│ scope │ │ │ value │
│ expiresAt │ │ │ expiresAt │
│ createdAt │ │ │ createdAt │
│ updatedAt │ └───│ userId (FK) │
└─────────────────┘ └──────────────────┘
Stores user account information.
{id: text("id").primaryKey(),// Unique user identifiername: text("name").notNull(),// Display nameemail: text("email").notNull().unique(),// Email (unique constraint)emailVerified: boolean("email_verified")// Email verification status.default(false).notNull(),image: text("image"),// Profile picture URLcreatedAt: timestamp("created_at")// Account creation time.defaultNow().notNull(),updatedAt: timestamp("updated_at")// Last update time.defaultNow().notNull()}Indexes:
- Primary key on
id - Unique index on
email
Manages user authentication sessions.
{id: text("id").primaryKey(),// Session identifierexpiresAt: timestamp("expires_at").notNull(),// Expiration timestamptoken: text("token").notNull().unique(),// Session token (unique)userId: text("user_id").notNull()// Foreign key to user.references(()=>user.id,{onDelete: "cascade"}),ipAddress: text("ip_address"),// Client IP addressuserAgent: text("user_agent"),// Browser user agentcreatedAt: timestamp("created_at")// Session creation.defaultNow().notNull(),updatedAt: timestamp("updated_at")// Last activity.defaultNow().notNull()}Indexes:
- Primary key on
id - Unique index on
token - Index on
userIdfor fast lookups
Relations:
- Cascading delete: Deleting a user removes all their sessions
Stores OAuth provider information.
{id: text("id").primaryKey(),// Account record IDaccountId: text("account_id").notNull(),// Provider's user IDproviderId: text("provider_id").notNull(),// Provider name (google)userId: text("user_id").notNull()// Foreign key to user.references(()=>user.id,{onDelete: "cascade"}),accessToken: text("access_token"),// OAuth access tokenrefreshToken: text("refresh_token"),// OAuth refresh tokenidToken: text("id_token"),// OAuth ID tokenaccessTokenExpiresAt: timestamp("..."),// Access token expiryrefreshTokenExpiresAt: timestamp("..."),// Refresh token expiryscope: text("scope"),// OAuth scopes grantedcreatedAt: timestamp("created_at")// Link creation.defaultNow().notNull(),updatedAt: timestamp("updated_at")// Last token refresh.defaultNow().notNull()}Indexes:
- Primary key on
id - Index on
userIdfor user lookup
Relations:
- Cascading delete: Removing user removes OAuth accounts
Stores video metadata and settings.
{id: text("id").primaryKey(),// Database video IDtitle: text("title").notNull(),// Video titledescription: text("description").notNull(),// Video descriptionvideoUrl: text("video_url").notNull(),// BunnyCDN embed URLvideoId: text("video_id").notNull(),// BunnyCDN video GUIDthumbnailUrl: text("thumbnail_url").notNull(),// Thumbnail CDN URLvisibility: text("visibility")// public | private.$type<"public"|"private">().notNull(),userId: text("user_id").notNull()// Video owner.references(()=>user.id,{onDelete: "cascade"}),views: integer("views").notNull().default(0),// View countduration: integer("duration"),// Duration in secondscreatedAt: timestamp("created_at")// Upload time.notNull().defaultNow(),updatedAt: timestamp("updated_at")// Last modification.notNull().defaultNow()}Indexes:
- Primary key on
id - Index on
userIdfor user videos lookup - Index on
visibilityfor filtering public videos - Index on
createdAtfor sorting
Relations:
- Cascading delete: Deleting user removes all their videos
Email verification codes.
{id: text("id").primaryKey(),// Verification record IDidentifier: text("identifier").notNull(),// Email being verifiedvalue: text("value").notNull(),// Verification codeexpiresAt: timestamp("expires_at").notNull(),// Code expirationcreatedAt: timestamp("created_at")// Code creation.defaultNow().notNull()}Get all public videos with user info:
constvideos=awaitdb.select({video: videos,user: {id: user.id,name: user.name,image: user.image}}).from(videos).leftJoin(user,eq(videos.userId,user.id)).where(eq(videos.visibility,'public')).orderBy(desc(videos.createdAt)).limit(8);Increment video views:
awaitdb.update(videos).set({views: sql`${videos.views} + 1`}).where(eq(videos.id,videoId));Get user's videos (including private if owner):
constisOwner=userId===currentUserId;constconditions=[eq(videos.userId,userId),!isOwner&&eq(videos.visibility,'public')].filter(Boolean);constuserVideos=awaitdb.select().from(videos).where(and(...conditions));SnapCast uses Better Auth with Google OAuth for secure authentication.
┌──────────────┐
│ Browser │
│ ┌──────────┐ │ 1. Click "Sign in with Google"
│ │ Sign-in │ ├────────────────────────────────────────┐
│ │ Button │ │ │
│ └──────────┘ │ ▼
└──────────────┘ ┌──────────────────────────────────┐
│ Next.js Middleware │
│ /api/auth/[...all]/route.ts │
▲ └──────────┬───────────────────────┘
│ │ 2. Redirect to Google
│ ▼
│ ┌──────────────────────────────────┐
│ │ Google OAuth Consent Screen │
│ │ - Request email & profile │
│ │ - User approves │
│ └──────────┬───────────────────────┘
│ │ 3. Callback with code
│ ▼
│ ┌──────────────────────────────────┐
│ │ Better Auth Processing │
│ │ - Exchange code for tokens │
│ │ - Fetch user profile │
│ └──────────┬───────────────────────┘
│ │
│ ▼
│ ┌──────────────────────────────────┐
│ │ Database Operations (Drizzle) │
│ │ 1. Upsert user record │
│ │ 2. Create/update account │
│ │ 3. Create session │
│ └──────────┬───────────────────────┘
│ │
│ ▼
│ ┌──────────────────────────────────┐
│ │ Set Session Cookie │
│ 4. Redirect home │ - httpOnly: true │
│ with session │ - secure: true (prod) │
│ │ - sameSite: lax │
└──────────────────────┤ - expires: 30 days │
└──────────────────────────────────┘
import{betterAuth}from"better-auth";import{drizzleAdapter}from"better-auth/adapters/drizzle";import{db}from"@/drizzle/db";import{schema}from"@/drizzle/schema";import{nextCookies}from"better-auth/next-js";exportconstauth=betterAuth({// Database adapter connects Better Auth to Drizzledatabase: drizzleAdapter(db,{provider: 'pg',// PostgreSQL
schema // Our database schema}),// OAuth providers configurationsocialProviders: {google: {clientId: process.env.GOOGLE_CLIENT_ID!,clientSecret: process.env.GOOGLE_CLIENT_SECRET!,}},// Enable Next.js cookie integrationplugins: [nextCookies()],// Base URL for OAuth callbacksbaseUrl: process.env.NEXT_PUBLIC_BASE_URL!,});What this does:
- Integrates with Drizzle ORM for database operations
- Configures Google as OAuth provider
- Enables secure cookie-based sessions
- Sets up OAuth callback URLs automatically
import{createAuthClient}from"better-auth/react";exportconstauthClient=createAuthClient({baseURL: process.env.NEXT_PUBLIC_BASE_URL!,});Usage in components:
const{data: session}=authClient.useSession();const{ signOut }=authClient;// Sign outawaitsignOut();import{NextRequest,NextResponse}from"next/server";import{auth}from"./lib/auth";import{headers}from"next/headers";// Force Node.js runtime (Better Auth requires Node.js APIs)exportconstruntime='nodejs';exportasyncfunctionmiddleware(request: NextRequest){// Check for valid sessionconstsession=awaitauth.api.getSession({headers: awaitheaders()});// Redirect unauthenticated users to sign-inif(!session){returnNextResponse.redirect(newURL('/sign-in',request.url));}// Allow authenticated requestsreturnNextResponse.next();}// Protect all routes except:exportconstconfig={matcher: ["/((?!api|_next/static|_next/image|favicon.ico|sign-in|assets).*)"]};What this protects:
- ✅
/- Home page - ✅
/upload- Upload page - ✅
/videos/:id- Video pages - ✅
/profile/:id- Profile pages - ❌
/sign-in- Public (excluded) - ❌
/api/*- API routes (handled separately) - ❌
/assets/*- Static assets (excluded)
import{authClient}from'@/lib/auth-client';constSignInPage=()=>{consthandleGoogleSignIn=async()=>{awaitauthClient.signIn.social({provider: 'google',callbackURL: '/',// Redirect after sign-in});};return(<buttononClick={handleGoogleSignIn}>SigninwithGoogle</button>);};Session Lifecycle:
- Creation: When user signs in via Google OAuth
- Storage: Stored in
sessiontable with expiration - Cookie: Session token stored in httpOnly cookie
- Validation: Checked on every protected route request
- Renewal: Automatically renewed on activity
- Expiration: 30 days of inactivity (configurable)
Getting Session in Server Components:
import{auth}from'@/lib/auth';import{headers}from'next/headers';constsession=awaitauth.api.getSession({headers: awaitheaders()});constuserId=session?.user?.id;Getting Session in Client Components:
'use client'import{authClient}from'@/lib/auth-client';const{data: session}=authClient.useSession();constuserId=session?.user?.id;- Session Tokens: Cryptographically secure random tokens
- HttpOnly Cookies: Prevents XSS attacks
- Secure Flag: HTTPS-only in production
- SameSite: CSRF protection
- IP Tracking: Session bound to IP address
- User Agent: Detects session hijacking
- Automatic Expiry: Stale sessions cleaned up
loom/
├── app/ # Next.js App Router
│ ├── (auth)/ # Authentication pages (sign-in)
│ ├── (root)/ # Protected pages (videos, profile)
│ ├── api/ # API routes
│ ├── globals.css # Global styles & Tailwind config
│ └── layout.tsx # Root layout
├── components/ # Reusable React components
├── constants/ # App constants & dummy data
├── drizzle/ # Database layer
│ ├── db.ts # Database client
│ ├── schema.ts # Table definitions
│ └── migrations/ # SQL migration files (generated)
├── fonts/ # Custom font configurations
├── lib/ # Utility libraries
│ ├── auth.ts # Better Auth server config
│ └── auth-client.ts # Better Auth client config
├── public/ # Static assets
│ └── assets/ # Icons, images, samples
├── drizzle.config.ts # Drizzle Kit configuration
├── middleware.ts # Route protection
├── next.config.ts # Next.js configuration
├── tailwind.config.ts # Tailwind CSS configuration
└── tsconfig.json # TypeScript configuration
drizzle/schema.ts: Defines your database tables using Drizzle ORMdrizzle/db.ts: Creates database connection instancedrizzle.config.ts: Configures Drizzle Kit CLI tool
lib/auth.ts: Server-side Better Auth configurationlib/auth-client.ts: Client-side authentication methodsmiddleware.ts: Route protection logicapp/api/auth/[...all]/route.ts: Catch-all API route for auth endpoints
components/navbar.tsx: Navigation bar with user menucomponents/header.tsx: Page headers with titlescomponents/VideoCard.tsx: Video thumbnail cardsapp/globals.css: Custom Tailwind utilities and design system
npm run dev # Start development server
npm run build # Build for production
npm run start # Start production server
npm run lint # Run ESLint# Drizzle commands
npx drizzle-kit push # Push schema to database (no migrations)
npx drizzle-kit generate # Generate migration files
npx drizzle-kit migrate # Run pending migrations
npx drizzle-kit studio # Open Drizzle Studio (database GUI)npx drizzle-kit studioOpens a browser-based GUI at https://local.drizzle.studio to:
- View and edit table data
- Run SQL queries
- Inspect table schemas
- Test database operations
- Edit
drizzle/schema.tsto add/modify tables - Generate migration:
npx drizzle-kit generate - Review migration files in
drizzle/migrations/ - Apply migration:
npx drizzle-kit migrate
SnapCast implements a multi-step video upload process with BunnyCDN integration.
┌──────────────────────────────────────────────────────────────┐
│ Client (Browser) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. User Selects Video File │ │
│ │ - File validation (size, type) │ │
│ │ - Thumbnail selection/upload │ │
│ │ - Form fields (title, description, visibility) │ │
│ └────────────────┬────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 2. Request Upload URLs (Server Action) │ │
│ │ getVideoUploadUrl() → video URL + videoId │ │
│ │ getThumbnailUploadUrl() → thumbnail URL + CDN │ │
│ └────────────────┬────────────────────────────────────┘ │
└───────────────────┼───────────────────────────────────────────┘
│
┌───────────────────▼───────────────────────────────────────────┐
│ Next.js Server (Server Actions) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 3. Create Video Resource on BunnyCDN │ │
│ │ POST /library/{libraryId}/videos │ │
│ │ Response: { guid, uploadUrl } │ │
│ └────────────────┬────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 4. Return Upload Endpoints to Client │ │
│ │ - Video upload URL │ │
│ │ - Thumbnail upload URL │ │
│ │ - BunnyCDN access keys │ │
│ └────────────────┬────────────────────────────────────┘ │
└───────────────────┼───────────────────────────────────────────┘
│
┌───────────────────▼───────────────────────────────────────────┐
│ Client (Browser) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 5. Direct Upload to BunnyCDN │ │
│ │ PUT video to upload URL with progress tracking │ │
│ │ PUT thumbnail to storage URL │ │
│ └────────────────┬────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 6. Save Metadata (Server Action) │ │
│ │ saveVideoDetails() with all metadata │ │
│ └────────────────┬────────────────────────────────────┘ │
└───────────────────┼───────────────────────────────────────────┘
│
┌───────────────────▼───────────────────────────────────────────┐
│ Next.js Server (Server Actions) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 7. Update BunnyCDN Video Metadata │ │
│ │ POST /library/{libraryId}/videos/{videoId} │ │
│ │ - Set title & description │ │
│ └────────────────┬────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 8. Rate Limiting Check (Arcjet) │ │
│ │ - Fingerprint user │ │
│ │ - Check: Max 2 uploads per minute │ │
│ └────────────────┬────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 9. Save to Database (Drizzle ORM) │ │
│ │ INSERT INTO videos VALUES (...) │ │
│ │ - videoId, title, thumbnailUrl, visibility │ │
│ │ - userId, videoUrl, createdAt │ │
│ └────────────────┬────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 10. Revalidate Paths │ │
│ │ - Invalidate / (home page cache) │ │
│ │ - Trigger re-fetch on next visit │ │
│ └────────────────┬────────────────────────────────────┘ │
└───────────────────┼───────────────────────────────────────────┘
│
┌───────────────────▼───────────────────────────────────────────┐
│ Client (Browser) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 11. Success! Redirect to Home │ │
│ │ - Video appears in public library │ │
│ │ - BunnyCDN processes video for streaming │ │
│ └─────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
exportconstgetVideoUploadUrl=withErrorHandling(async()=>{awaitgetSessionUserId();// Verify authentication// Create video resource on BunnyCDNconstvideoResponse=awaitapiFetch<BunnyVideoResponse>(`${VIDEO_STREAM_BASE_URL}/${BUNNY_LIBRARY_ID}/videos`,{method: "POST",bunnyType: "stream",body: {title: 'Temporary Title',collectionId: ''}});constuploadUrl=`${VIDEO_STREAM_BASE_URL}/${BUNNY_LIBRARY_ID}/videos/${videoResponse.guid}`;return{videoId: videoResponse.guid,// BunnyCDN video ID
uploadUrl,// Direct upload endpointaccessKey: ACCESS_KEYS.streamAccessKey,};});exportconstgetThumbnailUploadUrl=withErrorHandling(async(videoId: string)=>{awaitgetSessionUserId();constfilename=`${Date.now()}-${videoId}-thumbnail`;constuploadUrl=`${THUMBNAIL_STORAGE_BASE_URL}/thumbnails/${filename}`;constcdnUrl=`${THUMBNAIL_CDN_URL}/thumbnails/${filename}`;return{
uploadUrl,// Where to PUT the thumbnail
cdnUrl,// Public URL for servingaccessKey: ACCESS_KEYS.storageAccessKey,};});// Get upload URLsconst{ videoId, uploadUrl, accessKey }=awaitgetVideoUploadUrl();constthumbnailData=awaitgetThumbnailUploadUrl(videoId);// Upload video directly to BunnyCDNconstvideoUpload=awaitfetch(uploadUrl,{method: 'PUT',body: videoFile,headers: {'AccessKey': accessKey,'Content-Type': 'application/octet-stream'}});// Upload thumbnailconstthumbnailUpload=awaitfetch(thumbnailData.uploadUrl,{method: 'PUT',body: thumbnailFile,headers: {'AccessKey': thumbnailData.accessKey,'Content-Type': 'image/jpeg'}});// Save metadataawaitsaveVideoDetails({
videoId,
title,
description,thumbnailUrl: thumbnailData.cdnUrl,
visibility,// ... other fields});exportconstsaveVideoDetails=withErrorHandling(async(videoDetails: VideoDetails)=>{constuserId=awaitgetSessionUserId();if(!userId)thrownewError("User ID not found");// Rate limiting (2 uploads per minute)awaitvalidateWithArcjet(userId);// Update BunnyCDN video metadataawaitapiFetch(`${VIDEO_STREAM_BASE_URL}/${BUNNY_LIBRARY_ID}/videos/${videoDetails.videoId}`,{method: "POST",bunnyType: "stream",body: {title: videoDetails.title,description: videoDetails.description,}});// Save to databaseawaitdb.insert(videos).values({
...videoDetails,visibility: videoDetails.visibilityas"public"|"private",videoUrl: `${BUNNY.EMBED_URL}/${BUNNY_LIBRARY_ID}/${videoDetails.videoId}`,
userId,createdAt: newDate(),updatedAt: newDate(),});// Revalidate home page to show new videorevalidatePaths([`/`]);return{videoId: videoDetails.videoId};});// Maximum file sizesconstMAX_VIDEO_SIZE=500*1024*1024;// 500 MBconstMAX_THUMBNAIL_SIZE=10*1024*1024;// 10 MB// Allowed video formatsconstALLOWED_VIDEO_TYPES=['video/mp4','video/webm','video/quicktime',// .mov];// Allowed thumbnail formatsconstALLOWED_IMAGE_TYPES=['image/jpeg','image/jpg','image/png','image/webp',];SnapCast implements multiple security layers to protect against abuse and unauthorized access.
importarcjetfrom'@arcjet/next';import{fixedWindow}from'@arcjet/next';constaj=arcjet({key: getEnv('ARCJET_KEY'),rules: [],});constvalidateWithArcjet=async(fingerprint: string)=>{// Configure rate limitingconstratelimit=aj.withRule(fixedWindow({mode: 'LIVE',window: '1m',// 1 minute windowmax: 2,// Maximum 2 requestscharacteristics: ['fingerprint'],}));constreq=awaitrequest();constdecision=awaitratelimit.protect(req,{ fingerprint });if(decision.isDenied()){thrownewError('Rate limit exceeded. Please try again later.');}};Applied to:
- Video uploads (2 per minute per user)
- Prevents spam and abuse
- User fingerprinting for accurate tracking
All delete and update operations verify ownership:
exportconstdeleteVideo=withErrorHandling(async(videoId: string,bunnyVideoId: string)=>{constuserId=awaitgetSessionUserId();if(!userId)thrownewError("Unauthenticated");// Verify ownershipconst[video]=awaitdb.select().from(videos).where(eq(videos.id,videoId));if(!video)thrownewError("Video not found");if(video.userId!==userId)thrownewError("Unauthorized");// Delete from BunnyCDNawaitapiFetch(`${VIDEO_STREAM_BASE_URL}/${BUNNY_LIBRARY_ID}/videos/${bunnyVideoId}`,{method: "DELETE",bunnyType: "stream"});// Delete from databaseawaitdb.delete(videos).where(eq(videos.id,videoId));revalidatePaths([`/`,`/profile/${userId}`]);return{success: true};});// Runtime enforcementexportconstruntime='nodejs';// Force Node.js for Better Authexportasyncfunctionmiddleware(request: NextRequest){constsession=awaitauth.api.getSession({headers: awaitheaders()});if(!session){returnNextResponse.redirect(newURL('/sign-in',request.url));}returnNextResponse.next();}Protects:
- All application routes by default
- Executes before page rendering
- Immediate redirect if unauthenticated
// Authentication check wrapperconstgetSessionUserId=async(): Promise<string|null>=>{constsession=awaitauth.api.getSession({headers: awaitheaders()});if(!session)thrownewError("Unauthenticated");returnsession?.user.id||null;};// Error handling wrapperexportconstwithErrorHandling=<Textends(...args: any[])=>any>(fn: T): T=>{return(async(...args: Parameters<T>)=>{try{returnawaitfn(...args);}catch(error){console.error('Server action error:',error);throwerror;}})asT;};Every server action:
- Wrapped in
withErrorHandling - Checks authentication with
getSessionUserId - Validates input data
- Verifies permissions
- Logs errors for debugging
// Cascading deletes
userId: text("user_id").notNull().references(()=>user.id,{onDelete: "cascade"})// Unique constraints
email: text("email").notNull().unique()
token: text("token").notNull().unique()// Type-safe queries (prevents SQL injection)awaitdb.select().from(videos).where(eq(videos.id,videoId));// Parameterized// API keys stored in environment variables (never exposed)constACCESS_KEYS={streamAccessKey: getEnv("BUNNY_STREAM_ACCESS_KEY"),storageAccessKey: getEnv("BUNNY_STORAGE_ACCESS_KEY"),};// Keys sent via secure server actions onlyconst{ accessKey }=awaitgetVideoUploadUrl();// Public videos: Visible to everyone// Private videos: Visible only to ownerconstcanSeeTheVideos=currentUserId
? or(eq(videos.visibility,'public'),eq(videos.userId,currentUserId)// Owner can see private)
: eq(videos.visibility,'public');// Non-authenticated: public onlyAll server actions are called from client components using Next.js Server Actions.
getVideoUploadUrl()
// Returns upload URL for videoconstresult=awaitgetVideoUploadUrl();// Returns: { videoId, uploadUrl, accessKey }getThumbnailUploadUrl(videoId: string)
// Returns upload URL for thumbnailconstresult=awaitgetThumbnailUploadUrl(videoId);// Returns: { uploadUrl, cdnUrl, accessKey }saveVideoDetails(videoDetails: VideoDetails)
// Saves video metadata to databaseconstresult=awaitsaveVideoDetails({videoId: 'bunny-video-guid',title: 'My Video',description: 'Video description',thumbnailUrl: 'https://cdn.url/thumb.jpg',visibility: 'public',duration: 120,tags: 'tag1,tag2'});// Returns: { videoId }deleteVideo(videoId: string, bunnyVideoId: string)
// Deletes video from BunnyCDN and databaseawaitdeleteVideo(dbVideoId,bunnyGuid);// Returns: { success: true }// Throws: "Unauthorized" if not ownerupdateVideoVisibility(videoId: string, visibility: 'public' | 'private')
// Updates video visibilityawaitupdateVideoVisibility(videoId,'private');// Returns: { success: true, visibility: 'private' }// Throws: "Unauthorized" if not ownerincrementVideoViews(videoId: string)
// Increments view countawaitincrementVideoViews(videoId);// Returns: { success: true, views: number }getAllVideos(searchQuery?, sortFilter?, pageNumber?, pageSize?)
// Get paginated public videosconstresult=awaitgetAllVideos('search','Most Viewed',1,8);// Returns: {// videos: VideoWithUser[],// pagination: {// currentPage: number,// totalPages: number,// totalVideos: number,// pageSize: number// }// }getVideoById(videoId: string)
// Get single video with user infoconstresult=awaitgetVideoById(videoId);// Returns: { video: Video, user: User }getAllVideosByUser(userId: string, searchQuery?, sortFilter?)
// Get user's videos (public + private if owner)constresult=awaitgetAllVideosByUser(userId,'','Most Recent');// Returns: {// user: User,// videos: VideoWithUser[],// count: number// }"Most Viewed"- ORDER BY views DESC"Most Recent"- ORDER BY createdAt DESC"Oldest First"- ORDER BY createdAt ASC"Least Viewed"- ORDER BY views ASC
- Ensure
export const runtime = 'nodejs';is inmiddleware.ts - Better Auth requires Node.js runtime, not Edge runtime
- Verify
DATABASE_URLin.envis correct - Check if database is accessible
- Ensure SSL mode is set if required by your provider
- Verify redirect URI in Google Console matches your app
- Check
NEXT_PUBLIC_BASE_URLis correct - Ensure Google+ API is enabled in Google Cloud Console
- Check class names exist in
app/globals.css - Restart dev server after changing Tailwind config
- Verify
@tailwindcss/postcssis installed
- Next.js Documentation
- Drizzle ORM Documentation
- Better Auth Documentation
- Tailwind CSS Documentation
- Neon Documentation
Vercel is the recommended platform for deploying Next.js applications.
# Test production build locally
npm run build
npm run start
# Verify everything works# Check http://localhost:3000git add .
git commit -m "Prepare for deployment"
git push origin mainSign up/Login to Vercel
- Visit vercel.com
- Connect your GitHub account
Import Project
- Click "Add New" → "Project"
- Select your GitHub repository
- Click "Import"
Configure Project
- Framework Preset: Next.js (auto-detected)
- Root Directory:
./(default) - Build Command:
npm run build(default) - Output Directory:
.next(default)
Add Environment Variables
In Vercel dashboard, add all environment variables:
DATABASE_URL=postgresql://...BETTER_AUTH_SECRET=your_production_secretNEXT_PUBLIC_BASE_URL=https://your-domain.vercel.appGOOGLE_CLIENT_ID=your_google_client_idGOOGLE_CLIENT_SECRET=your_google_client_secretBUNNY_LIBRARY_ID=your_bunny_library_idBUNNY_STREAM_ACCESS_KEY=your_stream_keyBUNNY_STORAGE_ACCESS_KEY=your_storage_keyARCJET_KEY=your_arcjet_key
Important:
- Generate a NEW
BETTER_AUTH_SECRETfor production - Update
NEXT_PUBLIC_BASE_URLto your Vercel domain - Ensure all keys are from production services
- Generate a NEW
Deploy
- Click "Deploy"
- Wait for build to complete (2-3 minutes)
- Vercel will provide a production URL
Google Cloud Console
- Go to your OAuth 2.0 Client
- Add to Authorized redirect URIs:
https://your-domain.vercel.app/api/auth/callback/google - Save changes
Test Authentication
- Visit your production URL
- Try signing in with Google
- Verify it redirects back correctly
- In Vercel dashboard → Settings → Domains
- Add your custom domain
- Update DNS records as instructed
- Update
NEXT_PUBLIC_BASE_URLto custom domain - Update Google OAuth redirect URI
For production deployment:
# Generate migration files
npx drizzle-kit generate
# Review migrations in drizzle/migrations/# Apply to production database
DATABASE_URL=your_production_url npx drizzle-kit migrate- ✅ Use production database (not development)
- ✅ New
BETTER_AUTH_SECRET(different from dev) - ✅ HTTPS-only cookies enabled
- ✅ Rate limiting configured
- ✅ Error logging set up
- ✅ CDN properly configured
- ✅ OAuth redirects updated
- ✅ Database backups enabled
Create vercel.json (optional):
{
"buildCommand": "npm run build",
"devCommand": "npm run dev",
"installCommand": "npm install",
"framework": "nextjs",
"regions": ["iad1"],
"env": {
"NEXT_PUBLIC_BASE_URL": "https://your-domain.vercel.app"
}
}Recommended Services:
- Vercel Analytics - Built-in performance monitoring
- Sentry - Error tracking
- PostHog - Product analytics
- BunnyCDN Analytics - Video streaming metrics
# Development
npm run dev # Start dev server (http://localhost:3000)
npm run build # Build for production
npm run start # Start production server
npm run lint # Run ESLint# Database (Drizzle Kit)
npx drizzle-kit push # Push schema to database (no migrations)
npx drizzle-kit generate # Generate migration files
npx drizzle-kit migrate # Apply migrations
npx drizzle-kit studio # Open Drizzle Studio GUI# Utilities
npm run type-check # TypeScript type checking
npm run format # Format code with PrettierDrizzle Studio provides a GUI for database management:
npx drizzle-kit studioFeatures:
- Browse tables and data
- Run SQL queries
- Edit records
- View relationships
- Test queries
Access: Opens at https://local.drizzle.studio
Cause: Better Auth requires Node.js runtime, but middleware defaults to Edge.
Solution:
// middleware.tsexportconstruntime='nodejs';// Add this lineSymptoms:
ECONNREFUSEDConnection timeoutSSL required
Solutions:
# Check DATABASE_URL format
postgresql://user:password@host:5432/dbname?sslmode=require
# Test connection
psql $DATABASE_URL# Verify Neon project is active# Check if database existsSymptoms:
redirect_uri_mismatchinvalid_client
Solutions:
- Verify redirect URI in Google Console:
http://localhost:3000/api/auth/callback/google - Check
NEXT_PUBLIC_BASE_URLmatches - Ensure Google+ API is enabled
- Clear browser cookies and try again
Symptoms:
- 401 Unauthorized
- 403 Forbidden
- Upload timeout
Solutions:
// Verify API keysconsole.log('Stream Key:',process.env.BUNNY_STREAM_ACCESS_KEY);console.log('Storage Key:',process.env.BUNNY_STORAGE_ACCESS_KEY);// Check library IDconsole.log('Library ID:',process.env.BUNNY_LIBRARY_ID);// Test API key manuallycurl-XGET"https://video.bunnycdn.com/library/{libraryId}/videos" \
-H"AccessKey: your_key"Cause: Tailwind CSS v4 configuration issues
Solutions:
# Restart dev server
npm run dev
# Clear Next.js cache
rm -rf .next
# Verify globals.css import# Check app/layout.tsx has: import './globals.css'# Check tailwind.config.ts existsSymptoms:
- "Rate limit exceeded"
- Can't upload videos
Solutions:
// Temporarily disable for testing// Comment out in lib/actions/video.ts// await validateWithArcjet(userId);// Or adjust limitsfixedWindow({window: '5m',// Increase windowmax: 10,// Increase limit})Symptoms:
- Browser shows "too many redirects"
- Can't access any page
Solutions:
// Check matcher doesn't block sign-inexportconstconfig={matcher: ["/((?!api|_next/static|_next/image|favicon.ico|sign-in|assets).*)"]};// Verify sign-in route exists// Check /app/(auth)/sign-in/page.tsxSymptoms:
- Black screen
- Loading forever
- 404 error
Solutions:
// Verify video URL formatconstvideoUrl=`https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}`;// Check BunnyCDN video processing// Videos may take 1-2 minutes to process// Verify video exists in BunnyCDN dashboardCommon errors:
// Property does not exist// Solution: Update index.d.ts with proper types// Module not found// Solution: npm install <package>// Type mismatch// Solution: Check interface definitionsSymptoms:
npm run buildfails- Type errors in production
Solutions:
# Clear cache
rm -rf .next
rm -rf node_modules
npm install
# Check TypeScript config
npx tsc --noEmit
# Review error messages
npm run build 2>&1| tee build.logEnable detailed logging:
// lib/utils.tsexportconstDEBUG=process.env.NODE_ENV==='development';// Use in codeif(DEBUG){console.log('Video details:',videoDetails);}Check Logs:
- Browser console (F12)
- Terminal output
- Vercel deployment logs
Review Documentation:
Common Resources:
- Stack Overflow
- GitHub Issues
- Discord communities
- Next.js 16:nextjs.org/docs
- Drizzle ORM:orm.drizzle.team
- Better Auth:better-auth.com
- Tailwind CSS:tailwindcss.com
- BunnyCDN:docs.bunny.net
- Arcjet:arcjet.com/docs
- Next.js App Router Guide
- Drizzle ORM Quick Start
- BunnyCDN Stream API Guide
- Better Auth React Integration
Contributions are welcome! Please follow these steps:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to branch (
git push origin feature/amazing-feature) - Open a Pull Request
- Follow TypeScript best practices
- Use meaningful commit messages
- Add comments for complex logic
- Test before submitting PR
- Update documentation if needed
This project is licensed under the MIT License - see the LICENSE file for details.
- DevSsChar - Initial work - GitHub
- Next.js team for the amazing framework
- Vercel for hosting and deployment
- BunnyCDN for video streaming infrastructure
- Better Auth for authentication solution
- Drizzle team for the excellent ORM
Built with ❤️ using Next.js 16, TypeScript, and modern web technologies.
For questions or support, please open an issue on GitHub.