DeployxaDocs Portal
CLI v1.0.4
Tutorial: Next.js 14 Full-Stack Blog
Tutorial · Next.js

Build and deploy a full-stack Next.js blog

In about 30 minutes you will build a small blog with the App Router, Tailwind CSS and PostgreSQL, cache post pages with incremental static regeneration (ISR), publish posts through a protected API route, and deploy it to Deployxa with a custom domain.

What you will build

  • A home page listing posts, read from PostgreSQL on each request.
  • Post pages at /posts/[slug], rendered on first visit and cached for 60 seconds (ISR).
  • A POST /api/posts route handler, protected by a bearer token, that saves a post and refreshes its cached page.
  • A tiny migration script that creates the table before the server starts.

You need Node.js 20+, a Deployxa account and the CLI (npm install -g @deployxa/cli). A local PostgreSQL is optional; you can test against the Deployxa database once it exists. The code targets Next.js 15 and newer.

1. Create the app

shell
$ npx create-next-app@latest deployxa-blog --ts --tailwind --eslint --app --src-dir --import-alias "@/*"
$ cd deployxa-blog
$ npm install pg
$ npm install -D @types/pg

2. Connect to PostgreSQL

Deployxa injects DATABASE_URL when you attach a database. Create one shared pool and keep it on globalThis so hot reload in development does not open a new pool on every edit:

ts
// src/lib/db.ts
import { Pool } from 'pg';

const g = globalThis as unknown as { pgPool?: Pool };

export const pool =
  g.pgPool ?? new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });

if (process.env.NODE_ENV !== 'production') g.pgPool = pool;

export type Post = {
  slug: string;
  title: string;
  body: string;
  created_at: Date;
};

3. Add a migration script

sql
-- db/schema.sql
CREATE TABLE IF NOT EXISTS posts (
  id         SERIAL PRIMARY KEY,
  slug       TEXT UNIQUE NOT NULL,
  title      TEXT NOT NULL,
  body       TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
js
// scripts/migrate.mjs
import { readFile } from 'node:fs/promises';
import pg from 'pg';

const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
await client.query(await readFile(new URL('../db/schema.sql', import.meta.url), 'utf8'));
await client.end();
console.log('schema up to date');

Add a production start script that migrates, then starts Next.js. npm runs scripts in a shell, so && works here:

json
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "start:prod": "node scripts/migrate.mjs && next start"
  }
}

4. The home page

Next.js prerenders static pages during next build. To keep the build independent of the database, the home page renders on each request:

tsx
// src/app/page.tsx
import Link from 'next/link';
import { pool, type Post } from '@/lib/db';

export const dynamic = 'force-dynamic';

export default async function Home() {
  const { rows } = await pool.query<Post>(
    'SELECT slug, title, created_at FROM posts ORDER BY created_at DESC LIMIT 50'
  );

  return (
    <main className="mx-auto max-w-2xl px-6 py-16">
      <h1 className="text-4xl font-bold tracking-tight">Deployxa Blog</h1>
      <ul className="mt-10 space-y-6">
        {rows.map((p) => (
          <li key={p.slug}>
            <Link href={`/posts/${p.slug}`} className="text-xl font-semibold hover:underline">
              {p.title}
            </Link>
            <p className="text-sm text-gray-500">{new Date(p.created_at).toDateString()}</p>
          </li>
        ))}
        {rows.length === 0 && <li className="text-gray-500">No posts yet.</li>}
      </ul>
    </main>
  );
}

5. Post pages with ISR

Returning an empty array from generateStaticParams means no posts are fetched at build time; each post is rendered the first time someone visits it, then served from cache and regenerated in the background at most every 60 seconds. In Next.js 15+, params is a Promise, so it is awaited.

tsx
// src/app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { pool, type Post } from '@/lib/db';

export const revalidate = 60;

export async function generateStaticParams() {
  return [];
}

async function getPost(slug: string) {
  const { rows } = await pool.query<Post>('SELECT * FROM posts WHERE slug = $1', [slug]);
  return rows[0];
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const post = await getPost((await params).slug);
  return { title: post?.title ?? 'Not found' };
}

export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const post = await getPost((await params).slug);
  if (!post) notFound();

  return (
    <article className="mx-auto max-w-2xl px-6 py-16">
      <h1 className="text-4xl font-bold tracking-tight">{post.title}</h1>
      <p className="mt-2 text-sm text-gray-500">{new Date(post.created_at).toDateString()}</p>
      <div className="mt-8 space-y-4 leading-7">
        {post.body.split(/\n{2,}/).map((para, i) => (
          <p key={i}>{para}</p>
        ))}
      </div>
    </article>
  );
}

6. A protected publishing API

ts
// src/app/api/posts/route.ts
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { pool } from '@/lib/db';

const slugify = (s: string) =>
  s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');

export async function POST(req: Request) {
  if (req.headers.get('authorization') !== `Bearer ${process.env.ADMIN_TOKEN}`) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  }

  const { title, body } = await req.json();
  if (!title || !body) {
    return NextResponse.json({ error: 'title and body are required' }, { status: 400 });
  }

  const slug = slugify(title);
  await pool.query(
    `INSERT INTO posts (slug, title, body) VALUES ($1, $2, $3)
     ON CONFLICT (slug) DO UPDATE SET title = EXCLUDED.title, body = EXCLUDED.body`,
    [slug, title, body]
  );

  revalidatePath(`/posts/${slug}`);   // drop the cached page right away
  return NextResponse.json({ slug }, { status: 201 });
}
Warning: Use a long random ADMIN_TOKEN (for example openssl rand -hex 32) and never commit it. If it is unset, the check compares against Bearer undefined, so set it before you deploy.

7. Try it locally (optional)

shell
$ export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/blog"
$ export ADMIN_TOKEN="dev-token"
$ node scripts/migrate.mjs
$ npm run dev

$ curl -X POST http://localhost:3000/api/posts \
    -H "Authorization: Bearer dev-token" -H "Content-Type: application/json" \
    -d '{"title":"Hello Deployxa","body":"First post.\n\nShipped from my laptop."}'

8. Deploy to Deployxa

Dashboard

  1. 1
    Push and import

    Push the project to GitHub, then open /dashboard/projects/new and pick the repository and branch.

  2. 2
    Configure

    The preset is detected as Next.js (build npm run build, output .next). Set Database to PostgreSQL so DATABASE_URL is injected.

  3. 3
    Environment

    Add ADMIN_TOKEN with your generated value.

  4. 4
    Start command and port

    Deploy, then on the Environment Canvas set the web service's start command to npm run start:prod and Internal Port to 3000. Save and redeploy. The runtime log shows schema up to date before Next.js starts.

CLI

shell
$ rm -rf node_modules .next           # deploy uploads the folder as-is
$ deployxa login
$ deployxa deploy                      # creates and links the project on first run
$ deployxa env set ADMIN_TOKEN "$(openssl rand -hex 32)"
$ deployxa logs --live

Attach PostgreSQL and set the start command in the dashboard as above (the CLI has no flags for those), then run deployxa redeploy.

9. Publish your first post

shell
$ curl -X POST https://<your-app-url>/api/posts \
    -H "Authorization: Bearer <ADMIN_TOKEN>" -H "Content-Type: application/json" \
    -d '{"title":"Hello from production","body":"Served by Next.js on Deployxa."}'

Open /posts/hello-from-production. The first visit renders and caches the page; edits published through the API show up immediately because the route handler calls revalidatePath, and other changes appear within 60 seconds.

10. Add your domain

In Dashboard → Domains, connect blog.example.com to the project, create the CNAME record it shows, and press Retry verification once DNS has propagated. Or run deployxa domains add blog.example.com and watch deployxa dns until it reports SSL Active.

Troubleshooting

SymptomFix
ECONNREFUSED 127.0.0.1:5432 at startupNo database is attached, so DATABASE_URL is empty. Attach PostgreSQL and redeploy.
Build fails while "Collecting page data"A page queries the database at build time. Keep dynamic = 'force-dynamic' on the home page and the empty generateStaticParams on posts.
Type error: params is not a PromiseOn Next.js 14, type params as a plain object; awaiting it still works at runtime.
401 from /api/postsThe header must be exactly Authorization: Bearer <ADMIN_TOKEN>, and the variable must be set on the project.
A post page shows old contentIt is still within the 60-second window; publish through the API to revalidate it immediately.

Next steps: switch to output: 'standalone' for a smaller image (see Deploy Next.js) and roll back a bad release with deployxa rollback previous.