Before you start
You'll need Node.js 20.19+ and the Neon CLI installed:
npm i -g neonCreate your project in a supported region
Object Storage, Functions, and the AI Gateway are in beta and currently available in AWS US East (Ohio) (aws-us-east-2) and AWS Europe (Frankfurt) (aws-eu-central-1), on new or existing projects in those regions, so use a project in one of them to follow this guide. Support is expanding toward all regions. Postgres works in any region. The three beta services are free to use during beta, subject to usage limits. The AI Gateway requires a paid plan; Object Storage and Functions work on any plan.
Create a Neon project
If you don't have a Neon account, sign up at console.neon.tech.
Create your project in AWS US East (Ohio) or AWS Europe (Frankfurt). Any path below works; the rest of this guide uses the Neon CLI, which you'll also use in step 3 to link the project and pull its credentials automatically.
Sign in and create the project:
Terminalneon login neon projects create --name my-backend --region-id aws-us-east-2Scaffold a Next.js app
Create a new Next.js project with TypeScript, Tailwind CSS, and the App Router. The
--yesflag accepts the remaining defaults without prompting.Terminalnpx create-next-app@latest my-backend --typescript --tailwind --app --eslint --yes cd my-backendDeclare your backend in neon.ts
A single
neon.tsfile declares your backend as code. You enable a capability there, runneon deploy, and Neon provisions it and writes its credentials into.env.local. You'll grow this file as you add capabilities.Work through the commands on the right:
neon linkconnects the directory to your project (writing a.neonfile);neon checkoutthen pins the branch and pulls its env vars, includingDATABASE_URL, into.env.local. If you haven't signed in to the CLI yet, runneon loginfirst. Run interactively,neon linkprompts you to createneon.ts; the--no-configflag skips that prompt so the explicitneon config initin step 2 stays in control.neon config initscaffoldsneon.tsand installs@neon/configand@neon/env. It includes a branch policy; keep it and add thepreviewblocks shown in later steps alongside it (those snippets omit the policy for brevity).
Postgres is already available on the branch, so
DATABASE_URLis in.env.localand you can build the data layer before adding any beta services.Terminalneon link --no-config # select the my-backend project; skip link's neon.ts prompt neon checkout main # pin the branch, pull env vars into .env.local neon config init # scaffold neon.ts, install @neon/config and @neon/envInstall dependencies
Install
drizzle-ormfor typed queries and@neondatabase/serverlessfor the HTTP driver (works in Node, edge, and serverless runtimes). Adddrizzle-kitas a dev dependency for the schema migration.Terminalnpm install drizzle-orm @neondatabase/serverless npm install -D drizzle-kitDefine your schema
Create a TypeScript schema for a
poststable. This example uses Drizzle for schema management, but you can use any ORM or migration tool. Drizzle uses this schema for both the migration and your type-safe queries. Each post has anauthorso you can tell them apart; this app is single-user, so the author defaults toanonymous.lib/db/schema.tsimport { bigint, boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; export const posts = pgTable('posts', { id: bigint('id', { mode: 'number' }) .primaryKey() .generatedByDefaultAsIdentity(), author: text('author').notNull().default('anonymous'), content: text('content').notNull(), isPublished: boolean('is_published').notNull().default(false), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow(), });drizzle.config.tsimport { loadEnvConfig } from '@next/env'; import { defineConfig } from 'drizzle-kit'; loadEnvConfig(process.cwd()); export default defineConfig({ schema: './lib/db/schema.ts', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL!, }, });drizzle-kitis a standalone CLI and doesn't read.env.localautomatically.loadEnvConfigmatches Next.js's env loading behavior so the migration step picks up the sameDATABASE_URLas the app.Push the schema and seed sample data
This example uses Drizzle's CLI to apply the schema, but you can use your ORM or migration tool's equivalent command.
drizzle-kit pushcreates the table directly from your schema. In production, you'd typically usedrizzle-kit generateanddrizzle-kit migratefor tracked migrations, but push is faster for a tutorial.Then seed three sample posts in the Neon Console SQL Editor: two published and one draft, so the
where(eq(posts.isPublished, true))filter on the posts page has something visible to do.Terminalnpx drizzle-kit pushOpen your project in the Neon Console, go to Postgres database > SQL Editor, and run:
INSERT INTO posts (author, content, is_published) VALUES ('Dana Smith', 'Postgres branching lets you copy your whole database in seconds.', true), ('Alex Lopez', 'Serverless compute scales to zero when idle, so you only pay for what you use.', true), ('anonymous', 'This draft is hidden. Flip is_published to true in the SQL editor to see it appear.', false);List posts in a Server Component
Create the Drizzle client and a
/postspage. The page is a Server Component, so the Drizzle query runs on the server at request time.dynamic = 'force-dynamic'keeps the data fresh on every request.lib/db/client.tsimport { drizzle } from 'drizzle-orm/neon-http'; import { neon } from '@neondatabase/serverless'; import * as schema from './schema'; const sql = neon(process.env.DATABASE_URL!); export const db = drizzle(sql, { schema });app/posts/page.tsximport { db } from '@/lib/db/client'; import { posts } from '@/lib/db/schema'; import { desc, eq } from 'drizzle-orm'; export const dynamic = 'force-dynamic'; export default async function PostsPage() { const allPosts = await db .select() .from(posts) .where(eq(posts.isPublished, true)) .orderBy(desc(posts.createdAt)) .limit(10); return ( <main className="p-8"> <h1 className="mb-4 text-2xl font-bold">Published posts</h1> <ul className="space-y-2"> {allPosts.map((post) => ( <li key={post.id} className="rounded border p-3"> <p>{post.content}</p> <p className="mt-1 text-xs text-gray-500">by {post.author}</p> </li> ))} </ul> </main> ); }Add Object Storage and upload images
Add an
imagesbucket to yourneon.tsand runneon deploy. Neon provisions the bucket and injects the S3-compatible credentials (AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_ENDPOINT_URL_S3,AWS_REGION) into.env.local.Then add a client upload page that submits the file to a Server Action. The Files SDK
neonadapter reads the injectedAWS_*variables and configures the endpoint for you, so there's no client setup.The action returns the object's public URL. In a real app you'd store that URL on a row, for example an
image_urlcolumn onposts, so a record can reference its file. This step keeps the upload standalone to focus on the storage flow.note
neon deploymerges credentials into.env.localwithout discarding your own entries.neon.tsimport { defineConfig } from '@neon/config/v1'; export default defineConfig({ // branch policy omitted for brevity; keep the one from `neon config init` preview: { buckets: { images: { access: 'public_read' }, }, }, });Terminalneon deploy npm install files-sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/s3-presigned-postapp/upload/actions.ts'use server'; import { Files } from 'files-sdk'; import { neon } from 'files-sdk/neon'; const files = new Files({ adapter: neon({ bucket: 'images' }) }); export async function uploadImage( _prev: { error?: string; publicUrl?: string } | null, formData: FormData, ) { const file = formData.get('file') as File | null; if (!file) return { error: 'No file selected' }; const bytes = new Uint8Array(await file.arrayBuffer()); const key = `${Date.now()}-${file.name}`; await files.upload(key, bytes, { contentType: file.type }); // The images bucket is public_read, so the object is served directly. const publicUrl = `${process.env.AWS_ENDPOINT_URL_S3}/images/${key}`; return { publicUrl }; }app/upload/page.tsx'use client'; import { useActionState } from 'react'; import { uploadImage } from './actions'; export default function UploadPage() { const [state, formAction, isPending] = useActionState(uploadImage, null); return ( <main className="p-8"> <h1 className="mb-4 text-2xl font-bold">Upload an image</h1> <form action={formAction} className="mb-4"> <input name="file" type="file" accept="image/*" required /> <button type="submit" disabled={isPending} className="mt-3 rounded-md bg-indigo-500 px-3 py-1.5 text-sm font-semibold text-white disabled:opacity-50" > {isPending ? 'Uploading...' : 'Upload'} </button> </form> {state?.error && <p className="text-sm text-red-500">{state.error}</p>} {state?.publicUrl && ( <p className="text-sm text-gray-500 break-all">Uploaded: {state.publicUrl}</p> )} </main> ); }Write the Neon Function
Now add the piece that makes this a full backend: a Neon Function that runs AI on long-lived compute next to your database. It's a normal Hono app with two routes:
POST /generatewrites a post from a topic with the AI Gateway.POST /assistantstreams a tool-calling assistant that answers questions about your posts. The tool loop queries Postgres and runs in-process, so it isn't cut off by a serverless request limit.
Install the function's dependencies, then create
functions/posts.ts.Connect with a pooled `pg` client
A function keeps running across requests, so open a
pgPoolonce at module scope and reuse it. Don't use@neondatabase/serverlessinside a function; it's built for short-lived, per-request invocations. See Connecting to Postgres.Terminalnpm install hono pg ai@^7 @neon/ai-sdk-provider @neon/functions zod npm install -D @types/pgfunctions/posts.tsimport { Hono } from 'hono'; import { cors } from 'hono/cors'; import { attachDatabasePool } from '@neon/functions'; import { Pool } from 'pg'; import { neon } from '@neon/ai-sdk-provider'; import { streamText, generateText, convertToModelMessages, tool, stepCountIs } from 'ai'; import { z } from 'zod'; // Reused across requests. Use a pooled pg client, not the serverless driver. const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 }); // Keep an idle disconnect from crashing the isolate. attachDatabasePool(pool); const app = new Hono(); // The assistant is called from the browser, so allow cross-origin requests. app.use('/*', cors()); // One-shot generation: create a post from a topic and save it. app.post('/generate', async (c) => { const { topic, author = 'anonymous' } = await c.req.json(); const { text } = await generateText({ model: neon('gpt-5-nano'), prompt: `Write a 2 sentence post about the following topic. Just send the post content without any additional text: ${topic}`, }); const { rows } = await pool.query( 'insert into posts (author, content, is_published) values ($1, $2, true) returning *', [author, text], ); return c.json(rows[0]); }); // Streaming assistant: answers questions about the posts, using a tool that // queries Postgres. The tool loop runs in-process on Neon compute. app.post('/assistant', async (c) => { const { messages } = await c.req.json(); const result = streamText({ model: neon('gpt-5-mini'), system: "You are a helpful assistant that answers questions about the user's blog posts. Use the queryPosts tool to look them up.", messages: await convertToModelMessages(messages), tools: { queryPosts: tool({ description: 'Fetch the most recent published posts from the database.', inputSchema: z.object({ limit: z.number().default(10).describe('How many posts to fetch.'), }), execute: async ({ limit }) => { const { rows } = await pool.query( 'select author, content, created_at from posts where is_published = true order by created_at desc limit $1', [limit], ); return rows; }, }), }, stopWhen: stepCountIs(5), // Disable telemetry: the function runtime's tracing conflicts with the // AI SDK's streaming spans. experimental_telemetry: { isEnabled: false }, }); return result.toUIMessageStreamResponse(); }); export default app;Deploy the function
Declare the function and the AI Gateway in
neon.ts, thenneon deploy. Neon builds the function, gives it a public URL, and injects the AI Gateway credentials (NEON_AI_GATEWAY_TOKEN,NEON_AI_GATEWAY_BASE_URL) so the@neon/ai-sdk-providerinside the function needs no configuration.Copy the
invocation_urlfrom theneon functions get postsoutput into.env.localasNEXT_PUBLIC_POSTS_FN_URL(theNEXT_PUBLIC_prefix exposes it to the browser, which calls the assistant directly).NEXT_PUBLIC_variables are read at build time, so restart the dev server if it's already running.A Neon Function has its own URL, so the browser calls it directly. That keeps a long stream off your host's serverless timeout.
First call after a deploy
The first AI Gateway call on a new branch can return a
403while the credential propagates. It clears within a few seconds, so retry.neon.tsimport { defineConfig } from '@neon/config/v1'; export default defineConfig({ // branch policy omitted for brevity; keep the one from `neon config init` preview: { aiGateway: true, buckets: { images: { access: 'public_read' }, }, functions: { posts: { name: 'posts assistant', source: './functions/posts.ts' }, }, }, });Terminalneon deploy neon functions get posts # prints the invocation_urlslug posts name posts assistant invocation_url https://<branch_id>-posts.compute.<cell>.us-east-2.aws.neon.tech/.env.localNEXT_PUBLIC_POSTS_FN_URL=https://<branch_id>-posts.compute.<cell>.us-east-2.aws.neon.tech/Call the function from your app
Wire two pages to the function:
/generatecallsPOST /generatefrom a Server Action (server-to-server, so no CORS). Good for a short, one-shot generation./assistantstreams fromPOST /assistantdirectly in the browser with the AI SDK'suseChathook. Calling the function directly keeps the stream off any serverless host that would time it out.
Terminalnpm install @ai-sdk/reactAuthenticate the function in production
The function has a public URL and no auth check, which is fine for this tutorial. Before shipping, gate it with an API key or a JWT. See Neon Functions authentication.
app/generate/actions.ts'use server'; export async function generatePost( _prev: { error?: string; content?: string } | null, formData: FormData, ) { const topic = formData.get('topic') as string; const res = await fetch(`${process.env.NEXT_PUBLIC_POSTS_FN_URL}generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ topic, author: 'anonymous' }), }); if (!res.ok) return { error: 'Generation failed' }; const post = await res.json(); return { content: post.content as string }; }app/generate/page.tsx'use client'; import { useActionState } from 'react'; import { generatePost } from './actions'; export default function GeneratePage() { const [state, formAction, isPending] = useActionState(generatePost, null); return ( <main className="p-8"> <h1 className="mb-4 text-2xl font-bold">Generate a post</h1> <form action={formAction} className="mb-4 flex gap-2"> <input name="topic" placeholder="Topic" required className="rounded border px-2 py-1" /> <button type="submit" disabled={isPending} className="rounded-md bg-indigo-500 px-3 py-1.5 text-sm font-semibold text-white" > {isPending ? 'Generating...' : 'Generate'} </button> </form> {state?.error && <p className="text-sm text-red-500">{state.error}</p>} {state?.content && <p className="rounded border p-3">{state.content}</p>} </main> ); }app/assistant/page.tsx'use client'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { useState } from 'react'; export default function AssistantPage() { const [input, setInput] = useState(''); const { messages, sendMessage, status } = useChat({ transport: new DefaultChatTransport({ api: `${process.env.NEXT_PUBLIC_POSTS_FN_URL}assistant`, }), }); return ( <main className="p-8"> <h1 className="mb-4 text-2xl font-bold">Ask about your posts</h1> <div className="mb-4 space-y-2"> {messages.map((m) => ( <div key={m.id} className="rounded border p-3"> <span className="font-medium">{m.role}: </span> {m.parts.map((p, i) => (p.type === 'text' ? <span key={i}>{p.text}</span> : null))} </div> ))} </div> <form onSubmit={(e) => { e.preventDefault(); if (input.trim()) { sendMessage({ text: input }); setInput(''); } }} className="flex gap-2" > <input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Ask about your posts" className="flex-1 rounded border px-2 py-1" /> <button type="submit" disabled={status !== 'ready'} className="rounded-md bg-indigo-500 px-3 py-1.5 text-sm font-semibold text-white" > Send </button> </form> </main> ); }Run the app
Start the dev server, then open the URL it prints. Try each page:
/postslists the seeded posts./generategenerates a post and saves it./assistantchats about your posts, streaming from the function./uploaduploads an image to your bucket.
To iterate on the function locally, run
neon dev, which serves it with the same injected Neon variables it gets in production.Terminalnpm run dev
What you built
You now have a Next.js app where:
- Published posts are queried server-side via Drizzle with full TypeScript types
- Images upload to a Neon Storage bucket through a Server Action and the Files SDK
- A Neon Function generates posts and runs a streaming, tool-calling AI assistant on compute next to your database
- The whole backend is declared in one
neon.tsand provisioned withneon deploy, which injects every credential into.env.local - The Next.js app deploys to any App Router host that supports server actions, including Vercel, Netlify, and self-hosted Node, while the long-running AI lives on the Neon Function
Next steps
- Make it multi-user with Managed Better Auth: add
auth: truetoneon.tsfor Managed Better Auth, gate the pages with a session, and verify the caller's JWT inside the function (Neon Functions authentication). See the Auth quickstart. - Go deeper on Functions: hold open WebSockets and SSE or build a fuller AI agent on the same function.
- Branch your whole backend:
neon checkoutforks the database, buckets, and function together for preview environments. See Branching. - Generated migrations: for tracked schema changes, switch from a direct push to generated migrations. If you're using Drizzle, that means moving from
drizzle-kit pushtodrizzle-kit generate; other ORMs and migration tools offer an equivalent.
Need help?
Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.








