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/postsroute 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
$ 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:
// 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
-- 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() );
// 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:
{
"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:
// 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.
// 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
// 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 });
}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)
$ 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
- 1Push and import
Push the project to GitHub, then open
/dashboard/projects/newand pick the repository and branch. - 2Configure
The preset is detected as Next.js (build
npm run build, output.next). Set Database to PostgreSQL soDATABASE_URLis injected. - 3Environment
Add
ADMIN_TOKENwith your generated value. - 4Start command and port
Deploy, then on the Environment Canvas set the web service's start command to
npm run start:prodand Internal Port to3000. Save and redeploy. The runtime log showsschema up to datebefore Next.js starts.
CLI
$ 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
$ 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
Next steps: switch to output: 'standalone' for a smaller image (see Deploy Next.js) and roll back a bad release with deployxa rollback previous.