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_secret
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# ========================================
# OAuth Providers
# ========================================
GOOGLE_CLIENT_ID=your_google_client_id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_google_client_secret
# ========================================
# BunnyCDN Configuration
# ========================================
BUNNY_LIBRARY_ID=your_bunny_library_id
BUNNY_STREAM_ACCESS_KEY=your_stream_api_key
BUNNY_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 file
export default defineConfig({
out: './drizzle/migrations', // Where migration files will be stored
schema: './drizzle/schema.ts', // Your database schema definition
dialect: 'postgresql', // Database type
dbCredentials: {
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
export const user = pgTable("user", {
id: text("id").primaryKey(), // Unique user ID
name: text("name").notNull(), // User's display name
email: text("email").notNull().unique(), // Email (must be unique)
emailVerified: boolean("email_verified").default(false),
image: text("image"), // Profile picture URL
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});Session Table - Manages user sessions
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(), // When session expires
token: text("token").notNull().unique(), // Session token
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
// ... other fields
});Account Table - Stores OAuth provider information
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(), // Provider's user ID
providerId: text("provider_id").notNull(), // e.g., "google"
userId: text("user_id").references(() => user.id),
accessToken: text("access_token"), // OAuth access token
refreshToken: text("refresh_token"), // OAuth refresh token
// ... other fields
});Verification Table - Email verification codes
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(), // Email address
value: text("value").notNull(), // Verification code
expiresAt: 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";
export const auth = betterAuth({
// Connect Better Auth to your database using Drizzle
database: drizzleAdapter(db, {
provider: 'pg', // PostgreSQL provider
schema // Your database schema
}),
// Configure OAuth providers
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}
},
// Enable Next.js cookie handling
plugins: [nextCookies()],
// Base URL for callbacks
baseUrl: 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 driver
const db = 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)
export const runtime = 'nodejs';
export async function middleware(request: NextRequest) {
// Get current session from Better Auth
const session = await auth.api.getSession({
headers: await headers()
});
// Redirect to sign-in if no session exists
if (!session) {
return NextResponse.redirect(
new URL('/sign-in', request.url)
);
}
// Allow request to continue if authenticated
return NextResponse.next();
}
// Apply middleware to all routes except these
export const config = {
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 identifier
name: text("name").notNull(), // Display name
email: text("email").notNull().unique(), // Email (unique constraint)
emailVerified: boolean("email_verified") // Email verification status
.default(false).notNull(),
image: text("image"), // Profile picture URL
createdAt: 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 identifier
expiresAt: timestamp("expires_at").notNull(), // Expiration timestamp
token: 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 address
userAgent: text("user_agent"), // Browser user agent
createdAt: 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 ID
accountId: text("account_id").notNull(), // Provider's user ID
providerId: 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 token
refreshToken: text("refresh_token"), // OAuth refresh token
idToken: text("id_token"), // OAuth ID token
accessTokenExpiresAt: timestamp("..."), // Access token expiry
refreshTokenExpiresAt: timestamp("..."), // Refresh token expiry
scope: text("scope"), // OAuth scopes granted
createdAt: 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 ID
title: text("title").notNull(), // Video title
description: text("description").notNull(), // Video description
videoUrl: text("video_url").notNull(), // BunnyCDN embed URL
videoId: text("video_id").notNull(), // BunnyCDN video GUID
thumbnailUrl: text("thumbnail_url").notNull(), // Thumbnail CDN URL
visibility: 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 count
duration: integer("duration"), // Duration in seconds
createdAt: 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 ID
identifier: text("identifier").notNull(), // Email being verified
value: text("value").notNull(), // Verification code
expiresAt: timestamp("expires_at").notNull(), // Code expiration
createdAt: timestamp("created_at") // Code creation
.defaultNow().notNull()
}Get all public videos with user info:
const videos = await db
.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:
await db
.update(videos)
.set({ views: sql`${videos.views} + 1` })
.where(eq(videos.id, videoId));Get user's videos (including private if owner):
const isOwner = userId === currentUserId;
const conditions = [
eq(videos.userId, userId),
!isOwner && eq(videos.visibility, 'public')
].filter(Boolean);
const userVideos = await db
.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";
export const auth = betterAuth({
// Database adapter connects Better Auth to Drizzle
database: drizzleAdapter(db, {
provider: 'pg', // PostgreSQL
schema // Our database schema
}),
// OAuth providers configuration
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}
},
// Enable Next.js cookie integration
plugins: [nextCookies()],
// Base URL for OAuth callbacks
baseUrl: 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";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BASE_URL!,
});Usage in components:
const { data: session } = authClient.useSession();
const { signOut } = authClient;
// Sign out
await signOut();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)
export const runtime = 'nodejs';
export async function middleware(request: NextRequest) {
// Check for valid session
const session = await auth.api.getSession({
headers: await headers()
});
// Redirect unauthenticated users to sign-in
if (!session) {
return NextResponse.redirect(
new URL('/sign-in', request.url)
);
}
// Allow authenticated requests
return NextResponse.next();
}
// Protect all routes except:
export const config = {
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';
const SignInPage = () => {
const handleGoogleSignIn = async () => {
await authClient.signIn.social({
provider: 'google',
callbackURL: '/', // Redirect after sign-in
});
};
return (
<button onClick={handleGoogleSignIn}>
Sign in with Google
</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';
const session = await auth.api.getSession({
headers: await headers()
});
const userId = session?.user?.id;Getting Session in Client Components:
'use client'
import { authClient } from '@/lib/auth-client';
const { data: session } = authClient.useSession();
const userId = 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 β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export const getVideoUploadUrl = withErrorHandling(async () => {
await getSessionUserId(); // Verify authentication
// Create video resource on BunnyCDN
const videoResponse = await apiFetch<BunnyVideoResponse>(
`${VIDEO_STREAM_BASE_URL}/${BUNNY_LIBRARY_ID}/videos`,
{
method: "POST",
bunnyType: "stream",
body: { title: 'Temporary Title', collectionId: '' }
}
);
const uploadUrl = `${VIDEO_STREAM_BASE_URL}/${BUNNY_LIBRARY_ID}/videos/${videoResponse.guid}`;
return {
videoId: videoResponse.guid, // BunnyCDN video ID
uploadUrl, // Direct upload endpoint
accessKey: ACCESS_KEYS.streamAccessKey,
};
});
export const getThumbnailUploadUrl = withErrorHandling(async (videoId: string) => {
await getSessionUserId();
const filename = `${Date.now()}-${videoId}-thumbnail`;
const uploadUrl = `${THUMBNAIL_STORAGE_BASE_URL}/thumbnails/${filename}`;
const cdnUrl = `${THUMBNAIL_CDN_URL}/thumbnails/${filename}`;
return {
uploadUrl, // Where to PUT the thumbnail
cdnUrl, // Public URL for serving
accessKey: ACCESS_KEYS.storageAccessKey,
};
});// Get upload URLs
const { videoId, uploadUrl, accessKey } = await getVideoUploadUrl();
const thumbnailData = await getThumbnailUploadUrl(videoId);
// Upload video directly to BunnyCDN
const videoUpload = await fetch(uploadUrl, {
method: 'PUT',
body: videoFile,
headers: {
'AccessKey': accessKey,
'Content-Type': 'application/octet-stream'
}
});
// Upload thumbnail
const thumbnailUpload = await fetch(thumbnailData.uploadUrl, {
method: 'PUT',
body: thumbnailFile,
headers: {
'AccessKey': thumbnailData.accessKey,
'Content-Type': 'image/jpeg'
}
});
// Save metadata
await saveVideoDetails({
videoId,
title,
description,
thumbnailUrl: thumbnailData.cdnUrl,
visibility,
// ... other fields
});export const saveVideoDetails = withErrorHandling(async (
videoDetails: VideoDetails
) => {
const userId = await getSessionUserId();
if (!userId) throw new Error("User ID not found");
// Rate limiting (2 uploads per minute)
await validateWithArcjet(userId);
// Update BunnyCDN video metadata
await apiFetch(
`${VIDEO_STREAM_BASE_URL}/${BUNNY_LIBRARY_ID}/videos/${videoDetails.videoId}`,
{
method: "POST",
bunnyType: "stream",
body: {
title: videoDetails.title,
description: videoDetails.description,
}
}
);
// Save to database
await db.insert(videos).values({
...videoDetails,
visibility: videoDetails.visibility as "public" | "private",
videoUrl: `${BUNNY.EMBED_URL}/${BUNNY_LIBRARY_ID}/${videoDetails.videoId}`,
userId,
createdAt: new Date(),
updatedAt: new Date(),
});
// Revalidate home page to show new video
revalidatePaths([`/`]);
return { videoId: videoDetails.videoId };
});// Maximum file sizes
const MAX_VIDEO_SIZE = 500 * 1024 * 1024; // 500 MB
const MAX_THUMBNAIL_SIZE = 10 * 1024 * 1024; // 10 MB
// Allowed video formats
const ALLOWED_VIDEO_TYPES = [
'video/mp4',
'video/webm',
'video/quicktime', // .mov
];
// Allowed thumbnail formats
const ALLOWED_IMAGE_TYPES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
];SnapCast implements multiple security layers to protect against abuse and unauthorized access.
import arcjet from '@arcjet/next';
import { fixedWindow } from '@arcjet/next';
const aj = arcjet({
key: getEnv('ARCJET_KEY'),
rules: [],
});
const validateWithArcjet = async (fingerprint: string) => {
// Configure rate limiting
const ratelimit = aj.withRule(
fixedWindow({
mode: 'LIVE',
window: '1m', // 1 minute window
max: 2, // Maximum 2 requests
characteristics: ['fingerprint'],
})
);
const req = await request();
const decision = await ratelimit.protect(req, { fingerprint });
if (decision.isDenied()) {
throw new Error('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:
export const deleteVideo = withErrorHandling(async (videoId: string, bunnyVideoId: string) => {
const userId = await getSessionUserId();
if (!userId) throw new Error("Unauthenticated");
// Verify ownership
const [video] = await db.select().from(videos).where(eq(videos.id, videoId));
if (!video) throw new Error("Video not found");
if (video.userId !== userId) throw new Error("Unauthorized");
// Delete from BunnyCDN
await apiFetch(
`${VIDEO_STREAM_BASE_URL}/${BUNNY_LIBRARY_ID}/videos/${bunnyVideoId}`,
{ method: "DELETE", bunnyType: "stream" }
);
// Delete from database
await db.delete(videos).where(eq(videos.id, videoId));
revalidatePaths([`/`, `/profile/${userId}`]);
return { success: true };
});// Runtime enforcement
export const runtime = 'nodejs'; // Force Node.js for Better Auth
export async function middleware(request: NextRequest) {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session) {
return NextResponse.redirect(new URL('/sign-in', request.url));
}
return NextResponse.next();
}Protects:
- All application routes by default
- Executes before page rendering
- Immediate redirect if unauthenticated
// Authentication check wrapper
const getSessionUserId = async (): Promise<string | null> => {
const session = await auth.api.getSession({
headers: await headers()
});
if (!session) throw new Error("Unauthenticated");
return session?.user.id || null;
};
// Error handling wrapper
export const withErrorHandling = <T extends (...args: any[]) => any>(
fn: T
): T => {
return (async (...args: Parameters<T>) => {
try {
return await fn(...args);
} catch (error) {
console.error('Server action error:', error);
throw error;
}
}) as T;
};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)
await db.select().from(videos)
.where(eq(videos.id, videoId)); // Parameterized// API keys stored in environment variables (never exposed)
const ACCESS_KEYS = {
streamAccessKey: getEnv("BUNNY_STREAM_ACCESS_KEY"),
storageAccessKey: getEnv("BUNNY_STORAGE_ACCESS_KEY"),
};
// Keys sent via secure server actions only
const { accessKey } = await getVideoUploadUrl();// Public videos: Visible to everyone
// Private videos: Visible only to owner
const canSeeTheVideos = 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 video
const result = await getVideoUploadUrl();
// Returns: { videoId, uploadUrl, accessKey }getThumbnailUploadUrl(videoId: string)
// Returns upload URL for thumbnail
const result = await getThumbnailUploadUrl(videoId);
// Returns: { uploadUrl, cdnUrl, accessKey }saveVideoDetails(videoDetails: VideoDetails)
// Saves video metadata to database
const result = await saveVideoDetails({
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 database
await deleteVideo(dbVideoId, bunnyGuid);
// Returns: { success: true }
// Throws: "Unauthorized" if not ownerupdateVideoVisibility(videoId: string, visibility: 'public' | 'private')
// Updates video visibility
await updateVideoVisibility(videoId, 'private');
// Returns: { success: true, visibility: 'private' }
// Throws: "Unauthorized" if not ownerincrementVideoViews(videoId: string)
// Increments view count
await incrementVideoViews(videoId);
// Returns: { success: true, views: number }getAllVideos(searchQuery?, sortFilter?, pageNumber?, pageSize?)
// Get paginated public videos
const result = await getAllVideos('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 info
const result = await getVideoById(videoId);
// Returns: { video: Video, user: User }getAllVideosByUser(userId: string, searchQuery?, sortFilter?)
// Get user's videos (public + private if owner)
const result = await getAllVideosByUser(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 main-
Sign 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_secret NEXT_PUBLIC_BASE_URL=https://your-domain.vercel.app GOOGLE_CLIENT_ID=your_google_client_id GOOGLE_CLIENT_SECRET=your_google_client_secret BUNNY_LIBRARY_ID=your_bunny_library_id BUNNY_STREAM_ACCESS_KEY=your_stream_key BUNNY_STORAGE_ACCESS_KEY=your_storage_key ARCJET_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.ts
export const runtime = '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 keys
console.log('Stream Key:', process.env.BUNNY_STREAM_ACCESS_KEY);
console.log('Storage Key:', process.env.BUNNY_STORAGE_ACCESS_KEY);
// Check library ID
console.log('Library ID:', process.env.BUNNY_LIBRARY_ID);
// Test API key manually
curl -X GET "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 limits
fixedWindow({
window: '5m', // Increase window
max: 10, // Increase limit
})Symptoms:
- Browser shows "too many redirects"
- Can't access any page
Solutions:
// Check matcher doesn't block sign-in
export const config = {
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 format
const videoUrl = `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.ts
export const DEBUG = process.env.NODE_ENV === 'development';
// Use in code
if (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.