src/lib/profiles.ts reads and writes JSON files under profiles/*.json.
Vercel runs each serverless function in an ephemeral container — filesystem writes
disappear when the function exits. Any profile created in production is lost on
the next cold start. The app must migrate to a real persistent database before
the deployment is meaningful.
Why Neon / Vercel Postgres
- Free tier: 0.5 GB storage, 60 compute-hours/month — plenty for launch
- Serverless-native HTTP transport — no persistent connection pool needed
- One-click attachment from the Vercel dashboard; env vars auto-injected
@vercel/postgrespackage wraps it with asqltagged-template helper
Schema — single profiles table, JSONB payload
Store the entire UserProfile object as a JSONB column called data. This
keeps the DB schema stable even as UserProfile gains new optional fields.
CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);| File | Action |
|---|---|
package.json |
add @vercel/postgres |
src/lib/profiles.ts |
full rewrite — sync fs → async SQL |
src/app/api/profiles/route.ts |
add await to getProfile + saveProfile calls |
src/app/[profileId]/page.tsx |
add await to both getProfile calls |
.gitignore |
remove /profiles/*.json line |
profiles/.gitkeep |
delete (directory no longer needed) |
.env.local (new, gitignored) |
add POSTGRES_URL for local dev |
cd /Users/michael/Documents/code/orbit-app
npm install @vercel/postgres@vercel/postgres reads the POSTGRES_URL (or POSTGRES_URL_NON_POOLING)
environment variable that Vercel injects when a Postgres store is attached.
Option A — Vercel dashboard (recommended for deploy)
- Go to vercel.com → New Project → import
DevMikeRoberts/orbit-app→ Deploy - After the first deploy, open the project → Storage tab → Connect Store
- Create a new Postgres store (free tier) or attach an existing one
- Vercel injects
POSTGRES_URL,POSTGRES_PRISMA_URL,POSTGRES_URL_NON_POOLING,POSTGRES_USER,POSTGRES_HOST,POSTGRES_PASSWORD,POSTGRES_DATABASEas environment variables - Trigger a redeploy so the new env vars are picked up
Option B — pull env vars for local dev (requires Vercel CLI)
npm install -g vercel
vercel login
vercel link # point local dir at the DevMikeRoberts/orbit-app project
vercel env pull .env.local # writes all env vars to .env.local.env.local is already in .gitignore — never commit it.
Option C — local Postgres for offline dev Install Postgres locally (or use Docker), create a database, and set:
# .env.local
POSTGRES_URL=postgresql://localhost:5432/orbit_dev
Run this SQL exactly once — in the Neon console (Storage → Open in Neon),
in the Vercel Postgres query editor, or via psql $POSTGRES_URL:
CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
data JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS profiles_created_at_idx ON profiles (created_at DESC);Do not auto-run DDL inside route handlers on every request — run it once
and leave it. The IF NOT EXISTS guards make it idempotent if ever re-run.
Replace the entire file content with:
import { sql } from '@vercel/postgres';
import type { UserProfile } from '@/types/profile';
export async function getProfile(id: string): Promise<UserProfile | null> {
try {
const { rows } = await sql`SELECT data FROM profiles WHERE id = ${id}`;
return rows.length > 0 ? (rows[0].data as UserProfile) : null;
} catch {
return null;
}
}
export async function saveProfile(profile: UserProfile): Promise<void> {
await sql`
INSERT INTO profiles (id, data)
VALUES (${profile.id}, ${JSON.stringify(profile)})
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data
`;
}
export async function listProfileIds(): Promise<string[]> {
const { rows } = await sql`SELECT id FROM profiles ORDER BY created_at DESC`;
return rows.map((r) => r.id as string);
}Key changes vs the old file:
- All three functions are now
asyncand return Promises getProfileuses a parameterized query — no injection risksaveProfileusesINSERT … ON CONFLICT DO UPDATE(upsert) so it works for both creates and future updateslistProfileIdsorders bycreated_at DESCso newest profiles surface first- No filesystem imports (
fs,path) remain
Two changes:
- The collision-check
whileloop mustawait getProfile saveProfile(profile)must be awaited
Full updated file:
import { NextRequest } from "next/server";
import { saveProfile, getProfile } from "@/lib/profiles";
import { generateProfileId } from "@/lib/hash";
import type { UserProfile } from "@/types/profile";
export async function POST(req: NextRequest) {
let body: Partial<UserProfile>;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.name || typeof body.name !== "string") {
return Response.json({ error: "name is required" }, { status: 400 });
}
if (!body.email || typeof body.email !== "string") {
return Response.json({ error: "email is required" }, { status: 400 });
}
let id = generateProfileId(body.name);
while (await getProfile(id)) {
id = generateProfileId(body.name);
}
const profile: UserProfile = {
id,
name: body.name,
handle: body.handle ?? `@${body.name.toLowerCase().replace(/\s+/g, "")}`,
email: body.email,
title: body.title,
resumeUrl: body.resumeUrl,
liveLocation: body.liveLocation,
locations: body.locations ?? [],
projects: body.projects ?? [],
createdAt: new Date().toISOString(),
};
await saveProfile(profile);
return Response.json({ id, url: `/${id}` }, { status: 201 });
}The only diff from the current file: while (await getProfile(id)) and
await saveProfile(profile). Everything else is identical.
getProfile is called twice — once in generateMetadata, once in Page.
Both are already inside async functions, so just add await:
import { notFound } from "next/navigation";
import { getProfile } from "@/lib/profiles";
import ProfilePage from "@/components/ProfilePage";
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ profileId: string }>;
}): Promise<Metadata> {
const { profileId } = await params;
const profile = await getProfile(profileId); // ← await added
if (!profile) return {};
return {
title: `Orbit — ${profile.name}`,
description: profile.title
? `${profile.name} · ${profile.title}`
: `${profile.name}'s interactive globe portfolio`,
};
}
export default async function Page({
params,
}: {
params: Promise<{ profileId: string }>;
}) {
const { profileId } = await params;
const profile = await getProfile(profileId); // ← await added
if (!profile) notFound();
return <ProfilePage profile={profile} />;
}# Remove the profiles directory entirely (no longer needed)
rm -rf /Users/michael/Documents/code/orbit-app/profilesIn .gitignore, remove this line:
/profiles/*.json
cd /Users/michael/Documents/code/orbit-app
# Type check — should be clean; the only risk is the sync→async change
npx tsc --noEmit
# Full build — POSTGRES_URL must be set; if not, skip this locally
# and rely on Vercel's build step instead
POSTGRES_URL=postgresql://placeholder/placeholder npm run build@vercel/postgres does not make a real DB connection at build time for static
pages, so a dummy POSTGRES_URL is enough to let the build complete. The
/[profileId] route is dynamic (ƒ) so it is not statically generated.
-
Push the completed changes to
mainon GitHub:git add src/lib/profiles.ts src/app/api/profiles/route.ts \ src/app/\[profileId\]/page.tsx package.json package-lock.json \ .gitignore git commit -m "migrate profile storage from filesystem to Vercel Postgres" git push origin main -
Import the project on vercel.com if not already done:
- New Project → import
DevMikeRoberts/orbit-app→ Deploy
- New Project → import
-
Attach Postgres (Storage tab → Connect Store → Postgres):
- Creates a Neon database and sets all
POSTGRES_*env vars automatically - Trigger a redeploy so the new env vars are live
- Creates a Neon database and sets all
-
Run the DDL once in the Neon console (Storage → Open in Neon → SQL Editor):
CREATE TABLE IF NOT EXISTS profiles ( id TEXT PRIMARY KEY, data JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS profiles_created_at_idx ON profiles (created_at DESC);
-
Verify: visit
{deploy-url}/create→ complete the wizard → confirm the success screen shows a URL → visit that URL → globe loads with your data.
-
npx tsc --noEmitpasses with zero errors after the changes -
npm run buildcompletes (with a valid or dummyPOSTGRES_URL) -
POST /api/profilesreturns201with{ id, url }in local dev -
GET /{id}renders the profile globe (not 404) in local dev - Restarting the dev server (
Ctrl-C+npm run dev) does not lose the profile — confirms data is in the DB, not memory/filesystem - Vercel preview deploy: create a profile and reload the URL — profile persists
- Rate limiting on
POST /api/profiles(anyone can create profiles) - Profile editing or deletion (no
PUT/DELETEroutes) - Auth / ownership — profiles are public and anonymous once created
- Pagination on
listProfileIds(not called from any route yet)
These can be addressed in follow-on work once the storage layer is solid.