Full Stack : Construindo uma Aplicação Completa com Next.js 14 + Node.js do Zero ao Deploy
- #Node.js
- #Railway
- #Next.js
- #Vercel
Neste artigo você vai construir uma aplicação full stack moderna usando Next.js 14 (App Router) no front e Node.js + Express + Prisma no back. Do banco de dados ao deploy na Vercel e Railway.
Por que essa stack está dominando o mercado?
Se você abrir o LinkedIn agora e filtrar vagas de full stack no Brasil, vai encontrar uma combinação se repetindo: Next.js no front, Node.js no back, PostgreSQL no banco. Não é hype é o mercado dizendo o que funciona.
Next.js 14 com App Router trouxe uma mudança de paradigma: o front e o back começaram a se misturar de um jeito inteligente, com Server Components, Server Actions e cache granular. E quando você combina isso com uma API Node.js bem estruturada, você tem uma stack que escala, performa e é fácil de manter.
Neste artigo vou mostrar como montar isso de verdade, com código real e as decisões que um desenvolvedor sênior tomaria no dia a dia.
O que vamos construir
Uma API de gerenciamento de tarefas (Task Manager) com:
- ✅ Autenticação JWT
- ✅ CRUD de tarefas com filtros
- ✅ Frontend consumindo a API com React Query
- ✅ Banco de dados PostgreSQL via Prisma ORM
- ✅ Deploy na Vercel (front) + Railway (back)
Estrutura do projeto
taskmaster/
├── apps/
│ ├── web/ # Next.js 14 - App Router
│ └── api/ # Node.js + Express
├── packages/
│ └── types/ # Tipos compartilhados (TypeScript)
└── package.json # Monorepo com npm workspaces
Monorepo com npm workspaces: simples, sem a complexidade do Turborepo quando você não precisa dela. Tipos compartilhados entre front e back, essa é a virada de chave que separa junior de sênior.
Backend: Node.js + Express + Prisma
1. Setup inicial
mkdir taskmaster && cd taskmaster
npm init -w apps/api -y
cd apps/api
npm install express prisma @prisma/client jsonwebtoken bcryptjs zod
npm install -D typescript ts-node @types/express @types/node nodemon
2. Schema do Prisma
// apps/api/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String
name String
tasks Task[]
createdAt DateTime @default(now())
}
model Task {
id String @id @default(cuid())
title String
description String?
status TaskStatus @default(PENDING)
priority Priority @default(MEDIUM)
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum TaskStatus {
PENDING
IN_PROGRESS
DONE
}
enum Priority {
LOW
MEDIUM
HIGH
}
3. Estrutura de pastas da API
apps/api/src/
├── controllers/
│ ├── auth.controller.ts
│ └── task.controller.ts
├── middlewares/
│ ├── auth.middleware.ts
│ └── validate.middleware.ts
├── routes/
│ ├── auth.routes.ts
│ └── task.routes.ts
├── services/
│ ├── auth.service.ts
│ └── task.service.ts
├── lib/
│ └── prisma.ts
└── server.ts
Essa separação é importante: controller recebe request e devolve response, service contém a lógica de negócio. Não misture as responsabilidades.
4. Singleton do Prisma Client
// apps/api/src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : [],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
Sem isso, em desenvolvimento com hot reload você vai criar uma nova conexão com o banco a cada save. Detalhe pequeno, problema grande em escala.
5. Middleware de autenticação
// apps/api/src/middlewares/auth.middleware.ts
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
export interface AuthRequest extends Request {
userId?: string
}
export function authenticate(
req: AuthRequest,
res: Response,
next: NextFunction
) {
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
return res.status(401).json({ error: 'Token não fornecido' })
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string
}
req.userId = decoded.userId
next()
} catch {
return res.status(401).json({ error: 'Token inválido ou expirado' })
}
}
6. Task Service — a lógica que importa
// apps/api/src/services/task.service.ts
import { prisma } from '../lib/prisma'
import { TaskStatus, Priority } from '@prisma/client'
interface CreateTaskInput {
title: string
description?: string
priority?: Priority
userId: string
}
interface ListTasksInput {
userId: string
status?: TaskStatus
priority?: Priority
page?: number
limit?: number
}
export class TaskService {
async create(data: CreateTaskInput) {
return prisma.task.create({ data })
}
async list({ userId, status, priority, page = 1, limit = 10 }: ListTasksInput) {
const where = {
userId,
...(status && { status }),
...(priority && { priority }),
}
const [tasks, total] = await Promise.all([
prisma.task.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.task.count({ where }),
])
return {
tasks,
pagination: {
total,
page,
pages: Math.ceil(total / limit),
},
}
}
async update(id: string, userId: string, data: Partial<CreateTaskInput & { status: TaskStatus }>) {
return prisma.task.update({
where: { id, userId }, // garante que o usuário só edita as próprias tasks
data,
})
}
async delete(id: string, userId: string) {
return prisma.task.delete({
where: { id, userId },
})
}
}
Note o where: { id, userId } no update e delete. Isso é autorização em nível de dado — um erro clássico de segurança é validar autenticação mas esquecer que o usuário A pode editar tarefas do usuário B.
Frontend: Next.js 14 com App Router
1. Setup
npx create-next-app@latest apps/web \
--typescript \
--tailwind \
--app \
--no-src-dir
2. Configurando o React Query
// apps/web/app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minuto
},
},
})
)
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
3. Camada de API no front (lib/api.ts)
// apps/web/lib/api.ts
const API_URL = process.env.NEXT_PUBLIC_API_URL!
async function fetchWithAuth(path: string, options?: RequestInit) {
const token = localStorage.getItem('token')
const res = await fetch(`${API_URL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options?.headers,
},
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.message ?? 'Erro na requisição')
}
return res.json()
}
export const api = {
tasks: {
list: (params?: { status?: string; page?: number }) =>
fetchWithAuth(`/tasks?${new URLSearchParams(params as any)}`),
create: (data: { title: string; description?: string; priority?: string }) =>
fetchWithAuth('/tasks', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: object) =>
fetchWithAuth(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string) =>
fetchWithAuth(`/tasks/${id}`, { method: 'DELETE' }),
},
}
4. Hook de tarefas com React Query
// apps/web/hooks/useTasks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
export function useTasks(filters?: { status?: string }) {
return useQuery({
queryKey: ['tasks', filters],
queryFn: () => api.tasks.list(filters),
})
}
export function useCreateTask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: api.tasks.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
})
}
export function useDeleteTask() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: api.tasks.delete,
onMutate: async (id) => {
// Optimistic update: remove da UI antes de confirmar no servidor
await queryClient.cancelQueries({ queryKey: ['tasks'] })
const previous = queryClient.getQueryData(['tasks'])
queryClient.setQueryData(['tasks'], (old: any) => ({
...old,
tasks: old.tasks.filter((t: any) => t.id !== id),
}))
return { previous }
},
onError: (_, __, context) => {
queryClient.setQueryData(['tasks'], context?.previous)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
})
}
O optimistic update no delete é o tipo de detalhe que faz uma aplicação parecer rápida e profissional. O usuário vê a task sumir instantaneamente; se der erro, ela volta.
5. Componente de listagem
// apps/web/app/dashboard/page.tsx
'use client'
import { useTasks, useDeleteTask } from '@/hooks/useTasks'
import { useState } from 'react'
const STATUS_LABELS = {
PENDING: 'Pendente',
IN_PROGRESS: 'Em andamento',
DONE: 'Concluído',
}
const PRIORITY_COLORS = {
LOW: 'bg-green-100 text-green-800',
MEDIUM: 'bg-yellow-100 text-yellow-800',
HIGH: 'bg-red-100 text-red-800',
}
export default function DashboardPage() {
const [filter, setFilter] = useState<string | undefined>()
const { data, isLoading } = useTasks({ status: filter })
const deleteTask = useDeleteTask()
if (isLoading) return <div className="animate-pulse">Carregando...</div>
return (
<div className="max-w-4xl mx-auto p-6">
<div className="flex gap-2 mb-6">
{['Todos', 'PENDING', 'IN_PROGRESS', 'DONE'].map((s) => (
<button
key={s}
onClick={() => setFilter(s === 'Todos' ? undefined : s)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors
${filter === (s === 'Todos' ? undefined : s)
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{s === 'Todos' ? 'Todos' : STATUS_LABELS[s as keyof typeof STATUS_LABELS]}
</button>
))}
</div>
<ul className="space-y-3">
{data?.tasks.map((task: any) => (
<li
key={task.id}
className="flex items-center justify-between p-4 bg-white rounded-xl shadow-sm border border-gray-100"
>
<div>
<p className="font-medium text-gray-900">{task.title}</p>
{task.description && (
<p className="text-sm text-gray-500 mt-1">{task.description}</p>
)}
<span className={`mt-2 inline-block px-2 py-0.5 rounded text-xs font-medium
${PRIORITY_COLORS[task.priority as keyof typeof PRIORITY_COLORS]}`}>
{task.priority}
</span>
</div>
<button
onClick={() => deleteTask.mutate(task.id)}
className="text-red-400 hover:text-red-600 transition-colors ml-4"
>
Remover
</button>
</li>
))}
</ul>
{data?.pagination && (
<p className="text-sm text-gray-400 mt-4 text-center">
{data.pagination.total} tarefas no total
</p>
)}
</div>
)
}
Deploy: Vercel + Railway
Backend no Railway
O Railway é a melhor opção custo-benefício para hospedar Node.js + PostgreSQL hoje.
# railway.toml na raiz de apps/api
[build]
builder = "nixpacks"
[deploy]
startCommand = "npm run start"
healthcheckPath = "/health"
healthcheckTimeout = 300
Variáveis de ambiente no Railway:
DATABASE_URL=postgresql://...
JWT_SECRET=seu-secret-aqui-use-openssl-rand-base64-32
NODE_ENV=production
PORT=3001
Frontend na Vercel
vercel --cwd apps/web
Variável de ambiente na Vercel:
NEXT_PUBLIC_API_URL=https://sua-api.railway.app
Boas práticas que fazem a diferença
1. Validação com Zod em ambos os lados
Não confie só no TypeScript — ele some no runtime. Use Zod para validar inputs tanto na API quanto no front:
import { z } from 'zod'
export const createTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'),
})
2. Rate limiting na API
npm install express-rate-limit
import rateLimit from 'express-rate-limit'
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 100,
message: { error: 'Muitas requisições, tente novamente mais tarde.' },
})
app.use('/api/', limiter)
3. Variáveis de ambiente tipadas
// apps/api/src/env.ts
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3001),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
})
export const env = envSchema.parse(process.env)
Se uma variável de ambiente obrigatória estiver faltando, a aplicação não sobe — e você descobre em desenvolvimento, não em produção.
O que aprender a seguir
Você tem uma aplicação full stack funcionando. O próximo nível é:
- Testes: Vitest para unit tests nos services, Playwright para E2E
- Cache: Redis para sessões e rate limiting distribuído
- Filas: Bull/BullMQ para processar tarefas em background (envio de emails, notificações)
- Observabilidade: Sentry para erros, Axiom ou Datadog para logs estruturados
- CI/CD: GitHub Actions rodando lint, testes e deploy automático
Cada um desses tópicos é um artigo em si mas dominar essa base é o que permite você evoluir para eles.
Conclusão
Construir uma aplicação full stack de verdade não é só juntar tecnologias — é fazer escolhas conscientes: onde fica a lógica de negócio, como garantir segurança nos dados, como a UI responde rápido mesmo com latência na rede.
Next.js 14 + Node.js não é a única stack boa, mas é uma das mais demandadas no mercado brasileiro hoje. E agora você sabe não só como usar, mas por que cada peça está onde está.
Pedro Sebben — Desenvolvedor Full Stack



