Sitemap

Next.js v15 (SSR) ve Express ile GraphQL Entegrasyonu

12 min readJun 13, 2025

--

Bu yazıda, SSR (Server-Side Rendering) odaklı bir mimaride Next.js v15 ve Express v5 ile GraphQL entegrasyonunun nasıl gerçekleştirileceğini ele alacağım. Henüz öğrenme sürecindeyim; dolayısıyla bazı eksikliklerim olabilir. Ancak mümkün olduğunca güncel yaklaşımları ve best-practice’leri takip etmeye, aynı zamanda type-safe bir yapı kurmaya özen gösteriyorum. Next.js v15 ve Express v5 için yeterince güncel bir kaynak bulamadığım için bu yazıyı hazırlama ihtiyacı duydum.

İlk adım, hadi bir node.js projesi kuralım:

mkdir server && cd server && touch package.json

package.json dosyasına aşağıdakileri yazalım:

{
"name": "server",
"version": "0.0.1",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node dist/index.js",
"dev": "node --loader ts-node/esm src/index.ts",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC"
}

Daha sonra şu paketleri yükleyelim:

pnpm add -D @types/node ts-node typescript

tsconfig.json oluşturup içine şunları yazalım:

{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

Tabii unutmadan, .gitignore oluşturalım:

# Dependencies
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log

# TypeScript
dist/
*.tsbuildinfo

# Environment variables
.env
.env.local
.env.*.local

# IDE
.idea/
.vscode/
*.swp
*.swo

# Logs
logs/
*.log

# OS
.DS_Store
Thumbs.db

# Testing
coverage/

# Build output
build/
out/

Siz istediğinizi oluşturabilirsiniz, ben örnek olsun diye bunu koydum. Şimdi ise denemek için src/index.ts dosyası oluşturup test edelim:

pnpm dev

“deneme” çıktısını alabildiğimize göre ilerleyelim.

Gelelim ballı kısıma, hadi şu GraphQL ve gerekli paketleri yükleyelim:

pnpm add @apollo/server graphql @as-integrations/express5 cors dotenv express
pnpm add -D nodemon @types/cors @types/express

Şimdi tek tek açıklayacağım:

  • @apollo/server : GraphQL için server oluşturabilmemizi sağlar.
  • graphql : GraphQL :D
  • @as-integrations/express5 : Express ile Apollo Server entegrasyonunu sağlar. Bu sayede server’da hem Express hem de Apollo Server kullanabiliyor olacağız. Aslında startStandaloneServer de kullanabiliriz fakat bu yöntemde bazı middleware’ları ekleyemiyoruz.
  • cors : CORS ayarlarını yapabileceğimiz paket.
  • dotenv : .env dosyasındaki gizli env’lerimizi kullanabileceğimiz paket.
  • express : Express, Node.js üzerinde çalışan ve web uygulamaları ile API’lerin geliştirilmesini kolaylaştıran minimal ve esnek bir web sunucu çatısıdır.
  • nodemon : nodemon, Node.js uygulamanı geliştirme sırasında dosyalarda yapılan değişiklikleri otomatik algılayarak uygulamayı yeniden başlatan bir araçtır. Yani ctrl + c yapıp tekrar node app.js yazmana gerek kalmaz, kendisi otomatik yapar.

.env dosyası:

PORT=4000

Şimdi ise basit bir index.ts oluşturalım:

import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@as-integrations/express5";
import cors from "cors";
import dotenv from "dotenv";
import express from "express";

dotenv.config({ path: ".env" });

const app = express();
const port = process.env.PORT || 4001;

const apolloServer = new ApolloServer({
typeDefs: `
type Query {
"Hello World açıklaması"
hello: String
}
`,
resolvers: {
Query: {
hello: () => "Hello World",
},
},
});

apolloServer.start().then(() => {
app.use("/graphql", cors(), express.json(), expressMiddleware(apolloServer));
});

app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});

Ardından pnpm dev :

Şimdi de http://localhost:4000/graphql adresine girip bakalım ve basit bir query test edelim:

Çıktımız böyleyse tebrikler! Güzel ilerliyoruz bakalım şimdilik :D

Şimdi ise işimiz daha organize ilerlemek, o halde başlayalım. Öncelikle hadi graphql klasörü oluşturup içine schema.graphql dosyası oluşturalım:

type Book {
title: String!
author: Author!
}

type Author {
name: String!
books: [Book!]!
}

type Query {
books: [Book!]!
authors: [Author!]!
}

Bu arada, .graphql uzantılı dosyaların renklendirilmesi için VS Code’a veya Cursor’a bu eklentiyi yükleyebilirsiniz. Şimdi ise aynı klasöre graphql-server.ts oluşturalım:

import { ApolloServer } from "@apollo/server";
import { readFileSync } from "fs";
import path from "path";

const typeDefs = readFileSync(path.join(__dirname, "schema.graphql"), "utf-8");

const apolloServer = new ApolloServer({
typeDefs,
});

export default apolloServer;

Ayrıca, package.json’da da değişiklik yapmamız gerekiyor:

"dev": "NODE_ENV=development nodemon --ext ts,graphql src/index.ts",

Buraya “ — ext ts,graphql” ve “NODE_ENV=development” ekledik, bu sayede artık .graphql dosyasında değişiklik yaptığımızda da projemiz yenilenecek. Şimdi ise index.ts dosyasını güncelleyelim:

import { expressMiddleware } from "@as-integrations/express5";
import cors from "cors";
import dotenv from "dotenv";
import express from "express";
import apolloServer from "./graphql/graphql-server";

dotenv.config({ path: ".env" });

const app = express();
const port = process.env.PORT || 4000;

apolloServer.start().then(() => {
app.use("/graphql", cors(), express.json(), expressMiddleware(apolloServer));
});

app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});

Fark ettiyseniz artık ApolloServer burada yok, bunu graphql-server dosyasından çekiyoruz. Şimdi görevimiz resolvers eklemek. Bir tane aynı klasöre resolvers.ts oluşturalım:

const resolvers = {
Query: {
numberSix() {
return 6;
},
numberSeven() {
return 7;
},
},
};

export default resolvers;

graphql-server.ts dosyasındaki güncelleme:

const apolloServer = new ApolloServer({
typeDefs,
resolvers,
});

schema.graphql :


type Book {
title: String!
author: Author!
}

type Author {
name: String!
books: [Book!]!
}

type Query {
books: [Book!]!
authors: [Author!]!
numberSix: Int! # Should always return the number 6 when queried
numberSeven: Int! # Should always return 7
}

Eklediğimiz basit resolver’ı test edelim:

Eğer problemsiz şekilde "numberSix": 6 görüyorsak harika!

Şimdi codegen kuracağız. Nedir bu? Codegen, sen elle uğraşmayasın diye şemaya bakıp otomatik kod yazan akıllı yardımcı. Hadi başlayalım:

pnpm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

Ardından:

npx graphql-code-generator init

Ben bunları seçtim, siz de seçin sonra patlamayalım :D

Oluşturulan codegen.ts dosyasını tekrar güncelleyeceğiz:

import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
overwrite: true,
schema: "src/graphql/schema.graphql",
generates: {
"src/generated/graphql.ts": {
plugins: ["typescript", "typescript-resolvers"],
config: {
contextType: "../graphql/context#Context",
},
},
},
};

export default config;

Burada çok önemli bir nokta var. config altında contextType olarak Context verdik. Bu sayede ilerleyen süreçte token vs. eklersek type-safe hata almayacağız. src/graphql/context.ts adında bir dosya oluşturup içine aşağıdakileri yazalım:

import { BaseContext } from "@apollo/server";

export interface Context extends BaseContext {
token: string;
}

Şimdi ise graphql-server.ts dosyasında bir değişiklik daha yapacağız, BaseContext ekledik:

import { ApolloServer, BaseContext } from "@apollo/server";
import { readFileSync } from "fs";
import path from "path";
import resolvers from "./resolvers";

const typeDefs = readFileSync(path.join(__dirname, "schema.graphql"), "utf-8");

const apolloServer = new ApolloServer<BaseContext>({
typeDefs,
resolvers,
});

export default apolloServer;

Hadi artık pnpm codegen ile generate edelim:

Harika! Ama şimdi biz bunu neden yaptık? İşte resolvers tipini verebilmek için,src/graphql/resolvers.ts :

import { Resolvers } from "../generated/graphql";

const resolvers: Resolvers = {
Query: {
books: async () => {
return [];
},
},
};

export default resolvers;

Artık type-safe bir şekilde Resolver’larımızı yazabileceğiz. Ama tabii her seferinde manuel olarak pnpm codegen demeyeceğiz, bunun için concurrent kullanacağız:

pnpm add -D concurrently

Ardından package.json dosyasındaki scriptleri güncelleyelim:

"scripts": {
"start": "node dist/index.js",
"dev": "concurrently \"pnpm run watch:codegen\" \"NODE_ENV=development nodemon --ext ts,graphql src/index.ts\"",
"build": "tsc",
"codegen": "graphql-codegen --config codegen.ts",
"watch:codegen": "nodemon --watch src/**/*.graphql --watch src/**/*.ts --ext graphql,ts --exec \"pnpm run codegen\""
},

dev, watch:codegen bakınız. Bu sayede her .graphql güncellendiğinde codegen çalışacak ve temiz bir type oluşturacak. Hadi biraz test yapalım:

schema.graphql :

type Query {
books: [Book!]!
book(id: ID!): Book
authors: [Author!]!
author(id: ID!): Author
}

type Book {
id: ID!
title: String!
authorId: ID!
author: Author!
description: String
price: Float!
coverImage: String
category: String!
}

type Author {
id: ID!
name: String!
books: [Book!]!
}

input BookInput {
title: String
authorId: ID
description: String
price: Float
coverImage: String
category: String
}

type Mutation {
addBook(title: String!, authorId: ID!): Book!
updateBook(id: ID!, book: BookInput): Book!
deleteBook(id: ID!): Boolean!
}

src/mock/authors.ts :

import { Author } from "../generated/graphql";

const authors: Omit<Author, "books">[] = [
{
id: "1",
name: "Fyodor Dostoyevski",
},
{
id: "2",
name: "George Orwell",
},
{
id: "3",
name: "Antoine de Saint-Exupéry",
},
{
id: "4",
name: "Paulo Coelho",
},
];

export default authors;

src/mock/books.ts :

import { Book } from "../generated/graphql";

const books: Omit<Book, "author">[] = [
{
id: "1",
title: "Suç ve Ceza",
description:
"Rus edebiyatının başyapıtlarından biri olan bu roman, bir öğrencinin işlediği çifte cinayet ve sonrasında yaşadığı vicdan azabını konu alır.",
price: 45.99,
coverImage: "https://picsum.photos/id/500/800/800",
category: "Roman",
authorId: "1",
},
{
id: "2",
title: "1984",
description:
"Distopik bir dünyada geçen bu roman, totaliter bir rejimin insanlar üzerindeki etkisini anlatır.",
price: 35.5,
coverImage: "https://picsum.photos/id/501/800/800",
category: "Distopik",
authorId: "2",
},
{
id: "3",
title: "Küçük Prens",
description:
"Çocuklar için yazılmış ama her yaştan okuyucuya hitap eden bu masal, bir prensin farklı gezegenlerdeki maceralarını anlatır.",
price: 25.75,
coverImage: "https://picsum.photos/id/502/800/800",
category: "Çocuk",
authorId: "2",
},
{
id: "4",
title: "Simyacı",
description:
"Bir çobanın kendi efsanesini bulmak için çıktığı yolculuğu anlatan bu roman, kişisel gelişim temasını işler.",
price: 30.0,
coverImage: "https://picsum.photos/id/503/800/800",
category: "Kişisel Gelişim",
authorId: "3",
},
{
id: "5",
title: "Şeker Portakalı",
description:
"Brezilya'nın yoksul bir bölgesinde yaşayan Zezé'nin hikayesini anlatan bu roman, çocukluğun masumiyetini ve acımasızlığını yansıtır.",
price: 28.5,
coverImage: "https://picsum.photos/id/504/800/800",
category: "Roman",
authorId: "4",
},
];

export default books;

src/graphql/resolvers.ts :

import { Author, Book, Resolvers } from "../generated/graphql";
import authors from "../mock/authors";
import books from "../mock/books";

const resolvers: Resolvers = {
Query: {
books: () => books as Book[],
book: (_, { id }) => books.find((book) => book.id === id) as Book,
authors: () => authors as Author[],
author: (_, { id }) => authors.find((author) => author.id === id) as Author,
},
Book: {
author: (book) =>
authors.find((author) => author.id === book.authorId) as Author,
},
Author: {
books: (author) =>
books.filter((book) => book.authorId === author.id) as Book[],
},
Mutation: {
addBook: (_, { title, authorId }) => {
const newBook: Omit<Book, "author"> = {
id: (books.length + 1).toString(),
title,
authorId,
category: "Unknown",
price: 0,
};
books.push(newBook);
return newBook as Book;
},
updateBook: (_, { id, book: bookInput }) => {
const book = books.find((book) => book.id === id);
if (!book) throw new Error("Book not found");

const updatedBook: Omit<Book, "author"> = {
...book,
...(bookInput as Book),
};

return updatedBook as Book;
},
deleteBook: (_, { id }) => {
const index = books.findIndex((book) => book.id === id);
if (index === -1) throw new Error("Book not found");
books.splice(index, 1);
return true;
},
},
};

export default resolvers;

Veee, sonuç:

Şimdi şu context olayına tekrar dönelim, biraz daha modüler yapalım. Şimdi graphql-server.ts dosyasına aşağıdakini ekleyelim:

export const eMiddleware = () =>
expressMiddleware(apolloServer, {
context: async ({ req }) => {
return {
token: req.headers.authorization,
};
},
});

index.ts dosyasında gerekli değişiklikleri yaptıksan sonraki son hali:

import cors from "cors";
import dotenv from "dotenv";
import express from "express";
import apolloServer, { eMiddleware } from "./graphql/graphql-server";

dotenv.config({ path: ".env" });

const app = express();
const port = process.env.PORT || 4000;

apolloServer.start().then(() => {
app.use("/graphql", cors(), express.json(), eMiddleware());
});

app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});

İşte bu kadar! Artık olabildiğine temiz, type-safe bir GraphQL, Express Server’a sahibiz 🥳

Artık Next.js Zamanı

Hadi client kuralım:

mkdir client && cd client  && npx create-next-app@latest

Hemen ShadCN de ekleyelim:

pnpx shadcn init
pnpx shadcn add button card input select

page.tsx’e şu kodları yapıştırıverin:

import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";

export default function Home() {
return (
<div className="container mx-auto py-8">
{/* Header Section */}
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Kitap Koleksiyonu</h1>
<div className="flex gap-4">
<Input placeholder="Kitap ara..." className="w-[300px]" />
<Select>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Kategori seç" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tümü</SelectItem>
<SelectItem value="fiction">Kurgu</SelectItem>
<SelectItem value="non-fiction">Kurgu Dışı</SelectItem>
<SelectItem value="biography">Biyografi</SelectItem>
</SelectContent>
</Select>
</div>
</div>

{/* Books Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{/* Sample Book Card - This will be mapped over actual data */}
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle>Kitap Adı</CardTitle>
<CardDescription>Yazar Adı</CardDescription>
</CardHeader>
<CardContent>
<div className="aspect-[3/4] relative bg-gray-100 rounded-md mb-4">
{/* Book cover image will go here */}
</div>
<p className="text-sm text-gray-600 line-clamp-2">
Kitap açıklaması burada yer alacak...
</p>
<div className="mt-2">
<span className="text-lg font-semibold">₺199.99</span>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Detaylar</Button>
<Button>Sepete Ekle</Button>
</CardFooter>
</Card>

{/* Repeat the card structure for more books */}
{/* This is just a sample, in real implementation we'll map over the data */}
</div>
{/* Pagination */}
<div className="flex justify-center mt-8 gap-2">
<Button variant="outline">Önceki</Button>
<Button variant="outline">1</Button>
<Button variant="outline">2</Button>
<Button variant="outline">3</Button>
<Button variant="outline">Sonraki</Button>
</div>
</div>
);
}

Ha bu arada, amacım size Next.js, ShadCN öğretmek olmadığı için buraları hızlıca geçiyorum. Hatta AI’a yazdırdım, fark etmişsinizdir. Amacımız GraphQL’i Next.js v15'te SSR çalıştırmak.

Hadi GraphQL ekleyelim:

pnpm add @apollo/client graphql

src/lib/apollo-client.ts :

import { ApolloClient, InMemoryCache } from "@apollo/client";

const client = new ApolloClient({
uri: `${process.env.API}/graphql`,
cache: new InMemoryCache(),
});

export default client;

.env:

API=http://localhost:4000

src/lib/types/index.d.ts :

interface Book {
id: string;
title: string;
authorId: string;
author: Author;
description?: string;
price: number;
coverImage?: string;
category: string;
}

interface Author {
id: string;
name: string;
books: Book[];
}

interface BookInput {
title?: string;
authorId?: string;
description?: string;
price?: number;
coverImage?: string;
category?: string;
}

Şimdi ise Next.js’teki action’ları kullanacağız, src/actions/get-books.ts :

"use server";

import client from "@/lib/apollo-client";
import { gql } from "@apollo/client";

export const getHome = async (searchParams: {
search: string;
category: string;
}) => {
const { search, category } = searchParams;

const { data } = await client.query({
query: gql`
query GetBooks($search: String, $category: String) {
books(search: $search, category: $category) {
id
title
price
category
description
coverImage
author {
name
}
}
categories
}
`,
variables: {
search,
category,
},
});

return data as {
books: Book[];
categories: string[];
};
};

Ve ufak birkaç dokunuş:

components/filters.tsx:

"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import { Input } from "./ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";

export default function Filters({ categories }: { categories: string[] }) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState("");
const router = useRouter();

const handleSearch = async () => {
router.push(`/?search=${search}&category=${category}`);
};

return (
<div className="flex gap-4">
<form action={handleSearch}>
<Input
placeholder="Kitap ara..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</form>
<Select value={category} onValueChange={(value) => setCategory(value)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Kategori seç" />
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

app/page.tsx:

import { getHome } from "@/actions/get-home";
import Filters from "@/components/filters";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import Image from "next/image";

export default async function Home({
searchParams,
}: {
searchParams: Promise<{ search: string; category: string }>;
}) {
const home = await getHome(await searchParams);

return (
<div className="container mx-auto py-8">
{/* Header Section */}
<div className="flex md:flex-row flex-col gap-4 justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Kitap Koleksiyonu</h1>
<Filters categories={home.categories} />
</div>

{/* Books Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{/* Sample Book Card - This will be mapped over actual data */}
{home.books.map((book) => (
<Card key={book.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle>{book.title}</CardTitle>
<CardDescription>{book.author.name}</CardDescription>
</CardHeader>
<CardContent>
<div className="aspect-[3/4] relative bg-gray-100 rounded-md overflow-hidden mb-4">
{book.coverImage ? (
<Image src={book.coverImage} alt={book.title} fill />
) : (
<div className="flex items-center justify-center h-full">
<p className="text-gray-500">No image</p>
</div>
)}
</div>
<p className="text-sm text-gray-600 line-clamp-2">
{book.description}
</p>
<div className="mt-2">
<span className="text-lg font-semibold">
{Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "TRY",
}).format(book.price)}
</span>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline">Detaylar</Button>
<Button>Sepete Ekle</Button>
</CardFooter>
</Card>
))}

{/* Repeat the card structure for more books */}
{/* This is just a sample, in real implementation we'll map over the data */}
</div>

{/* Pagination */}
<div className="flex justify-center mt-8 gap-2">
<Button variant="outline">Önceki</Button>
<Button variant="outline">1</Button>
<Button variant="outline">2</Button>
<Button variant="outline">3</Button>
<Button variant="outline">Sonraki</Button>
</div>
</div>
);
}

Ve tadaaa! ⚡️

Artık server side rendering olan bir Next.js’te GraphQL query’lerimizi yapabiliyoruz!

Aslında buraya yazmadan yaptığım birçok şey var, mesela next.config.ts dosyasına picsum.photos’u eklemek gibi. Ben ana fikirden uzaklaşmadan yapmak istediğimize odaklandım. Uzun süredir böyle bir makale, içerik araştırdım fakat bulamadım. Kendi derdime çözüm buluna bunu yazıya döküp paylaşmak istedim, belki benim gibi yararlanmak isteyen kişiler vardır. Projenin tamamına erişmek için aşağıdaki repo’ya bakabilirsiniz:

https://github.com/mehmetext/graphql-crash-course

--

--

No responses yet