Moving JSONShare off Vercel and MongoDB: Postgres JSONB and Next.js 16
- Published on
- Reading time
- 12 mins read
On this page

JSONShare is my online JSON studio: an interactive tree inspector, a syntax-error auto-fixer, recursive alphabetical key sorting, VS Code-style themes, a diff view for comparing two documents, and a shareable URL for whatever you paste. The code is public at zgrgrcn/jsonshare.dev.
Until this month, its last commit was from September 2023: Next.js 13.4.19 on Vercel, with documents in a MongoDB Atlas cluster. When I came back to it, that cluster no longer existed. Its SRV record returned NXDOMAIN, so the app had nowhere to store anything. There was no error tracking either, so if it broke for a visitor, nobody would have known.
On 10 September that turned into four commits and a new Coolify app: PostgreSQL instead of MongoDB, a UI cleanup, Sentry, and Next.js 16 with React 19. Here is each step, with the real code.
Why move, and what there was to move
My side projects run on a single Coolify server (Ditching PaaS has the setup), next to a shared PostgreSQL 18 instance. With the Atlas cluster gone, the question wasn't how to get data out of MongoDB, but where JSONShare should live next: with everything else.
That makes this a short migration story. Unlike the Couchbase to PostgreSQL migration I wrote about earlier, there's no backfill, dual write or shadow read. There was nothing to copy, and the repo has no import script. Share links from before the move pointed at MongoDB ObjectIds (24 hex characters), and they were not carried over.
What I could do was make them fail cleanly. New IDs are UUIDs, and every incoming ID is shape-checked before it gets near the database:
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isValidId(id: string | undefined): boolean {
return typeof id === "string" && UUID_PATTERN.test(id);
}
Without that check, an old ObjectId would reach a uuid column, Postgres would reject it with invalid input syntax for type uuid, and the route would answer 500. With it, the API returns 404 and the share page says "This JSON was not found".
Why a jsonb column is enough
JSONShare's persistence is as simple as it gets: insert a document, read it back by ID, overwrite it on update. Nothing queries inside a document. That's a key-value workload, and it doesn't need a document database.
PostgreSQL has two JSON types. json stores an exact copy of the input text and re-parses it whenever a function processes it. jsonb stores a decomposed binary form: slightly slower to write, faster to process, and indexable. The PostgreSQL docs recommend jsonb for most applications.
The catch for a JSON tool is that jsonb normalizes. It drops insignificant whitespace, doesn't keep key order, and keeps only the last value of a duplicate key:
SELECT '{"name": "jsonshare", "id": 1}'::json;
-- {"name": "jsonshare", "id": 1}
SELECT '{"name": "jsonshare", "id": 1}'::jsonb;
-- {"id": 1, "name": "jsonshare"}
SELECT '{"a": 1, "a": 2}'::jsonb;
-- {"a": 2}
JSONShare's editor submits parsed JSON, so whitespace and duplicates are gone before the request is sent. Key order is the visible effect: a shared document comes back in jsonb's storage order, not as typed. If your app must return the exact text, keep the original in a text or json column.
GIN indexes on jsonb speed up containment, key-exists and jsonpath queries. JSONShare only reads by primary key, so it has no use for one.
The schema
The table lives in its own database, owned by its own role, on the shared server; how I split one Postgres between projects is covered in One Postgres, many projects. The repo doesn't ship migrations, so treat this DDL as a sketch of the shape the code relies on, not a copy of production:
CREATE TABLE documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
body jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
What the code does depend on: the database generates the ID (inserts never send one and read it back with RETURNING id), the app never writes created_at, and updates set updated_at = now(). body holds the same object the MongoDB documents kept under their body field (the editor's jsonData plus a version), so the table is the old collection with _id promoted to a real uuid primary key.
One note on IDs: JSONShare has no accounts, so whoever has the link can open and update the document. Random v4 UUIDs from gen_random_uuid() suit that; PostgreSQL 18's new uuidv7() helps index locality but embeds a timestamp in every ID.
Swapping the data layer
The MongoDB version needed two extra env vars for the database and collection names, and answered its error cases with HTTP 200 and an error field. The replacement is a small pg module with a single env var, DATABASE_URL:
import { Pool } from "pg";
// Next.js reloads modules in dev, so keep a single pool on the global object
const globalForPg = global as unknown as { jsonsharePool?: Pool };
function getPool(): Pool {
if (!globalForPg.jsonsharePool) {
globalForPg.jsonsharePool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
});
}
return globalForPg.jsonsharePool;
}
export async function insertDocument(body: unknown): Promise<string> {
const { rows } = await getPool().query<{ id: string }>(
"INSERT INTO documents (body) VALUES ($1) RETURNING id",
[JSON.stringify(body)],
);
return rows[0].id;
}
export async function findDocument(id: string): Promise<any | null> {
const { rows } = await getPool().query<{ body: any }>(
"SELECT body FROM documents WHERE id = $1",
[id],
);
return rows.length > 0 ? rows[0].body : null;
}
updateDocument runs UPDATE documents SET body = $1, updated_at = now() WHERE id = $2 and reports whether a row matched, so the route can answer 404.
What I'd reuse in other apps:
- One pool, created lazily. Without the
globalcache, every dev-mode recompile would open a new pool. And because the pool is created on the first query,next buildnever touches the database. - Stringify explicitly. node-postgres serializes plain objects to JSON on its own, but turns JavaScript arrays into Postgres array literals, which a
jsonbparameter rejects withinvalid input syntax for type json. - Reads come back parsed. node-postgres runs
jsonandjsonbcolumns throughJSON.parseby default. - Real status codes: 400 when the body isn't JSON, 404 for an unknown or malformed ID, 500 when the query fails.
The frontend didn't change for the swap. The POST route still answers with an _id field, which is what the Save button reads to redirect to the new share URL.
Next.js 13.4 to 16 in one commit
The upgrade moved next from 13.4.19 to 16.3.4 and React from 18 to 19, and pulled along @nextui-org/react 2.6.11 for React 19 (which needs framer-motion 11) and ESLint 9, which eslint-config-next 16 requires. npm now marks @nextui-org/react as deprecated in favour of @heroui/react.
Route params are Promises
Next.js 15 made params asynchronous in pages, layouts and route handlers, with a temporary synchronous fallback, and Next.js 16 removed the fallback. In a route handler it's a type change and an await:
export async function GET(req: Request, context: { params: Promise<{ jsonId: string }> }) {
const { jsonId } = await context.params;
if (!isValidId(jsonId)) {
return NextResponse.json({ error: 'JSON not found' }, { status: 404 });
}
// ...findDocument, then 404 or 500 as above
}
The shared-document page is a client component, so it can't await. React 19's use() unwraps the Promise instead:
'use client';
import { use } from 'react';
export default function SharedJsonPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = use(params);
const jsonId = slug?.[0] ?? '';
// ...fetch the document and render the editor
}
Config, tooling and one runtime fix
- Instrumentation is stable. On 13.4,
instrumentation.tsneededexperimental.instrumentationHook: true. The Sentry commit added the flag and the upgrade removed it eight minutes later, since instrumentation is stable as of Next.js 15. - Next.js edits
tsconfig.jsonitself. The diff contains Next 16's own changes:jsxwent frompreservetoreact-jsx, and.next/dev/types/**/*.tsjoinedinclude, becausenext devnow writes to its own.next/devdirectory. - jsoneditor needed a guard. The old viewer loaded jsoneditor via dynamic
import(). After the upgrade, its container could already be detached from the DOM when the import resolved, and jsoneditor crashed measuring it. The fix: skip init unless both containers are stillisConnected, and wrap setup and teardown intry/catch. That page was rewritten a week later.
The commit message lists the local checks: save, load, update, a 404 for an invalid ID, and a clean browser console.
Two leftovers are still in the repo as I write this. The lint script still calls next lint, which Next 16 removed; next build no longer lints, so the deploy never complains. And the Sentry config still sets disableLogger, which @sentry/nextjs 10 deprecates in favour of a webpack-only option, while Next 16 builds with Turbopack by default.
Sentry, tunneled through the app
@sentry/nextjs 10 brought error tracking and a user feedback button. The browser SDK starts in instrumentation-client.ts, a file convention added in Next.js 15.3:
import * as Sentry from '@sentry/nextjs';
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
tracesSampleRate: 0.1,
integrations: [
Sentry.feedbackIntegration({
colorScheme: 'system',
showBranding: false,
triggerLabel: 'Feedback',
formTitle: 'Send us a message',
// ...button label and placeholder text
isNameRequired: false,
isEmailRequired: false,
}),
],
});
}
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
On the server, instrumentation.ts imports the Node or edge config based on NEXT_RUNTIME and re-exports Sentry's captureRequestError as Next's onRequestError hook.
- The
if (dsn)guard keeps Sentry inert until the variable exists, so local development and forks work without it. tracesSampleRate: 0.1only samples performance traces; errors followsampleRate, which defaults to 1.0.- Name and email are optional in the feedback form, and it offers a screenshot attachment by default.
onRequestErroronly sees errors that escape to Next.js. The API routes catch their own database errors andconsole.errorthem, so those stay in the container logs and never become Sentry issues.
The tunnel is one option in the build config:
module.exports = withSentryConfig(nextConfig, {
// org and project settings omitted
silent: true,
disableLogger: true,
tunnelRoute: '/monitoring',
});
withSentryConfig implements tunnelRoute as a Next.js rewrite. The browser SDK posts its envelopes to /monitoring?o=<org id>&p=<project id> on JSONShare's own domain, and the Next.js server forwards them to Sentry's ingest endpoint, so blockers that match Sentry's domain never see a request. It's a plain rewrite, so next start on Coolify handles it. Caveats: it only works with DSNs on Sentry's hosted service, and any middleware (proxy in Next 16) must keep the tunnel path out of its matcher. tunnelRoute: true generates a random path per build instead of a fixed one.
The DSN ends up in the client bundle, and that's fine: Sentry's docs say a DSN only allows submitting new events, with no read access.
Deploying on Coolify
There's no Dockerfile. The Coolify app tracks main on the public GitHub repo, builds with Nixpacks, exposes port 3000 and redeploys on every push. Nixpacks runs npm ci, npm run build and npm run start, which is plain next start on its default port 3000.
Two build-time inputs need attention:
- The Node version. Next.js 16 requires Node.js 20.9 or newer, and Nixpacks defaults to Node 18. The repo doesn't pin a version, so it's pinned with
NIXPACKS_NODE_VERSIONin Coolify, as a build variable. Anengines.nodefield or an.nvmrcwould keep the pin in git instead. NEXT_PUBLIC_SENTRY_DSNmust exist during the build, because Next.js inlinesNEXT_PUBLIC_values at build time. In Coolify that's the "Build Variable" toggle (on by default), and changing the value means a rebuild, not a restart.
At runtime the app needs exactly one variable:
DATABASE_URL=postgres://APP_ROLE:APP_PASSWORD@postgres-internal:5432/APP_DB
postgres-internal stands in for the Postgres container's name: the app and the database share Coolify's internal Docker network, so the connection never leaves the server. The app reads DATABASE_URL only at request time, so it doesn't need to be a build variable. Running several apps on one Coolify server has more traps, collected in Coolify gotchas.
Takeaways
- Find out what's actually left before planning a migration. Here the data step was empty; the real work was a new home and making dead links fail with a 404 instead of a 500.
- For "store JSON, fetch by ID", a
jsonbcolumn is enough. Just remember it normalizes key order, whitespace and duplicate keys. - A three-major-version jump can be one commit when the app is small. Afterwards, look for scripts that broke without anyone noticing, like
next lint. - Tunnel browser error reporting through your own domain, and treat
NEXT_PUBLIC_values as build inputs. - Pin the Node version your builder uses. Builder defaults lag behind framework requirements.
JSONShare is back at jsonshare.ozgurgurcan.com, on the same server as everything else.