The official website for Lenis, the smooth scroll library.
-
Install dependencies:
bun install
-
Get the .env variables from Vercel:
vc link vc env pull
-
Run development environment:
bun dev
- Next.js 16 - React framework with Turbopack
- React 19 - UI library
- TypeScript - Type safety
- Bun - Package manager and runtime
- Tailwind CSS v4 - Utility-first CSS
- CSS Modules - Scoped styles
- PostCSS - CSS processing
- Lenis - Smooth scroll
- Tempus - RAF management
- Hamo - React hooks
- GSAP - Animation library
- Three.js - 3D graphics
- @react-three/fiber - React renderer for Three.js
- @react-three/drei - Three.js helpers
- Zustand - State management
- Notion API - Showcase data
- AWS S3 - Media storage (default)
- Cloudinary - Media CDN (alternative)
- Biome lint + format (auto-fix enabled)
- TypeScript type checking
Run manually:
bun biome check --write . # Lint and format
bun typecheck # Type check├── app/ # Next.js App Router pages
│ ├── api/ # API routes (cron, webhooks)
│ ├── showcase/ # Showcase page
│ └── ...
├── components/ # Reusable UI components
├── hooks/ # Custom React hooks
├── icons/ # SVG icons (imported as React components)
├── layouts/ # Page layout components
├── lib/
│ ├── media/ # Media storage service (S3/Cloudinary/Mux)
│ ├── styles/ # Global CSS, Tailwind config
│ ├── store.js # Zustand state store
│ └── ... # Utilities
├── scripts/ # Utility scripts (migration, etc.)
└── public/ # Static assets
The /showcase page pulls submissions from a Notion database. Since Notion serves media via temporary S3 URLs (~1hr expiry), we use a permanent storage provider (S3 or Cloudinary).
Notion Form → Notion DB → [S3 or Cloudinary] → Next.js Image optimization → User
↑
Cron job (every 10 min)
or
Notion webhook (instant)
- Submissions: Users submit projects via Notion form
- Sync: A cron job runs every 10 minutes to check for new/updated entries
- Media Processing: New images/videos are uploaded to the configured provider with deterministic filenames
- Deduplication: Provider checks if asset exists before uploading (via filename hash)
- Video Posters: For videos, a poster image is generated from the first frame (S3: WASM ffmpeg, Cloudinary: auto-generated)
- Delivery: Next.js Image component optimizes on delivery (format conversion, resizing)
| Provider | Pros | Cons |
|---|---|---|
| S3 (default) | Unlimited, ~$0.01/month, no rate limits | No on-the-fly transforms, slower poster generation |
| Cloudinary | Auto transforms, fast poster generation | 500 API calls/day limit (free tier) |
| Mux | Best for video streaming | 10 video limit (free tier) |
# Media Provider: 's3' | 'cloudinary' | 'mux'
MEDIA_PROVIDER=s3
# AWS S3 (when MEDIA_PROVIDER=s3)
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=us-east-1
S3_BUCKET_NAME=your-bucket-name
# Cloudinary (when MEDIA_PROVIDER=cloudinary)
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret
# Mux (when MEDIA_PROVIDER=mux)
MUX_TOKEN_ID=your_token_id
MUX_TOKEN_SECRET=your_token_secret
# Notion (required for showcase data)
NOTION_TOKEN=your_notion_integration_token
# API Security
CRON_SECRET=generate_with_openssl_rand_base64_32
REVALIDATE_SECRET=generate_with_openssl_rand_base64_32
NOTION_WEBHOOK_SECRET=generate_with_openssl_rand_base64_32| Endpoint | Purpose | Auth | Trigger |
|---|---|---|---|
/api/cron/sync-showcase |
Sync all published entries | Authorization: Bearer $CRON_SECRET |
Vercel Cron (every 10 min) |
/api/notion-webhook |
Instant updates on Notion changes | Authorization: Bearer $NOTION_WEBHOOK_SECRET |
Notion webhook |
/api/revalidate |
Manual cache invalidation | Authorization: Bearer $REVALIDATE_SECRET |
Manual trigger |
Change the provider by setting the MEDIA_PROVIDER environment variable:
# Use S3 (default, recommended)
MEDIA_PROVIDER=s3
# Use Cloudinary
MEDIA_PROVIDER=cloudinary
# Use Mux (videos only)
MEDIA_PROVIDER=muxNo code changes needed - just update the env var and redeploy.
If using S3, create a bucket with public read access:
# Create bucket
aws s3api create-bucket --bucket your-bucket-name --region us-east-1
# Enable public read
aws s3api put-bucket-policy --bucket your-bucket-name --policy '{
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-bucket-name/*"
}]
}'
# Set CORS
aws s3api put-bucket-cors --bucket your-bucket-name --cors-configuration '{
"CORSRules": [{"AllowedOrigins": ["*"], "AllowedMethods": ["GET", "HEAD"], "AllowedHeaders": ["*"], "MaxAgeSeconds": 86400}]
}'To migrate existing assets from Cloudinary to S3:
# Ensure both Cloudinary and S3 env vars are set
bun run scripts/migrate-cloudinary-to-s3.tsThe script will:
- List all assets in Cloudinary's
lenis-showcasefolder - Download each asset
- Upload to S3 with the same filename
- Generate video posters using Cloudinary's transformation API
- Skip assets that already exist in S3
Trigger a manual sync (useful for testing):
curl -H "Authorization: Bearer $CRON_SECRET" \
https://your-domain.vercel.app/api/cron/sync-showcaselib/media/
├── index.ts # Main exports
├── media-resolver.ts # Core resolution logic
├── types.ts # TypeScript interfaces
├── providers/
│ ├── index.ts # Provider registry (env-based selection)
│ ├── s3.ts # AWS S3 implementation
│ ├── cloudinary.ts # Cloudinary implementation
│ └── mux.ts # Mux implementation
└── utils/
└── ffmpeg.ts # WASM-based video poster generation
scripts/
└── migrate-cloudinary-to-s3.ts # Migration script
- All API endpoints require bearer token authentication
- Tokens should be generated with
openssl rand -base64 32 - Timing-safe comparison is used to prevent timing attacks
- URL fetching is restricted to an allowlist of trusted hosts
- Only Notion S3, Cloudinary, and Mux URLs are allowed
- Never commit
.env.localor secrets to git - Rotate secrets periodically
- Use IAM roles with minimal permissions for S3
Debug mode: Press Cmd/Ctrl + O to toggle debug overlay
Hosted on Vercel with automatic deployments from GitHub.