As a backend developer, you're accustomed to crafting robust APIs, managing databases, and scaling servers. Transitioning to frontend frameworks like Next.js might seem daunting, but the App Router in Next.js 13+ bridges that gap beautifully. It treats routes as server-first by default, enabling seamless server-side rendering (SSR), API logic, and data fetching—much like Express.js routes or FastAPI endpoints.
This practical tutorial walks you through building a full-featured blog app using App Router. We'll cover file-based routing, server components, data loading with fetch, dynamic routes, error handling, and more. By the end, you'll deploy a production-ready app to Vercel.
Perfect for your Next.js portfolio projects!
Kick off by creating a new Next.js project with the App Router enabled.
npx create-next-app@latest my-blog-app --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd my-blog-app
npm install prisma @prisma/client
npx prisma init
Update prisma/schema.prisma for a simple blog model:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite" // Use PostgreSQL in production
url = env("DATABASE_URL")
}
model Post {
id Int @id @default(autoincrement())
title String
content String
published Boolean @default(false)
createdAt DateTime @default(now())
}
Place files in src/app/ to define routes. Folders become path segments; page.tsx renders the page.
src/app/
├── layout.tsx // Root layout (shared across all pages)
├── page.tsx // Homepage: /
├── blog/
│ ├── page.tsx // /blog
│ └── [slug]/
│ └── page.tsx // /blog/my-post
└── api/
└── posts/
└── route.ts // /api/posts (API route)
src/app/layout.tsx)This wraps every page, like a global middleware. Add metadata, fonts, and providers.
import { Inter } from 'next/font/google';
import './globals.css';
import { PrismaClient } from '@prisma/client';
const inter = Inter({ subsets: ['latin'] });
export const metadata = {
title: 'My Blog',
description: 'Built with Next.js App Router',
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<nav className="bg-blue-600 p-4 text-white">
<a href="/" className="mr-4">Home</a>
<a href="/blog">Blog</a>
</nav>
<main className="p-8">{children}</main>
</body>
</html>
);
}Key Point: Layouts are Server Components by default—no client hydration needed for static parts.
src/app/page.tsx)Fetch and display published posts server-side.
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default async function Home() {
const posts = await prisma.post.findMany({
where: { published: true },
select: { id: true, title: true, createdAt: true },
});
return (
<div>
<h1 className="text-4xl font-bold mb-8">Latest Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id} className="mb-4 p-4 border rounded">
<a href=/blog/${post.id}} className="text-2xl hover:underline">
{post.title}
</a>
<p className="text-gray-500">
{new Date(post.createdAt).toLocaleDateString()}
</p>
</li>
))}
</ul>
</div>
);
}Backend Analogy: This is pure SSR—like an Express route querying a DB and rendering HTML.
src/app/blog/[slug]/page.tsx)Capture URL params with folder names like [slug]. Access via params prop.
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
interface Props {
params: { slug: string };
}
export default async function BlogPost({ params }: Props) {
const postId = parseInt(params.slug);
const post = await prisma.post.findUnique({
where: { id: postId },
});
if (!post) {
return <div>Post not found!</div>;
}
return (
<article>
<h1 className="text-3xl font-bold mb-4">{post.title}</h1>
<div
className="prose"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
);
}Enhanced Version with Loading UI: Add a loading.tsx sibling file for suspense.
// src/app/blog/[slug]/loading.tsx
export default function Loading() {
return <div className="animate-pulse">Loading post...</div>;
}Server Actions let you handle forms server-side—no separate API routes needed. Add "use server"; directive.
First, seed data in prisma/seed.ts:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.post.createMany({
data: [
{
title: 'Next.js App Router Basics',
content: '<p>Server Components rock!</p>',
published: true,
},
// Add more...
],
});
}
main().then(() => prisma.$disconnect());Run: npx prisma db seed.
Now, create a new post form (src/app/blog/new/page.tsx):
'use client';
import { useState } from 'react';
import { createPost } from '@/app/actions';
export default function NewPost() {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const handleSubmit = async (formData: FormData) => {
await createPost(formData);
// Redirect or reset form
};
return (
<form action={handleSubmit} className="max-w-md space-y-4">
<input
name="title"
placeholder="Post Title"
className="w-full p-2 border rounded"
required
/>
<textarea
name="content"
placeholder="Content (HTML supported)"
className="w-full p-2 border rounded h-32"
required
/>
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
Publish Post
</button>
</form>
);
}Define the action in src/app/actions.ts:
'use server';
import { PrismaClient } from '@prisma/client';
import { redirect } from 'next/navigation';
const prisma = new PrismaClient();
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await prisma.post.create({
data: { title, content, published: true },
});
redirect('/blog');
}
Pro Tip: Server Actions reduce bundle size—no client-side API calls.
src/app/api/posts/route.ts)For external consumption, build RESTful APIs.
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET() {
const posts = await prisma.post.findMany({ where: { published: true } });
return NextResponse.json(posts);
}
export async function POST(request: NextRequest) {
const { title, content } = await request.json();
const post = await prisma.post.create({
data: { title, content, published: true },
});
return NextResponse.json(post, { status: 201 });
}
Test with curl:
curl -X POST http://localhost:3000/api/posts \
-H "Content-Type: application/json" \
-d '{"title":"API Test","content":"<p>Via API</p>"}Add error.tsx and not-found.tsx in segments needing them.
// src/app/blog/[slug]/error.tsx
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={reset} className="mt-4 bg-red-500 text-white px-4 py-2">
Try again
</button>
</div>
);
}Global not-found in src/app/not-found.tsx:
export default function NotFound() {
return <h1>404 - Page Not Found</h1>;
}For slow fetches, use Suspense boundaries.
In homepage:
import { Suspense } from 'react';
import PostsList from './PostsList';
export default function Home() {
return (
<Suspense fallback={<div>Loading posts...</div>}>
<PostsList />
</Suspense>
);
}
Extract to PostsList.tsx (Server Component).
Push to GitHub, connect to Vercel. Set DATABASE_URL env var. Done—zero-config SSR, edge caching.
git init
git add .
git commit -m "Initial blog app"
git branch -M main
git remote add origin <your-repo>
git push -u origin mainApp Router empowers backend devs with server-centric primitives: zero-client by default, streaming, actions. Build APIs, fetch data, mutate—all in TypeScript.