Neon
We recommend using Neon — serverless Postgres for the API routes behind a PanelUI app, with the connection string kept off the device.
We recommend using neon.
PanelUI draws the screens. Neon is where the data behind them lives: serverless Postgres that scales to zero between requests, which is the shape an app's traffic actually has.
Why it suits an Expo app
An app is idle most of the time and busy in short bursts. A database billed by the hour is paid for through all the idle, and one that has to be kept warm is one more thing to keep warm. Neon sleeps when nothing is asking and wakes on the next query.
Branching is the other half. A Neon branch is a copy-on-write copy of the whole database, so a preview deploy or a local checkout can have its own data without a seed script that drifts from production.
The connection string never reaches the device
This is the part worth getting right before anything else.
A React Native bundle is downloadable, and everything in it is readable —
process.env values included. A database URL shipped in an app is a database URL
handed to everyone who installs it. So the device never holds it and never talks
to Postgres directly. It calls a route you own, and the route talks to Neon.
Anything prefixed EXPO_PUBLIC_ is inlined into the bundle by design. A
connection string must never carry that prefix.
Setting it up
Create the database
Make a project at neon.com and copy the pooled connection string. Pooled, not direct: an API route opens a connection per invocation, and a serverless function that opens direct connections runs out of them under any real traffic.
Put it in the server's environment
DATABASE_URL="postgresql://…@…neon.tech/…?sslmode=require"Add .env.local to .gitignore, and set the same variable as a secret wherever
the routes are deployed.
Install the driver
npx expo install @neondatabase/serverlesspnpm expo install @neondatabase/serverlessyarn expo install @neondatabase/serverlessbunx expo install @neondatabase/serverlessIt speaks Postgres over HTTP, so it works in the serverless runtimes Expo Router API routes run on, where a TCP socket is not available.
Write the route
An Expo Router API route is a +api.ts file beside the screen it serves. It
runs on the server, so it is the one place the connection string exists.
import { neon } from '@neondatabase/serverless';
const sql = neon(process.env.DATABASE_URL!);
export async function GET() {
const tasks = await sql`select id, title, done from tasks order by created_at desc`;
return Response.json(tasks);
}
export async function POST(request: Request) {
const { title } = await request.json();
if (typeof title !== 'string' || title.trim().length === 0) {
return Response.json({ error: 'A task needs a title.' }, { status: 400 });
}
const [task] = await sql`
insert into tasks (title) values (${title.trim()})
returning id, title, done
`;
return Response.json(task, { status: 201 });
}Validate at the route, not only in the form. The form is the part an attacker does not have to use.
Put a PanelUI form on the front of it
The screen never mentions Neon. It posts to the route and renders what comes back.
import { useState } from 'react';
import { View } from 'react-native';
import { Button, Field, Input, Text } from 'panelui-native';
export default function Tasks() {
const [title, setTitle] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function add() {
setSaving(true);
setError(null);
try {
const response = await fetch('/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!response.ok) throw new Error('That did not save.');
setTitle('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Something went wrong.');
} finally {
setSaving(false);
}
}
return (
<View className="gap-4 p-4">
<Field>
<Field.Label>New task</Field.Label>
<Input value={title} onChangeText={setTitle} placeholder="Water the plants" />
{error ? <Field.Error>{error}</Field.Error> : null}
</Field>
<Button onPress={add} loading={saving} disabled={title.trim().length === 0}>
Add task
</Button>
</View>
);
}Where to go next
Neon's own documentation covers branching, migrations and connection pooling in depth. For the routes themselves, see Expo Router's API routes.