Overview
This guide walks you through deploying The Noticing Project for the first time. Setup takes about 45–60 minutes. Once done, starting each new term takes about 5 minutes.
You will create free accounts at four services. None require a credit card for the usage levels typical of a 30-student term.
Step 1 — Put the code on GitHub
Download the project zip from the repository releases page and unzip it on your computer.
Go to github.com/new. Name it noticing-project. You can make it private if you prefer.
From your terminal, inside the unzipped project folder:
git init
git add .
git commit -m "initial setup"
git remote add origin https://github.com/YOUR-USERNAME/noticing-project.git
git push -u origin mainStep 2 — Set up the database (Vercel Postgres)
The database is created directly inside Vercel — no separate account or CLI needed. You'll do this after deploying in Step 6, but it's listed here so you know what to expect.
In Vercel, go to your project → Storage → Create Database → Postgres. Vercel automatically adds the database credentials to your environment variables. That's it — nothing to install or configure manually.
Step 3 — Set up media storage (Cloudinary)
Sign in at cloudinary.com. On your Dashboard, find your Cloud Name, API Key, and API Secret.
Go to Settings → Upload → Upload presets → Add upload preset. Set these three fields — everything else can stay at its default:
- Preset name:
noticing-project— must match exactly; the app references it by name. - Signing mode: set to Unsigned. This is required. It allows students' browsers to upload directly to Cloudinary without routing files through your server. If left on Signed, all uploads will fail with a permissions error.
- Asset folder:
noticing-project— keeps all project media in one folder in your Cloudinary library so it does not mix with anything else. Not strictly required, but strongly recommended.
Click Save when done.
Why Unsigned? Students' browsers upload directly to Cloudinary — your server never handles binary file data. This keeps the app fast and hosting costs at zero. If uploads ever stop working, the first thing to check is that this preset is still set to Unsigned.
Step 4 — Configure environment variables
Find the file named .env.example in the project folder. You need to create two env files — Prisma reads .env, while Next.js reads .env.local. They serve different purposes.
Database credentials are set automatically. When you create a Vercel Postgres database in Step 6, Vercel adds POSTGRES_PRISMA_URL and POSTGRES_URL_NON_POOLING to your environment variables automatically. You do not need to set these manually.
Create .env.local
Duplicate .env.example, rename the copy to .env.local, and fill in each value:
Then create a symlink so Prisma (which only reads .env) can see the same variables:
ln -s .env.local .envThis means one file to maintain. Both .env and .env.local are gitignored so neither gets committed.
| POSTGRES_PRISMA_URL | Set automatically by Vercel when you create a Postgres database. Not needed in .env.local. |
| POSTGRES_URL_NON_POOLING | Set automatically by Vercel. Not needed in .env.local. |
| NEXTAUTH_SECRET | A random string. Generate one: openssl rand -base64 32 |
| NEXTAUTH_URL | http://localhost:3000 for local testing |
| NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME | Your Cloudinary cloud name |
| CLOUDINARY_API_KEY | Your Cloudinary API key |
| CLOUDINARY_API_SECRET | Your Cloudinary API secret |
| NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET | noticing-project |
| GEOFENCE_POLYGON | Leave the default Dartmouth value for now — adjust in Step 7 |
Install dependencies:
npm installThe npm run db:push command is only needed after the Vercel Postgres database is created. Vercel runs prisma generate automatically during each deploy.
Step 5 — Create your admin account
Run this once from the project folder. Replace the values in quotes with your own details:
node -e "
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
async function main() {
const hash = await bcrypt.hash('YOUR-PASSWORD', 12);
await prisma.user.create({ data: {
username: 'admin',
email: 'you@dartmouth.edu',
displayName: 'Instructor',
passwordHash: hash,
role: 'admin',
}});
console.log('Admin created');
}
main().finally(() => prisma.\$disconnect());
"This only needs to be run once, ever. Your admin account persists across all terms.
Step 6 — Deploy to Vercel
Go to vercel.com/new and click Import next to your noticing-project repository.
Before clicking Deploy, open the Environment Variables section and add the variables from .env.local. Set NEXTAUTH_URL to your Vercel domain, e.g. https://noticing-project.vercel.app. Do not add POSTGRES_PRISMA_URL or POSTGRES_URL_NON_POOLING yet — these are added automatically when you create the database.
Click Deploy. Vercel builds and publishes the site in about 60 seconds.
In your Vercel project, go to Storage → Create Database → Postgres. Follow the prompts — Vercel automatically adds POSTGRES_PRISMA_URL and POSTGRES_URL_NON_POOLING to your environment variables and triggers a redeploy.
Once the Postgres database exists, run this once from your terminal to create all the tables:
npx vercel env pull .env.vercel
DATABASE_URL=$(grep POSTGRES_PRISMA_URL .env.vercel | cut -d= -f2-) npx prisma db pushThen delete .env.vercel — it contains credentials and should not be committed.
Step 7 — Adjust the campus boundary
The app ships with a default Dartmouth polygon. To refine it:
Sign in to your live site and go to /admin/geofence.
Click points on the map to outline the campus area you want to include. Use Undo last to correct mistakes.
Click Copy GeoJSON to clipboard. In Vercel, go to Settings → Environment Variables, update GEOFENCE_POLYGON, then Redeploy.
Each term: what to do
At the start of term
Go to /admin. Click Create term, name it (e.g. Winter 2026), then click Activate.
In the Registration whitelist section, paste your class email list from your course management system — one email per line. Only whitelisted emails can create accounts.
Send students the site URL. They register, claim a spot, and begin posting.
At the end of term
Go to /admin and click Archive term. All journals become read-only and remain publicly visible indefinitely. Students keep their accounts and can re-enroll in future terms.
What is this?
The Noticing Project is a field journal platform. You will claim a single spot on the Dartmouth campus, return to it throughout the term, and record what you observe — in writing, photos, audio, and video.
We live in what scholars call an "attention economy" — an environment where our focus has become a resource that apps, ads, and platforms compete to capture. The rhythm of daily life tends toward speed: we glance rather than gaze, skim rather than read, and move on before we have fully arrived.
This project invites you to practice slow looking — returning again and again to one seemingly unremarkable place and documenting what reveals itself through patient observation. The goal is not to reject technology, but to cultivate a complementary skill: the ability to direct your attention deliberately and discover what emerges when you give a small corner of the world your sustained, unhurried gaze.
Creating your account
Your instructor will share the URL. Click Join in the top-right corner.
Use the exact email address your instructor has on file — registration is by invitation only. Choose a username that will appear publicly on the map and your journal.
After registering you'll be taken directly to the campus map to claim your spot.
If registration is blocked, check that you're using the exact email your instructor enrolled — not an alias or forwarding address.
Claiming your spot
You get one spot per term. Choose carefully — you will be returning to it many times.
The map enters placement mode.
The pin must fall within the campus boundary. Click again to move it. Pins cannot be placed very close to another student's spot.
Not just "a bench" but "the bench facing east behind Collis, near the spruce, beside the stone wall." Specificity commits you to a particular place.
You can rename your spot at any time during the term — click the spot name on your journal page to edit it inline.
Posting a noticing
From your spot's journal page, click + New Noticing. The timestamp is set automatically when you open the compose screen — it records when you are actually at the spot, not when you finished writing.
Writing
Write in the large text area. There is no minimum length — some entries may be a single sentence, others several paragraphs. The text area supports basic markdown: **bold**, *italic*, - lists, > blockquotes, and [links](url). A hint is shown in the compose screen.
Entries can be edited within 48 hours of posting. After that they become permanent — the journal is a record of what you actually noticed at that moment.
Attaching media
| Type | How | Limit |
|---|---|---|
| Photo | Click photo and browse, or drag a file onto the drop zone. Uploads directly — no leaving the app. | 3 per entry, max 20 MB each |
| Audio | Click audio and attach a voice memo or field recording. Drag and drop also works. | 1 per entry, max 60 seconds |
| Video | Upload to YouTube (unlisted is fine), then click video and paste the link. | 1 per entry — keep it brief |
Click any photo in a journal to enlarge it. Photos are automatically compressed on upload — a 4 MB phone photo becomes roughly 400 KB with no visible quality loss.
Changing your spot
You can abandon your spot and claim a new one at any time during the term. On your journal page, click abandon spot at the bottom.
⚠️ This permanently deletes your spot and all your entries. There is no undo.
After abandoning you'll be taken back to the map to claim a new spot immediately.
Reading other journals
All journals are public. Click browse in the navigation to see every spot this term. Click any pin on the map to jump to that person's journal.
Tips for good noticing
- Visit often. The value compounds. A spot visited twice a week for nine weeks reveals things a single long visit never would.
- Notice what has changed. What is different since your last visit? What has appeared? What has gone?
- Follow your curiosity. If something catches your attention, research it. Your journal can contain research as well as observation.
- Use all your senses. What do you hear? What does the light do at this hour? Is there a smell?
- Don't wait for something to happen. The point is to discover that something is always happening.
- Be specific. "A bird" is less useful than "a pigeon with a gray band on its left leg." Specificity sharpens attention.
The admin dashboard
Sign in and go to /admin. The dashboard is the control centre for the project. You'll use it at the start and end of each term, and occasionally to resolve flagged content.
Dashboard sections
| Section | What it does |
|---|---|
| Terms | Create, activate, and archive terms. Only one term can be active at a time. |
| Registration whitelist | Paste your class email list. Only whitelisted emails can register. |
| Students | See all registered students, their spots, and entry counts. Remove accounts if needed. |
| Flagged content | Appears when students flag an entry. Dismiss the flag or delete the entry. |
Term lifecycle
Each term moves through three states:
| State | What students can do |
|---|---|
draft | Nothing — the term isn't visible to students yet |
active | Register, claim spots, post observations, abandon and reclaim spots |
archived | Read their journals — all posting is locked |
Archived journals remain publicly readable forever at their original URLs. Students from past terms can still sign in and read their work.
Managing the whitelist
At the start of each term, paste your class list from your course management system into the whitelist field in /admin. One email per line. The app also accepts comma- or semicolon-separated lists.
The whitelist table shows two groups: Not yet registered and Registered. Use this in the first week of term to follow up with students who haven't created their accounts yet.
Emails are reusable — a returning student from a prior term just signs in without re-registering. Removing an email from the whitelist doesn't delete their account; it only prevents new registrations with that address.
Geofence editor
Go to /admin/geofence to adjust the campus boundary. Click points on the map to trace the perimeter, use Undo last to correct mistakes, and click Copy GeoJSON to clipboard when done.
Paste the result into GEOFENCE_POLYGON in your Vercel environment variables, then redeploy. Students trying to claim a spot outside the boundary will see a clear error message.
Storage & limits
| Service | Free tier | Estimated usage (30 students, 9 weeks) |
|---|---|---|
| Cloudinary | 10 GB storage, 20 GB/mo bandwidth | ~1–2 GB with auto-compression |
| Vercel Postgres | 256 MB storage, 60 compute hours/month | Well under 10 MB (text and URLs only) |
| Vercel | 100 GB bandwidth/mo | Negligible at this scale |
You could run approximately 5–6 terms before approaching Cloudinary's free storage limit. Check usage at cloudinary.com/console mid-term.
Troubleshooting
| Problem | Fix |
|---|---|
| Students can't claim spots | Check that a term is set to active in /admin |
| "Outside campus boundary" error | Go to /admin/geofence and expand the polygon |
| Cloudinary uploads failing | Check that your upload preset is Unsigned in Cloudinary → Settings → Upload |
| Registration blocked unexpectedly | Confirm the student's email is in the whitelist, exactly as typed |
| Database errors on Vercel | Check that the Postgres database is created in Vercel Storage and that POSTGRES_PRISMA_URL appears in your environment variables. |
| Site not reflecting code changes | Vercel → Deployments → Redeploy |
Project file structure
src/
app/
api/
spots/ # claim and list spots
entries/ # post and read journal entries
terms/ # create, activate, archive terms
users/ # registration and current user
media/ # Cloudinary upload signing
admin/ # whitelist, flags, user management
auth/ # NextAuth route handler
admin/ # instructor dashboard + geofence editor
map/ # main campus map view
spot/[id]/ # individual spot journal (public)
new-noticing/ # compose screen
browse/ # public spot directory
login/ register/ # auth pages
components/ # shared UI components
lib/
prisma.ts # database client
auth.ts # NextAuth config
cloudinary.ts # media upload helpers
geofence.ts # campus boundary check
upload.ts # client-side upload utility
middleware.ts # route protection
prisma/
schema.prisma # data modelOverview for IT & Developers
The Noticing Project is a Next.js 14 application using the App Router, deployed to Vercel, with a Neon PostgreSQL database and Cloudinary for media storage. This page covers the architecture, environment setup, deployment pipeline, and maintenance tasks.
Tech stack
| Layer | Technology | Notes |
|---|---|---|
| Framework | Next.js 14 (App Router) | React server components, API routes, middleware |
| Language | TypeScript | Strict mode, moduleResolution: node, target es2017 |
| Database | Neon (PostgreSQL) | Prisma ORM v5.14, two connection strings required (pooled + direct) |
| Auth | NextAuth.js v5 beta | Credentials provider, JWT sessions, cookie-based middleware |
| Media | Cloudinary | Direct browser upload via unsigned preset, auto-compression on images |
| Hosting | Vercel | Auto-deploys from GitHub main branch |
| Maps | Leaflet + OpenStreetMap | Loaded dynamically to avoid SSR issues |
| Geofence | @turf/boolean-point-in-polygon | GeoJSON polygon stored in env var |
Environment variables
All variables are set in Vercel's environment variable dashboard. The Neon integration adds the database variables automatically. Others must be added manually.
| Variable | Source | Notes |
|---|---|---|
| POSTGRES_PRISMA_URL | Neon (auto) | Pooled connection for runtime queries |
| POSTGRES_URL_NON_POOLING | Neon (auto) | Direct connection for migrations |
| NEXTAUTH_SECRET | Manual | Generate with openssl rand -base64 32. Mark sensitive. |
| NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME | Cloudinary dashboard | Public — safe to expose to browser |
| CLOUDINARY_API_KEY | Cloudinary dashboard | |
| CLOUDINARY_API_SECRET | Cloudinary dashboard | Mark sensitive |
| NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET | Cloudinary dashboard | Must be set to Unsigned in Cloudinary |
| GEOFENCE_POLYGON | Admin geofence editor | GeoJSON coordinates array as JSON string |
Local development
Prisma reads .env, not .env.local. The simplest solution is to put all variables in .env.local and create a symlink so Prisma can find them:
POSTGRES_PRISMA_URL="postgresql://..."
POSTGRES_URL_NON_POOLING="postgresql://..."
NEXTAUTH_SECRET="..."
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="..."
CLOUDINARY_API_KEY="..."
CLOUDINARY_API_SECRET="..."
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET="noticing-project"
GEOFENCE_POLYGON='[[...]]'ln -s .env.local .envBoth .env and .env.local are already in .gitignore — the symlink won't be committed. This means one file to maintain instead of two.
⚠️ If you ever need to create a temporary .env file for a one-off Prisma command (e.g. pushing schema to production), delete it immediately afterward — it will override the symlink.
Database schema changes
The schema lives in prisma/schema.prisma. After any change, push to the database using the Prisma v5 CLI (not v7+):
# Always use prisma@5.14.0 — v7 broke the schema format
npx prisma@5.14.0 db push⚠️ Adding a required column to a table with existing rows will fail unless you provide a @default() value. Always add defaults to new required fields.
Deployment pipeline
Every push to the main branch on GitHub triggers an automatic Vercel deployment. The build command is prisma generate && next build — Prisma generates the client before Next.js compiles.
To deploy manually from the CLI: vercel deploy --prod
Authentication notes
The app uses NextAuth v5 beta with a credentials provider. A few non-obvious details:
- The middleware (
src/middleware.ts) checks for the session cookie directly rather than usinggetToken— this avoids an Edge Runtime incompatibility withbcryptjs - The session cookie is named
__Secure-authjs.session-tokenin production andauthjs.session-tokenin development NEXTAUTH_URLdoes not need to be set — NextAuth v5 detects it automatically from the request
Media upload flow
Media never touches the Next.js server. The flow is:
POST to /api/media/sign with { type: "image" | "audio" }. Returns a Cloudinary signature, timestamp, and config.
POST to https://api.cloudinary.com/v1_1/{cloud}/image/upload with the file and signature. Images are auto-compressed to max 1200px wide.
The Cloudinary URL is included in the entry POST body. Only the URL string is stored in the database.
Project structure
src/
app/
api/
auth/[...nextauth]/ # NextAuth route handler
spots/ # GET list, POST create (geofence checked)
spots/[id]/ # GET, PATCH rename, DELETE
entries/ # GET list, POST create
entries/[id]/ # PATCH edit (48hr window), DELETE
entries/[id]/flag/ # POST flag for review
terms/ # GET list, POST create (admin)
terms/[id]/ # PATCH activate/archive, DELETE draft
terms/active/ # GET current active term
users/register/ # POST — whitelist checked
users/me/ # GET current session user
media/sign/ # POST — returns Cloudinary upload signature
admin/whitelist/ # GET, POST bulk add, DELETE
admin/users/[id]/ # DELETE student account
admin/flags/[id]/ # PATCH resolve flag
admin/ # Instructor dashboard
admin/geofence/ # Geofence polygon editor
map/ # Campus map with spot claiming
spot/[id]/ # Public journal page
new-noticing/ # Compose screen
browse/ # Public spot directory
login/ register/ # Auth pages
components/
NavClient # Top nav — uses useSession()
Lightbox # Full-screen image viewer
MarkdownBody # Lightweight markdown renderer
EntryEditor # Inline entry edit (48hr window)
SpotNameEditor # Inline spot rename
AbandonSpot # Delete spot with confirmation
TermManager # Admin term controls
WhitelistManager # Admin email whitelist
StudentList # Admin student overview
FlaggedEntries # Admin content moderation
lib/
prisma.ts # Singleton Prisma client
auth.ts # NextAuth config + requireAuth/requireAdmin helpers
cloudinary.ts # Upload signing, media limits
geofence.ts # Campus boundary check (Turf.js)
upload.ts # Client-side Cloudinary upload utility
middleware.ts # Route protection via session cookie check
prisma/
schema.prisma # Data model