Skip to main content
Neon Postgres Docs
current

Search documentation

Type to search this documentation.

On this pageOverview

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...

You'll need Node.js 20.19+ and the Neon CLI installed:

Bash
npm i -g neon
  1. 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), 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-2

    Create 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.

  2. Scaffold a Next.js app

    Create a new Next.js project with TypeScript, Tailwind CSS, and the App Router. The --yes flag accepts the remaining defaults without prompting.

    Bash
    npx create-next-app@latest my-backend --typescript --tailwind --app --eslint --yes
    cd my-backend
  3. Declare your backend in neon.ts

    A single neon.ts file declares your backend as code. You enable a capability there, run neon 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:

    1. neon link connects the directory to your project (writing a .neon file); neon checkout then pins the branch and pulls its env vars, including DATABASE_URL, into .env.local. If you haven't signed in to the CLI yet, run neon login first. Run interactively, neon link prompts you to create neon.ts; the --no-config flag skips that prompt so the explicit neon config init in step 2 stays in control.
    2. neon config init scaffolds neon.ts and installs @neon/config and @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_URL is in .env.local and 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/env
  4. Install dependencies

    Install drizzle-orm for typed queries and @neondatabase/serverless for the HTTP driver (works in Node, edge, and serverless runtimes). Add drizzle-kit as a dev dependency for the schema migration.

    Bash
    npm install drizzle-orm @neondatabase/serverless
    npm install -D drizzle-kit
  5. Define your schema

    Create a TypeScript schema for a posts table. 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 an author so you can tell them apart; this app is single-user, so the author defaults to anonymous.

    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-kit is a standalone CLI and doesn't read .env.local automatically. loadEnvConfig matches Next.js's env loading behavior so the migration step picks up the same DATABASE_URL as the app.

  6. 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 push creates the table directly from your schema. In production, you'd typically use drizzle-kit generate and drizzle-kit migrate for 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 push

    Open 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);
  7. List posts in a Server Component

    Create the Drizzle client and a /posts page. 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>
      );
    }
  8. Add Object Storage and upload images

    Add an images bucket to your neon.ts and run neon 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 neon adapter reads the injected AWS_* 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_url column on posts, 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-post
    app/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>
      );
    }
  9. 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 /generate writes a post from a topic with the AI Gateway.
    • POST /assistant streams 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/pg
    functions/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;
  10. Deploy the function

    Declare the function and the AI Gateway in neon.ts, then neon 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-provider inside the function needs no configuration.

    Copy the invocation_url from the neon functions get posts output into .env.local as NEXT_PUBLIC_POSTS_FN_URL (the NEXT_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_url
    slug           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/
  11. Call the function from your app

    Wire two pages to the function:

    • /generate calls POST /generate from a Server Action (server-to-server, so no CORS). Good for a short, one-shot generation.
    • /assistant streams from POST /assistant directly in the browser with the AI SDK's useChat hook. Calling the function directly keeps the stream off any serverless host that would time it out.
    Bash
    npm install @ai-sdk/react
    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>
      );
    }
  12. Run the app

    Start the dev server, then open the URL it prints. Try each page:

    • /posts lists the seeded posts.
    • /generate generates a post and saves it.
    • /assistant chats about your posts, streaming from the function.
    • /upload uploads 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 dev
  13. Add 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 posts function:

    • A schedule trigger writes a post every day, like a cron job.
    • A storage_object_created trigger runs when an image is uploaded to your images bucket.

    The function has no root route, so each trigger targets its own path (/daily, /on-upload) that matches a new route. Neon sends a POST with a JSON envelope; each handler reads what it needs from data and checks the X-Neon-Trigger-Invocation-Id header 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, before export 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 function

    The upload trigger fires right away, so upload an image through /upload and 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, set enabled: false in neon.ts and redeploy, or manage it from the Console, CLI, or API.

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.ts and provisioned with neon 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
  • Make it multi-user with Managed Better Auth: add auth: true to neon.ts for 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 checkout forks 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 push to drizzle-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.

Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.

Suggest an edit

Propose a replacement for this page. The site team reviews it before applying any changes.

Export
Documentation menu