Angular'da SEO - Server-side Rendering (SSR): Angular Universal'a giriş

Merhaba,


Angular projemizi geliştiriyoruz her şey mükemmel! Derken aklımıza SEO geliyor. Çünkü kaynak kodda <app-root> var başka bir şey yok, kardeşim bu google sayfa içinde ne görecekte seni öne çıkaracak. Tam burada Angular Universal ile Server-side Rendering işlemi olaya el atıyor.


Nedir bu Angular Universal?


Angular Universal, Angular'ın sunucu tarafında işlenmesidir (Server side rendering - kısaca SSR). Bunu biraz daha detaylandıracak olursak, projemizin kaynak koduna baktığımızda farketmişsinizdir <app-root></app-root> var ve diğer içerikler yok. Buda başlı başına bir seo problemi demektir. Title ve description'ı değiştirebilirsiniz evet ancak dinamik çalışan bir sayfanız varsa orada bunuda yapamazsınız. Örnek verecek olursak: eticaret siteniz var ve bir ürünün detay sayfasına giriyorsunuz title'da ürünün başlığı olacak. Ancak ürünün detayları bir apiden geliyor ve o apiden gelen cevap size milisaniyeler içerisinde olmuyor. 0.5 saniye sürer, 1 saniye sürer, 5 saniye sürer. Google sitenizi indexleyeceği zaman senin apine giden istekten cevap dönmesini beklemeyecek ve buda seoda bize büyük bir sorun olacak. Angular Universal bu işi size sunucu tarafında yapıyor ve bütün içerik yerleştiği zaman size o html'i gönderir ve bütün apilerden cevaplar gelmiş, title ve description istediğiniz şekilde ayarlanmış şekilde size kaynak kodu döndürür. Daha sade şekilde anlatmak gerekirse sayfanıza yapılan istek ilk önce sunucu tarafında işlenir ve kaynak kod olarak size geri döner.


Daha fazla tanım yapıp, örnek verip uzatmayalım ve nasıl yapıyoruz bu işi ona bakalım;


İlk olarak projemizi oluşturalım ve başlayalım hazırlamaya (her zamanki gibi ilk önce projemizi baştan sona hazırlayıp ondan sonra universal'ı ekleyeceğiz ki mevcut projelerinizde nasıl yapmanız gerektiğini daha iyi anlayın),

ng new angularServerSideRendering --routing
cd angularServerSideRendering
ng serve



Şimdi projemizde 2 dinamik sayfamız olacak,

Anasayfa: burada apiden gelen verileri listeleyeceğiz.

Detay: burada yazının detayı olacak, bilgiler yine apiden gelecek.


Biraz detaylı olması için şöyle bir şey yapacağız: photos, albums, users apilerimiz var. Biz anasayfada photos apisinden gelen verileri listeleyeceğiz, bunun detayına girdiğimizde ise bu fotoğrafın hangi albüme ait olduğunu ve o albümünde hangi kullanıcıya ait olduğunu alıp detay sayfasında göstereceğiz. Yani bir apiye istek gidecek ordan cevap geldiğinde hemen başka bir apiye istek gidecek ordan da cevap geldiğinde sayfamız açılacak. SSR için gayet güzel bir örnek olacak. Ancak uzun bir anlatım olacak. Uzun süren anlatım projeyi angular universalı kullanacağımız kısıma getirene kadardır, siz dilerseniz bu kısımları atlayarak direkt Universal'ı kurduğumuz kısıma geçebilirsiniz bir şey kaçırmayacaksınız.


Hemen gerekli component ve servislerimizi oluşturalım;

ng g c components/header
ng g c components/footer
ng g c components/home
ng g c components/detail
ng g c components/loading-spinner
ng g s services/post
ng g s services/seo


Gelelim app.component.html içerisindeki gereksiz kodları silelim ve aşağıdaki gibi olsun;

<app-header></app-header>
<router-outlet></router-outlet>
<app-footer></app-footer>


app-routing.module.ts dosyasına gelelim ve route tanımlamalarımızı yapalım;

const routes: Routes = [
  { path: '', component: HomeComponent, pathMatch: 'full' },
  { path: 'detail/:id', component: DetailComponent }
];


Bir api kullanacağımız için HttpClientModule'ü projemizde kullanacağız o yüzden app.module.ts dosyasını açıyoruz ve bunu aşağıdaki dizinden import edip @NgModule içerisindeki imports: [...] bölümünde de ekliyoruz;

import {HttpClientModule} from '@angular/common/http';


Hazır app.module içerisindeyken api adresimizide tanımlayalım, bunun için yine @NgModule içerisindeki providers: [] bölümüne aşağıdaki satırı ekliyoruz;

{ provide: 'apiUrl', useValue: 'https://jsonplaceholder.typicode.com' }

Api adresini burada tanımlamayıp direkt service dosyasında da bir değişkene atayıp kullanabilirdik (bu proje için) ancak birden fazla servis oluşturduğunuzda ve api adresi değişecek olursa her servis dosyasında tek tek değiştirmek zorunda kalırsınız bu yüzden en temiz yöntem budur.

Not: Hatta bazen localhost'ta ayrı api adresi canlıda ayrı api adresi kullanacak olabilirsiniz. Böyle bir durumda sizi bu linke alalım.


Componentlerimizi oluşturduk, servislerimizi oluşturduk, HttpClientModule dahil edildi, api adresimizide tanımladık. Şuan için görüntümüz şöyle;

En sevmediğim görüntüdür kendisi ancak bu görüntüyü güzelleştirmek için vakit kaybetmeyeceğim çünkü ssr olayını çok zor gibi hissetmenizi istemiyorum.


Şimdi gelelim apiyi kullanmaya, önce post.service.ts dosyamızı açalım ve constructor'a şu şekilde tanımlamalarımızı yapalım;

constructor(
  @Inject('apiUrl') private apiUrl,
  private http: HttpClient
) { }

apiUrl kısmında @Inject() şeklinde kullandığımız için Inject'i import etmemiz gerekiyor onuda @angular/core içerisinden import ediyoruz.

http ise @angular/common/http içerisinden import ediyoruz.


Daha sonra hemen fotoğrafları getirecek olan servisimizi yazalım;

getPhotos() {
  return this.http.get(this.apiUrl + '/photos');
}


Bunları yaptıktan sonra post.service.ts dosyası ile işimiz şimdilik bitti ve şuanda bu dosyanın içeriği şu şekilde;

import {Inject, Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';

@Injectable({
  providedIn: 'root'
})
export class PostService {

  constructor(
    @Inject('apiUrl') private apiUrl,
    private http: HttpClient
  ) { }

  getPhotos() {
    return this.http.get(this.apiUrl + '/photos');
  }

}


geliyoruz home.component.ts dosyamıza ve burada da kodlarımızı yazalım;

  • showSpinner adında bir değişken oluşturalım ve değerini true yapalım. Apiden cevap geldiğinde bunu false olarak değiştireceğiz ve bunun değeri true olduğu sürece sayfada bir "loading" dönecek.
  • photos adında bir değişken oluşturalım apiden gelen verileri bu değişkene eşitleyeceğiz.
  • constructor içerisinde post.service'imizi tanımlayalım.
  • servisi kullanacağımız methodu yazalım ve bu methodu component ilk açıldığında çalışması için ngOnInit() { } içerisinde çağıralım.


Yani bunları yaptığımızda home.component.ts dosyamız aşağıdaki gibi olacaktır;

import { Component, OnInit } from '@angular/core';
import {PostService} from '../../services/post.service';

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.scss']
})
export class HomeComponent implements OnInit {

  showSpinner = true;
  photos;

  constructor(
    private postService: PostService
  ) { }

  ngOnInit() {
    this.getPhotos();
  }

  getPhotos() {
    this.postService.getPhotos()
      .subscribe((res: any) => {
        console.log(res);
        this.photos = res;
        this.showSpinner = false;
      }, (err: any) => {
        console.log(err);
      });
  }

}


Evet apimizden verileri aldık


Ancak 5bin tane veri geleceğini atlamışım neyse bunları gösterirken bir kısmını gösteririz ki sayfamızı hepten yavaşlatmasın.

Şimdi gelelim html tarafında bunları göstermeye;


home.component.html

<app-loading-spinner *ngIf="showSpinner"></app-loading-spinner>
<section class="photos" *ngIf="!showSpinner">
  <div class="photo" *ngFor="let photo of photos | slice: 0:20">
    <div class="photo-thumbnail">
      <img [src]="photo.thumbnailUrl">
    </div>
    <div class="photo-info">
      <h2><a routerLink="detail/{{ photo.id }}">{{ photo.title }}</a></h2>
      <h5>Photo url: {{ photo.url }}</h5>
    </div>
  </div>
</section>

*ngFor içindeki slice pipe'ı ile photos dizisindeki 5bin tane verinin 0. elemandan başlayıp 20. elemana kadar almasını söyledim.


home.component.scss

.photos {
  display: block;
  overflow: hidden;
  .photo {
    display: flex;
    width: 50%;
    float: left;
    .photo-thumbnail {
      margin-right: 15px;
    }
    .photo-info {
      h2 {

      }
      h5 {

      }
    }
  }
}


Evet iğrenç bir tasarım ile apiden gelen verileri ekrana bastık;


Şimdi birde detay servisimizi ve componentimizi ayarlayalım sonrasında asıl odak noktamız olan Universal için kolları sıvayalım.


post.service.ts dosyamıza aşağıdaki methodları ekliyoruz;

getPhoto(id) {
  return this.http.get(this.apiUrl + '/photos/' + id);
}

getAlbum(id) {
  return this.http.get(this.apiUrl + '/albums/' + id);
}

getUser(id) {
  return this.http.get(this.apiUrl + '/users/' + id);
}

Detaya girince önce getPhoto ile fotoğrafın bilgilerini getireceğiz daha sonra gelen bilgiler içinden albüm id'sini öğrenip getAlbum methodundan albüm bilgilerini getireceğiz o bilgiler gelincede user id'sini alıp getUser methodu ile kullanıcının bilgilerini getireceğiz :)


Şimdi gelelim detail.component.ts dosyamıza;

  • showSpinner adında bir değişken oluşturup true değerini veriyoruz. Bu son apiden cevap geldiğinde false olacak ve true olduğu sürece ekranda loading componentinin içeriği görünecek.
  • selectedPhotoId adında değişken oluşturuyoruz. url'de bulunan id'yi ngOnInit içerisinde bulup bu değişkene atayacağız.
  • photo, album ve user değişkenlerini oluşturuyoruz. Apilerden gelen dataları sırasıyla bu değişkenlere atayacağız.
  • Servisleri kullanacağımız methodları yazalım;


Bu işlemleri yaptığımızda detail.component.ts dosyamız aşağıdaki gibi olacak;

import { Component, OnInit } from '@angular/core';
import {PostService} from '../../services/post.service';
import {ActivatedRoute} from '@angular/router';

@Component({
  selector: 'app-detail',
  templateUrl: './detail.component.html',
  styleUrls: ['./detail.component.scss']
})
export class DetailComponent implements OnInit {

  showSpinner = true;
  selectedPhotoId;
  photo;
  album;
  user;

  constructor(
    private postService: PostService,
    private activatedRoute: ActivatedRoute
  ) { }

  ngOnInit() {
    this.selectedPhotoId = this.activatedRoute.snapshot.paramMap.get('id'); // url'deki id yi alıyoruz
    if (this.selectedPhotoId) {
      this.getPhoto(this.selectedPhotoId);
    }
  }

  getPhoto(id) {
    this.postService.getPhoto(id)
      .subscribe((res: any) => {
        this.photo = res;
        this.getAlbum(res.albumId);
      });
  }

  getAlbum(id) {
    this.postService.getAlbum(id)
      .subscribe((res: any) => {
        this.album = res;
        this.getUser(res.userId);
      });
  }

  getUser(id) {
    this.postService.getUser(id)
      .subscribe((res: any) => {
        this.user = res;
        this.showSpinner = false; // loading burada kaybolacak ve veriler görünecek.
      });
  }

}


detail.component.html

<app-loading-spinner *ngIf="showSpinner"></app-loading-spinner>
<section class="detail" *ngIf="!showSpinner">
  <div class="photo">
    <h2>Photo Information</h2>
    <ul>
      <li><img [src]="photo.thumbnailUrl" [alt]="photo.title"></li>
      <li><strong>Title: </strong> {{ photo.title }}</li>
      <li><strong>Url: </strong> {{ photo.url }}</li>
    </ul>
  </div>
  <div class="album">
    <h2>Album Information</h2>
    <ul>
      <li><strong>id: </strong> {{ album.id }}</li>
      <li><strong>Title: </strong> {{ album.title }}</li>
    </ul>
  </div>
  <div class="user">
    <h2>User Information</h2>
    <ul>
      <li><strong>Name: </strong> {{ user.name }}</li>
      <li><strong>Username: </strong> {{ user.username }}</li>
      <li><strong>Email: </strong> {{ user.email }}</li>
      <li><strong>Address: </strong> {{ user.address.street }} - {{ user.address.suite }} - {{ user.address.city }}</li>
    </ul>
  </div>
</section>


detail.component.scss

.detail {
  display: flex;
  .photo,
  .album,
  .user {
    padding: 30px;
    background-color: #f9f9f9;
    ul {
      margin: 0;
      padding: 0;
      list-style: none;
      li {
        padding: 5px 0;
        img {
          max-width: 150px;
        }
      }
    }
    &:nth-child(odd) {
      background-color: #f3f3f3;
    }
  }
}


Evet şuan için her ne kadar iğrenç bir tasarıma sahip olsakta anasayfada apiden gelen verileri listeleyip bunlara tıkladığımızda detaylarını gösteriyoruz.


Hala Universal'a giremedik ama biz seo servisi oluşturmuştuk bir tane seo.service.ts ona gelelim ve orada title, description'ı değiştireceğimiz methodlarımızı yazalım.


seo.service.ts dosyamızı aşağıdaki gibi ayarlıyoruz;

import { Injectable } from '@angular/core';
import {Meta, Title} from '@angular/platform-browser';

@Injectable({
  providedIn: 'root'
})
export class SeoService {

  constructor(
    private title: Title,
    private meta: Meta
  ) { }

  updateTitle(title: string) {
    this.title.setTitle(title);
  }

  updateMeta(name: string, content: string) {
    this.meta.updateTag({name, content});
  }

}

(Seo servisiyle ilgili bir başka yazı yayınlayacağım. -> Yazdım)


Şimdi bu servisi hem HomeComponent hemde DetailComponent'te kullanalım.


home.component.ts;

constructor( ... ) içerisinde private seoService: SeoService şeklinde import edelim.

daha sonra ngOnInit() { ... } içerisinde aşağıdaki gibi kullanalım.

this.seoService.updateTitle('Angular SSR Test - Homepage');


Aynı şekilde hızlıca detail.component.ts constructor( ... ) içerisinde tanımlayıp;

ngOnInit() { ... } içerisinde;

this.seoService.updateTitle('Detail page - Angular SSR Test');


getPhoto(id) { ... } içerisinde apiden cevap geldiğinde;

this.seoService.updateTitle(res.title + ' - Angular SSR');


getAlbum(id) { ... } içerisinde apiden cevap geldiğinde;

this.seoService.updateMeta('description', this.photo.title + ' - ' + res.title);


şeklinde tanımlamalarımızı yapıyoruz ve artık apiden cevap geldiğinde sayfamızın title ve description'ı değişecek.


Evet Angular Universal dışında her şey tamam artık. Şuana kadar yaptıklarımızla title ve description'ı da değiştirdik ama bir bakalım detay sayfasındayken sayfa kaynağında nasıl bir görüntü var;


<app-root></app-root> Evet şimdi şu ssr işini bir halledelim de artık kaynak kodumuz dolu dolu olsun.


Başlayalım :)

Eski sürümlerde angular universal kurulumu çok zahmetliydi ve çok zamanınızı alabiliyordu. Ancak artık çok basit bir şekilde halledebiliyorsunuz bunu.


İlk olarak aşağıdaki komutu bir çalıştırıyoruz; (eğer sizin projenizin adı farklıysa angular-server-side-rendering yazan yere kendi projenizin adını yazınız, projenizin adını package.json dosyasında "name" kısmından bakabilirsiniz. Ancak orada - olabilir o tireleri kaldırıp camelCase şeklinde yazınız. yani bende angular-server-side-rendering ama angularServerSideRendering yazacağım)

ng add @nguniversal/express-engine --clientProject angularServerSideRendering


Bu işlem bittikten sonra app.module.ts dosyasını açıyoruz @NgModule içerisinde imports bölümündeki BrowserModule.withServerTransition kısmındaki appId bölümünde yer alan serverApp i proje adınız ile değiştirin (yani en başından beri benimle aynı işlemleri yapıyorsanız angularServerSideRendering olarak değiştireceksiniz).


Evet Angular Universal'ı başarıyla kurdunuz :)


Evet tüm işlemler bu kadardı. Şimdi build ederek çalışıp çalışmadığını kontrol edelim. Aşağıdaki komut ile uygulamanızı build edin (3-4 dakika kadar sürebilir);

npm run build:ssr && npm run serve:ssr


İşlemler başarıyla bittiğinde aşağıdaki gibi bir görüntü ile karşılacaksınız. http://localhost:4000 e giriyoruz ve artık server tarafında işlenip bize dönen sonucu göreceğiz.


adrese girdiğimizde console.log diyerek bastığımız datalar terminalimizde görünecektir yani şu şekilde;


Ve açılan sayfamızda sayfa kaynağını göster diyoruz;


Detaya gidiyoruz;


Dolu dolu bir sayfa kaynağı bizi karşılıyor. Evet her şey bu kadar.

Projenizi bir web sitesinde yayına almak istediğiniz zaman sunucuda node.js kurulu olması gerekecektir. dist klasörü içerisindeki server.js, browser ve server klasörlerini ftp den yükleyip server.js'yi de node.js ile çalıştıracaksınız. Bu konuyla ilgili hosting sağlayıcınızdan destek almanız gerekebilir.


Umarım gereken tüm detayları sade bir şekilde anlatabilmişimdir.

Projemizin bitmiş haline buradan ulaşabilirsiniz: https://github.com/MehmetSert/angularServerSideRendering


Not: localhost:4000'i açmaya çalıştığınızda projeniz açılmıyorsa (sekme yüklenmiyorsa) veya localStorage, setTimeout, setInterval, sessionStorage gibi komutlarda hata alıyorsanız yani localStorage is not defined, setTimeout is not defined gibi hata alıyorsanız sizi şu yazıya alalım: https://kodumunblogu.net/detail/angular-universal-ssr-build-isleminde-settimeout-setinterval-localstorage-sessionstorage-is-not-defined-sorunu


Angular 8'e sonradan güncelleme yaptıysanız ve lazy loading module kullanıyorsanız sorun yaşayabilirsiniz. Onuda şuradaki yazımdan çözebilirsiniz.


Teşekkürler.

Mehmet Sert

HTML, CSS, Javascript ve Angular konularında tecrübe edinmiş ve Bursa'da Frontend Developer olarak bir firmada görev almaktayım. Yeni teknolojilere meraklıyım, öğrendiklerimi uygulayarak ve anlatarak pekiştirmeyi seviyorum....

"Angular'da SEO - Server-side Rendering (SSR): Angular Universal'a giriş" için 2 yorum yapıldı.
A.F
Ahmet Faruk ASLAN 25 Temmuz 2019

Aradığım bir konuyla ilgili makalenizi gördüm ve bu siteye ilk defa girdim. Aslında bu makale için gelmemiştim ama paylaşımlarınız ilgimi çekince hepsine göz atmadan duramadım. Zamanında bir sürü araştırma yapıp doğru çözümler bulamadığım konularda güzel makaleler yazmışsınız. Paylaşımlarınız için teşekkürler.

Mehmet Sert (Yazı sahibi)31 Temmuz 2019

Teşekkür ederim değerli yorumunuz için. Dediğiniz gibi bende zamanında o kadar araştırma yapıp çözüm bulmak için çabaladım ve bu eksiğin kapatılması gerektiğini düşündüm. Bildiklerimizi kendimize saklamak yerine bunları anlatalım istedik :)

Yorum yap * E-posta adresiniz yayınlanmayacak.