Build a full backend with Next.js and Neon
Before you start. You'll need Node.js 20.19+ and the Neon CLI installed Object Storage, Functions, and the AI Gateway are currently available in AWS US East (Ohio) (aws us east 2), AWS US East (N. Vir...
Before you start
Section titled “Before you start”You'll need Node.js 20.19+ and the Neon CLI installed:
npm i -g neonCreate 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), US East (N. Virginia), Europe (Frankfurt), or Asia Pacific (Singapore). 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:
Bash neon login neon projects create --name my-backend --region-id aws-us-east-2Create an API key, export it, then create the project:
Bash export NEON_API_KEY=neon_... curl -X POST https://console.neon.tech/api/v2/projects \ -H "Authorization: Bearer $NEON_API_KEY" \ -H "Content-Type: application/json" \ -d '{"project": {"name": "my-backend", "region_id": "aws-us-east-2"}}'In the Neon Console, click New Project, name it
my-backend, and select the AWS US East (Ohio) region.Scaffold 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.Bash npx 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 the service keys 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 the other services.Bash neon 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.Bash npm 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.ts import { 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.ts import { 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.Bash npx drizzle-kit pushOpen your project in the Neon Console, go to Postgres database > SQL Editor, and run:
SQL 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.ts import { 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.tsx import { 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.neon.ts import { defineConfig } from '@neon/config/v1'; export default defineConfig({ // branch policy omitted for brevity; keep the one from `neon config init` buckets: { images: { access: 'public_read' }, }, });Bash neon 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.Bash npm install hono pg ai@^7 @neon/ai-sdk-provider @neon/functions zod npm install -D @types/pgfunctions/posts.ts import { 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({ // Open-weight models are enabled by default. Frontier models (for example OpenAI GPT) need access requested in the Console first. model: neon('gpt-oss-20b'), 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('meta-llama-3-3-70b-instruct'), 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 }) => { // Cast created_at to text: a raw Date in the tool result fails the AI SDK // message schema when the result is fed back to the model on the next step. const { rows } = await pool.query( 'select author, content, created_at::text 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.
neon.ts import { defineConfig } from '@neon/config/v1'; export default defineConfig({ // branch policy omitted for brevity; keep the one from `neon config init` aiGateway: true, buckets: { images: { access: 'public_read' }, }, functions: { posts: { name: 'posts assistant', source: './functions/posts.ts' }, }, });Bash neon 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.local NEXT_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.
Bash npm install @ai-sdk/reactapp/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.Bash npm run devAdd Function Triggers
Your app calls the function on request. A Function Trigger lets Neon call it for you, so work runs on a schedule or on an event, even when the compute is scaled to zero. Add two, both pointing at the same
postsfunction:- A schedule trigger writes a post every day, like a cron job.
- A
storage_object_createdtrigger runs when an image is uploaded to yourimagesbucket.
The function has no root route, so each trigger targets its own path (
/daily,/on-upload) that matches a new route. Neon sends aPOSTwith a JSON envelope; each handler reads what it needs fromdataand checks theX-Neon-Trigger-Invocation-Idheader to confirm the call came from Neon. See the overview for the payload and branching behavior, and Schedule a function for cron syntax.Add two routes to
functions/posts.ts, beforeexport default app:functions/posts.ts // Schedule trigger: generate and publish a post. Reuses the pool and model above. app.post('/daily', async (c) => { if (!c.req.header('x-neon-trigger-invocation-id')) return c.json({ error: 'not a trigger call' }, 403); const { text } = await generateText({ model: neon('kimi-k3'), prompt: 'Write a 2 sentence post sharing a Postgres tip. Send only the post content.', }); await pool.query( 'insert into posts (author, content, is_published) values ($1, $2, true)', ['scheduler', text], ); return c.json({ ok: true }); }); // Object-storage trigger: runs when an image is uploaded. app.post('/on-upload', async (c) => { if (!c.req.header('x-neon-trigger-invocation-id')) return c.json({ error: 'not a trigger call' }, 403); const { data } = await c.req.json(); console.log(`image uploaded: ${data.bucket_name}/${data.object_key}`); return c.json({ ok: true }); });Declare both triggers in
neon.ts:neon.ts // add to your existing defineConfig({ ... }) triggers: { 'daily-post': { type: 'schedule', function: 'posts', cron: '0 9 * * *', functionPath: '/daily' }, 'on-upload': { type: 'storage_object_created', function: 'posts', bucket: 'images', functionPath: '/on-upload' }, },Deploy, then read the function logs:
Bash neon deploy neon logs query --source functionThe upload trigger fires right away, so upload an image through
/uploadand watch the log line. The scheduled post runs at the next 09:00 UTC; set the cron to* * * * *to see it sooner. To stop the daily post, setenabled: falseinneon.tsand redeploy, or manage it from the Console, CLI, or API.
What you built
Section titled “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
- Scheduled and event-driven work run as the same function code through Function Triggers, with no scheduler or queue to operate
Next steps
Section titled “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. - Run work on a schedule or an event: the Function Triggers overview covers cron syntax, object-key prefixes, and how triggers branch with your project.
Need help?
Section titled “Need help?”Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.