
📷 EXIF
Photo Blog
https://github.com/sambecker/exif-photo-blog/assets/169298/4253ea54-558a-4358-8834-89943cfbafb4
🎬 Demo
✨ Features
- Built-in auth
- Photo upload with EXIF extraction
- Organize photos by tag
- Infinite scroll
- Light/dark mode
- Automatic OG image generation
- CMD-K menu with photo search
- AI-generated text descriptions
- RSS/JSON feeds
- Support for Fujifilm recipes and film simulations
🛠️ Installation
1. Deploy to Vercel
- Click Deploy
- Add required storage (Vercel Postgres + Vercel Blob) as part of template installation
- Configure environment variable for production domain in project settings
NEXT_PUBLIC_DOMAIN
(e.g., photos.domain.com—used in absolute urls and seen in navigation if no explicit nav title is set)
2. Setup Auth
- Generate auth secret and add to environment variables:
AUTH_SECRET
- Add admin user to environment variables:
ADMIN_EMAIL
ADMIN_PASSWORD
- Trigger redeploy
- Visit project on Vercel, navigate to "Deployments" tab, click ••• button next to most recent deployment, and select "Redeploy"
3. Upload your first photo 🎉
- Visit
/admin
- Sign in with credentials supplied in Step 2
- Click "Upload Photos"
- Add optional title
- Click "Create"
If you don't plan to change the code, or don't mind making your updates public, consider forking this repo to easily receive future updates. If you've already setup your project on Vercel see detailed instructions here on reconfiguring your project.
🔄 Receiving updates
If you don't plan to change the code, or don't mind making your updates public, consider forking this repo to easily receive future updates. If you've already set up your project on Vercel see these migration instructions.
💻 Local development
- Clone code
- Run
pnpm i
to install dependencies - If necessary, install Vercel CLI and authenticate by running
vercel login
- Run
vercel link
to connect CLI to your project - Run
vercel dev
to start dev server with Vercel-managed environment variables
See FAQ for limitations of local development
🎨 Further customization
AI text generation
⚠️ READ BEFORE PROCEEDING
Usage of this feature will result in fees from OpenAI. When enabling AI text generation, follow all recommended mitigations in order to avoid unexpected charges and attacks. Make sure your OpenAI secret key environment variable is not prefixed with NEXT_PUBLIC.
- Setup OpenAI
- If you don't already have one, create an OpenAI account and fund it (see this thread if you're having issues)
- Generate an API key and store in environment variable
OPENAI_SECRET_KEY
(make sure to enable Responses API write access if customizing permissions) - Setup usage limits to avoid unexpected charges (recommended)
- Add rate limiting (recommended)
- As an additional precaution, create an Upstash Redis store from the storage tab of the Vercel dashboard and link it to your project in order to enable rate limiting—no further configuration necessary
- Configure auto-generated fields (optional)
- Set which text fields auto-generate when uploading a photo by storing a comma-separated list, e.g.,
AI_TEXT_AUTO_GENERATED_FIELDS = title,semantic
- Accepted values:
all
title
(default)caption
tags
(default)semantic
(default)none
- Set which text fields auto-generate when uploading a photo by storing a comma-separated list, e.g.,
Set OPENAI_BASE_URL
in order to use an alternate OpenAI-compatible provider
Web Analytics
- Open project on Vercel
- Click "Analytics" tab
- Follow "Enable Web Analytics" instructions (
@vercel/analytics
already included)
Speed Insights
- Open project on Vercel
- Click "Speed Insights" tab
- Follow "Enable Speed Insights" instructions (
@vercel/speed-insights
already included)
Optional configuration
Application behavior can be changed by configuring the following environment variables:
ContentNEXT_PUBLIC_META_TITLE
(seen in search results and browser tab)NEXT_PUBLIC_META_DESCRIPTION
(seen in search results)NEXT_PUBLIC_NAV_TITLE
(seen in top-right navigation, defaults to domain when not configured)NEXT_PUBLIC_NAV_CAPTION
(seen in top-right navigation, beneath title)NEXT_PUBLIC_PAGE_ABOUT
(seen in grid sidebar—accepts rich formatting tags:<b>
,<strong>
,<i>
,<em>
,<u>
,<br>
)NEXT_PUBLIC_DOMAIN_SHARE
(seen in share modals where a shorter url may be desirable)
⚠️ Enabling may result in increased project usage. Static optimization troubleshooting hints in FAQ.
NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS = 1
enables static optimization for photo pages (p/[photoId]
), i.e., renders pages at build timeNEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES = 1
enables static optimization for OG images, i.e., renders images at build timeNEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORIES = 1
enables static optimization for photo categories (tag/[tag]
,shot-on/[make]/[model]
, etc.), i.e., renders pages at build timeNEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_CATEGORY_OG_IMAGES = 1
enables static optimization for photo category (tag/[tag]
,shot-on/[make]/[model]
, etc.) OG images, i.e., renders images at build timeNEXT_PUBLIC_PRESERVE_ORIGINAL_UPLOADS = 1
prevents photo uploads being compressed before storingNEXT_PUBLIC_IMAGE_QUALITY = 1-100
controls the quality of large photosNEXT_PUBLIC_BLUR_DISABLED = 1
prevents image blur data being stored and displayed (potentially useful for limiting Postgres usage)
NEXT_PUBLIC_CATEGORY_VISIBILITY
- Comma-separated value controlling which photo sets appear in grid sidebar and CMD-K menu, and in what order. For example, you could move cameras above tags, and hide film simulations, by updating to
cameras,tags,lenses,recipes
. - Accepted values:
recents
(default)years
tags
(default)cameras
(default)lenses
(default)recipes
(default)films
(default)focal-lengths
- Comma-separated value controlling which photo sets appear in grid sidebar and CMD-K menu, and in what order. For example, you could move cameras above tags, and hide film simulations, by updating to
NEXT_PUBLIC_HIDE_CATEGORY_IMAGE_HOVERS = 1
prevents images displaying when hovering over category links:NEXT_PUBLIC_EXHAUSTIVE_SIDEBAR_CATEGORIES = 1
always shows expanded sidebar contentNEXT_PUBLIC_HIDE_TAGS_WITH_ONE_PHOTO = 1
to only show tags with 2 or more photos
NEXT_PUBLIC_DEFAULT_SORT
- Sets default sort on grid/full homepages
- Accepted values:
taken-at
(default)taken-at-oldest-first
uploaded-at
uploaded-at-oldest-first
NEXT_PUBLIC_NAV_SORT_CONTROL
- Controls sort UI on grid/full homepages
- Accepted values:
none
toggle
(default)menu
- Color-based sorting (experimental)
NEXT_PUBLIC_SORT_BY_COLOR = 1
enables color-based sorting (forces nav sort control to "menu," flags photos missing color data in admin dashboard)—color identification benefits greatly from AI being enabledNEXT_PUBLIC_COLOR_SORT_STARTING_HUE
controls which colors start first (accepts a hue of 0 to 360, default: 80)NEXT_PUBLIC_COLOR_SORT_CHROMA_CUTOFF
controls which colors are considered sufficiently vibrant (accepts a chroma of 0 to 0.37, default: 0.05):
NEXT_PUBLIC_PRIORITY_BASED_SORTING = 1
takes priority field into account when sorting photos (⚠️ enabling may have performance consequences)
NEXT_PUBLIC_HIDE_KEYBOARD_SHORTCUT_TOOLTIPS = 1
hides keyboard shortcut hints in areas like the main nav, and previous/next photo linksNEXT_PUBLIC_HIDE_EXIF_DATA = 1
hides EXIF data in photo details and OG images (potentially useful for portfolios, which don't focus on photography)NEXT_PUBLIC_HIDE_ZOOM_CONTROLS = 1
hides fullscreen photo zoom controlsNEXT_PUBLIC_HIDE_TAKEN_AT_TIME = 1
hides taken at time from photo metaNEXT_PUBLIC_HIDE_SOCIAL = 1
removes X (formerly Twitter) button from share modalNEXT_PUBLIC_HIDE_REPO_LINK = 1
removes footer link to repo
NEXT_PUBLIC_GRID_HOMEPAGE = 1
shows grid layout on homepageNEXT_PUBLIC_GRID_ASPECT_RATIO = 1.5
sets aspect ratio for grid tiles (defaults to1
—setting to0
removes the constraint)NEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1
ensures large thumbnails on photo grid views (if not configured, density is based on aspect ratio)
NEXT_PUBLIC_DEFAULT_THEME = light | dark
sets preferred initial theme (defaults tosystem
when not configured)NEXT_PUBLIC_MATTE_PHOTOS = 1
constrains the size of each photo, and displays a surrounding border, potentially useful for photos with tall aspect ratios (colors can be customized viaNEXT_PUBLIC_MATTE_COLOR
+NEXT_PUBLIC_MATTE_COLOR_DARK
)
NEXT_PUBLIC_GEO_PRIVACY = 1
disables collection/display of location-based data (⚠️ re-compresses uploaded images in order to remove GPS information)NEXT_PUBLIC_ALLOW_PUBLIC_DOWNLOADS = 1
enables public photo downloads for all visitors (⚠️ may result in increased bandwidth usage)NEXT_PUBLIC_SITE_FEEDS = 1
enables feeds at/feed.json
and/rss.xml
NEXT_PUBLIC_OG_TEXT_ALIGNMENT = BOTTOM
keeps OG image text bottom aligned (default is top)
Alternate storage providers
Only one storage adapter—Vercel Blob, Cloudflare R2, AWS S3, or MinIO—can be used at a time. Ideally, this is configured before photos are uploaded (see Issue #34 for migration considerations). If you have multiple adapters, you can set one as preferred by storing aws-s3
, cloudflare-r2
, minio
, or vercel-blob
in NEXT_PUBLIC_STORAGE_PREFERENCE
. See FAQ regarding unsupported providers.
Cloudflare R2
- Setup bucket
- Create R2 bucket with default settings
- Setup CORS under bucket settings:
[{"AllowedHeaders": ["*"],"AllowedMethods": ["GET","PUT"],"AllowedOrigins": ["http://localhost:3000","https://{VERCEL_PROJECT_NAME}*.vercel.app","{PRODUCTION_DOMAIN}"]}]- Enable public hosting by doing one of the following:
- Select "Connect Custom Domain" and choose a Cloudflare domain
- OR
- Select "Allow Access" from R2.dev subdomain
- Store public configuration:
NEXT_PUBLIC_CLOUDFLARE_R2_BUCKET
: bucket nameNEXT_PUBLIC_CLOUDFLARE_R2_ACCOUNT_ID
: account id (found on R2 overview page)NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_DOMAIN
: either "your-custom-domain.com" or "pub-jf90908...s0d9f8s0s9df.r2.dev"
- Setup private credentials
- Create API token by selecting "Manage R2 API Tokens," and clicking "Create API Token"
- Select "Object Read & Write," choose "Apply to specific buckets only," and select the bucket created in Step 1
- Store credentials (⚠️ Ensure access keys are not prefixed with
NEXT_PUBLIC
):CLOUDFLARE_R2_ACCESS_KEY
CLOUDFLARE_R2_SECRET_ACCESS_KEY
AWS S3
- Setup bucket
- Create S3 bucket with "ACLs enabled," and "Block all public access" turned off
- Setup CORS under bucket permissions:
[{"AllowedHeaders": ["*"],"AllowedMethods": ["GET","PUT"],"AllowedOrigins": ["http://localhost:*","https://{VERCEL_PROJECT_NAME}*.vercel.app","{PRODUCTION_DOMAIN}"],"ExposeHeaders": []}]
- Store public configuration
NEXT_PUBLIC_AWS_S3_BUCKET
: bucket nameNEXT_PUBLIC_AWS_S3_REGION
: bucket region, e.g., "us-east-1"
- Setup private credentials
- Create IAM policy using JSON editor:
{"Version": "2012-10-17","Statement": [{"Effect": "Allow","Action": ["s3:PutObject","s3:PutObjectACL","s3:GetObject","s3:ListBucket","s3:DeleteObject"],"Resource": ["arn:aws:s3:::{BUCKET_NAME}","arn:aws:s3:::{BUCKET_NAME}/*"]}]}
- Create IAM user by choosing "Attach policies directly," and selecting the policy created above. Create "Access key" under "Security credentials," choose "Application running outside AWS," and store credentials (⚠️ Ensure access keys are not prefixed with
NEXT_PUBLIC
):AWS_S3_ACCESS_KEY
AWS_S3_SECRET_ACCESS_KEY
- Create IAM policy using JSON editor:
MinIO
MinIO is a self-hosted S3-compatible object storage server.
1. Server/bucket setup
First, install and deploy the MinIO server, then create a bucket with public read access.
-
Install MinIO: Follow official documentation to install and deploy MinIO.
-
Create bucket:
mc mb myminio/{BUCKET_NAME} -
Set public read policy: Create file named
bucket-policy.json
with the following content to allow read-only access:{"Version": "2012-10-17","Statement": [{"Effect": "Allow","Principal": {"AWS": ["*"]},"Action": ["s3:GetObject"],"Resource": ["arn:aws:s3:::{BUCKET_NAME}/*"]}]}Next, apply this policy to your bucket:
mc policy set myminio/photos bucket-policy.json -
Store public configuration: Set the following public environment variables for your application:
NEXT_PUBLIC_MINIO_BUCKET
: Bucket nameNEXT_PUBLIC_MINIO_DOMAIN
: MinIO server endpoint, e.g., "minio.yourdomain.com"NEXT_PUBLIC_MINIO_PORT
: (optional)NEXT_PUBLIC_MINIO_DISABLE_SSL
: Set to1
to disable SSL (defaults to HTTPS)
2. Create user with restricted permissions
Create a dedicated user and a policy that grants permission to manage objects within your BUCKET_NAME
.
- Define user policy: Create file named
user-policy.json
. This policy will allow the user to list the bucket contents and to get, put, and delete objects within it.{"Version": "2012-10-17","Statement": [{"Effect": "Allow","Action": ["s3:DeleteObject","s3:GetObject","s3:ListBucket","s3:PutObject"],"Resource": ["arn:aws:s3:::{BUCKET_NAME}/*","arn:aws:s3:::{BUCKET_NAME}"]}]} - Create policy: Add named policy to MinIO.
mc admin policy add myminio photos-manager-policy user-policy.json
- Create user: Create new user with access key and secret key.
mc admin user add myminio {MINIO_ACCESS_KEY} {MINIO_SECRET_ACCESS_KEY}
- Attach policy to user: Assign
photos-manager-policy
to the user.mc admin policy set myminio photos-manager-policy user=MINIO_ACCESS_KEY - Store private credentials: Set the following private environment variables for your application. ⚠️ Ensure these access keys are not prefixed with
NEXT_PUBLIC
.MINIO_ACCESS_KEY
: Your MINIO_ACCESS_KEYMINIO_SECRET_ACCESS_KEY
: Your MINIO_SECRET_ACCESS_KEY
Alternate database providers (experimental)
Vercel Postgres can be switched to another Postgres-compatible, pooling provider by updating POSTGRES_URL
. Some providers only work when SSL is disabled, which can configured by setting DISABLE_POSTGRES_SSL = 1
.
Supabase
- Ensure connection string is set to "Transaction Mode" via port
6543
- Disable SSL by setting
DISABLE_POSTGRES_SSL = 1
💬 I18N
Partial internationalization (for non-admin, user-facing text) provided for a handful of languages. Configure locale by setting environment variable NEXT_PUBLIC_LOCALE
.
Supported Languages
bd-bn
en-gb
en-us
id-id
pt-br
pt-pt
tr-tr
zh-cn
To add support for a new language, open a PR following instructions in /src/i18n/index.ts, using en-us.ts as reference.
Thank you ❤️ translators: @sconetto (pt-br
, pt-pt
), @brandnholl (id-id
), @TongEc (zh-cn
), @xahidex (bd-bn
, hi-in
), @mehmetabak (tr-tr
), @simondeeley (en-gb
)
📖 FAQ
How do I receive template updates?How do I edit multiple photos?For forked repos, click "Code," then "Update branch" from the main repo page. If you originally cloned the code, you can create a fork from GitHub, then update your Git connection from your Vercel project settings. Once you've done this, you may need to go to your project deployments page, click •••, select "Create deployment," and choose
main
.
Why don't my photo changes show up immediately?In the admin menu, select "Batch edit ..." From there, you can perform bulk tag, favorite, and delete actions.
Why do production deployments fail when static optimization is enabled?This template statically optimizes core views such as
/
and/grid
to minimize visitor load times. Consequently, when photos are added, edited, or removed, it might take several minutes for those changes to propagate. If it seems like a change is not taking effect, try navigating to/admin/configuration
and clicking "Clear Cache."
Why don't my older photos look right?There have been reports (#184 + #185) that having large photos (over 30MB), or a CDN, e.g., Cloudflare in front of Vercel, may destabilize static optimization.
Why don't my OG images load when I share a link?As the template has evolved, EXIF fields (such as lenses) have been added, blur data is generated through a different method, and AI/privacy features have been added. In order to bring older photos up to date, either click the 'sync' button next to a photo or go to photo updates (
/admin/photos/updates
) to sync all photos that need updates.
Why do vertical images take up so much space?Many services such as iMessage, Slack, and X, require near-instant responses when unfurling link-based content. In order to guarantee sufficient responsiveness, consider rendering pages and image assets ahead of time by enabling static optimization by setting
NEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTOS = 1
andNEXT_PUBLIC_STATICALLY_OPTIMIZE_PHOTO_OG_IMAGES = 1
. Keep in mind that this will increase platform usage.
Why are my grid thumbnails so small?By default, all photos are shown full-width, regardless of orientation. Enable matting to showcase horizontal and vertical photos at similar scales by setting
NEXT_PUBLIC_MATTE_PHOTOS = 1
.
How secure are photos marked “private?”Thumbnail grid density (seen on
/grid
, tag overviews, and other photo sets) is dependent on aspect ratio configuration (ratios of 1 or less have more photos per row). This can be overridden by settingNEXT_PUBLIC_SHOW_LARGE_THUMBNAILS = 1
.
My images/content have fallen out of sync with my database and/or my production site no longer matches local development. What do I do?While all private paths (
/tag/private/*
) require authentication, raw links to individual photo assets remain publicly accessible. Randomly generated urls from storage providers are only secure via obscurity. Use with caution.
I'm seeing server-side runtime errors when loading a page after updating my fork. What do I do?Navigate to
/admin/configuration
and click "Clear Cache."
Why can’t I upload HEIC files?Navigate to
/admin/configuration
and click "Clear Cache." If this doesn't help, open an issue.
Why are my thumbnails square?This template relies on
sharp
to manipulate images andnext/image
to serve them, neither of which currently support HEIC (https://github.com/vercel/next.js/discussions/30043 + https://github.com/lovell/sharp/issues/3981). Fortunately, you can still upload HEIC files directly from native share controls on Apple platforms and they will automatically be converted to JPG upon upload. If you think you have a viable HEIC strategy, feel free to open a PR. See https://github.com/sambecker/exif-photo-blog/issues/229 for discussion.
Why aren't Fujifilm simulations importing alongside EXIF data?Absent configuration, the default grid aspect ratio is
1
.NEXT_PUBLIC_GRID_ASPECT_RATIO
can be set to any number (for instance,1.5
for 3:2 images) or ignored by setting to0
.
My Fujifilm recipes are missing/displaying incorrect data. What should I do?Fujifilm simulation data is stored in vendor-specific Makernote binaries embedded in EXIF data. Under certain circumstances an intermediary may strip out this data. For instance, there is a known issue on iOS where editing an image, e.g., cropping it, causes Makernote data loss. If simulation data appears to be missing, try importing the original file as it was stored by the camera. Additionally, if you can confirm the simulation mode, you can edit the photo and manually select it.
How do I hide Fujifilm content such as a recipes and film simulations?If you don't see a recipe, first try syncing your photo from the ••• menu, or from
/admin/photos
. If the data looks incorrect, open an issue with the file in question attached in order for it to be investigated. Fujifilm file specifications have evolved over time and recipe parsing may need to be adjusted based on camera model/vintage.
Why do my images appear flipped/rotated incorrectly?This can be accomplished by setting
NEXT_PUBLIC_CATEGORY_VISIBILITY
(which has a default value oftags,cameras,lenses,recipes,films
) totags,cameras,lenses
.
Why does my image placeholder blur look different from photo to photo?For a number of reasons, only EXIF orientations: 1, 3, 6, and 8 are supported. Orientations 2, 4, 5, and 7—which make use of mirroring—are not supported.
Why are large, multi-photo uploads not finishing?Earlier versions of this template generated blur data on the client, which varied visually from browser to browser. Data is now generated consistently on the server. If you wish to update blur data for a particular photo, edit the photo in question, make no changes, and choose "Update."
I've added my OpenAI key but can't seem to make it work. Why am I seeing connection errors?The default timeout for processing multiple uploads is 60 seconds (the limit for Hobby accounts). This can be extended to 5 minutes on Pro accounts by setting
maxDuration = 300
insrc/app/admin/uploads/page.tsx
.
How do I generate AI text for preexisting photos?You may need to pre-purchase credits before accessing the OpenAI API. See #110 for discussion. If you've customized key permissions, make sure write access to the Responses API is enabled.
Will there be support for image storage providers beyond Vercel, AWS, Cloudflare, and MinIO?Once AI text generation is configured, photos missing text will show up in photo updates (
/admin/photos/updates
).
Can I work locally without access to an image storage provider?At this time, there are no plans to introduce support for new storage providers. The template now supports Vercel Blob, AWS S3, Cloudflare R2, and MinIO (self-hosted S3-compatible storage). While configuring other AWS-compatible providers should not be too difficult, there's nuance to consider surrounding details like IAM, CORS, and domain configuration, which can differ slightly from platform to platform. If you'd like to contribute an implementation for a new storage provider, please open a PR.
Can this template be self-hosted?At this time, an external storage provider is necessary in order to develop locally. If you have a strategy to propose which allows files to be locally uploaded and served to
next/image
in away that mirrors an external storage provider for debugging purposes, please open a PR.
Why am I seeing many merge conflicts when syncing my fork?Possibly. See #116 and #132 for discussion around image hosting and docker usage.
Previous versions of this template stored Next.js "App Router" files in
/src
, and app-level functionality in/src/site
. If you've made customizations and are having difficulty merging updates, consider moving/src/app
files to/
, and renamingsrc/site
to/src/app
. Other structural changes include movingtailwind.css
andmiddleware.ts
to/
. Additionally, it may be helpful to review PR #195 for an overview of the most significant changes.