DIY AngularJS SEO with PhantomJS (the easy way!)

Setting up your AngularJS development environment needs to include SEO best practices. For JS-rendered applications, take a look at this solid solution using PhantomJS.

I’ve been tasked with recreating a website for a higher education institution and I want to capitalize on AngularJS technologies to provide a rich user experience. Unfortunately, one of the largest issues with using the SPA approach to business/corporate/education web design is search engine optimization; AngularJS, and any JS-rendered application framework, is not SEO-friendly. To get around this, we need a way to serve search engine bots a set of pre-rendered HTML pages. Our goal is to create a development environment for AngularJS SEO awesomeness. In this tutorial, we’ll walk through how to get PhantomJS up and running right alongside our app using our Yeoman AngularJS scaffolding that comes with small development server. We’ll go from having nothing to having a full development environment, complete with verifiable pre-rendered page cache for bots to eat up and enjoy.

Update – May 2nd: I’ve received a few dozen emails after publishing this article (and lots of generous praise — thank you, everyone!) Please use the comments section to post any questions or concerns you may have, as this is just the start of a huge AngularJS tutorial project that I’ve started.

The Scaffolding

To setup our development environment, we’ll be using Yeoman’s AngularJS generator, which is an all-in-one solution for developing and testing AngularJS applications. Open up a terminal and let’s get started:

Wait for the scaffolding to start, and definitely go ahead and answer those questions about whether you want to include Bootstrap (that’s what I use) and any of those other AngularJS modules. Once it’s finished, you’ll have a development environment setup. From your terminal you were just in, let’s test our development server: grunt server You should see some automation kick in, then your default browser will open up and you’ll see the default Yeoman scaffolding for the AngularJS template. Right now we’re all set to develop an SPA with AngularJS, but we’re going to take this one step further and create an HTML pre-rendering workflow to server pre-rendered HTML pages to bots for SEO purposes.

The SEO Setup

I have to be honest: I’m only writing this tutorial because I could not find a decent tutorial on setting up an environment for AngularJS SEO awesomeness. It couldn’t be that hard, right? We need something that will tell crawlers to eat up pre-rendered pages, and thankfully, a lot of the heavy lifting in terms of module design has already been taken care of by Steeve at GitHub. Steeve’s code is actually the bulk of what we’re going to be using here.

The first thing we’ll need to do is get a hold of this angular-seo package from GitHub:

git clone

Inside this folder you’ll have two core files:

  • angular-seo.js, which you need to put into your /lawsonry/app folder, and
  • angular-seo-server.js, which you need to put in your /lawsonry folder (or wherever your application root folder is — you know, the one with the Gruntfile.js file in it).

You can follow Steeve’s instructions, but I found it a little unhelpful at 6:00AM. So let’s do this setup together.

The idea is simple: we’re going to have our application running from our application port, and then a PhantomJS instance of our application running from a snapshot port. Requests from non-bots will be served directly from our application port (it doesn’t matter what port that is), and requests from bots and search engines will be served pre-rendered html content via the snapshot port.

To do this, we’ll have to do three things: tell our application to enable AJAX indexing by crawlers; include our seo module and tell our application to let us know when we’re done rendering the page; install and run PhantomJS.

Making our Site Crawlable

This couldn’t be easier. Go to your index.html file and add the following line just before the <head>:

<meta name="fragment" content="!"/>

This basically tells search engines that, while you’re technically a SPA, you have the ability to interpret a special URL structure that it will request in order to ask for pre-rendered HTML pages. Here’s the gist of what’s happening:

  • A crawler hits your site and sees that it’s not pre-rendered HTML, but finds the fragment meta tag. This tag tells it to alter the way it requests information from your server by changing the hashtag in the URL structure to ?_escaped_fragment.
  • Now your server, with this request for a new url, serves the request from a pre-rendered set of pages instead of from the application. This latter procedure gives the search engine a full html page to work with, rather than just an empty JS-rendered page.1

Adding the SEO Code

The next thing we’ll do is go into our app.js file and find the module inclusions part of our declaration. However you do it, you’ll need to include the seo module that comes inside the angular-seo.js file we put in our lawsonry/app folder earlier. For example, here’s what my module declarations block looks like:

Notice that I’ve added the seo module up there. Make sure you do, too! The last thing we’ll do in the app is set a scope-level declaration that all the html has been rendered. This is super easy: Depending on how you organize your controllers, simply call $scope.htmlReady() whenever you are certain that the HTML page is done loading. This is often done at the end of the main controller. For example, with the controller that comes with Yeoman’s AngularJS scaffolding, your main.js file would look like this:

Finally, we need to actually include the angular-seo.js file manually in our index.html file, toward the bottom where the includes for our controllers go. In an unedited scaffolding, my new index.html file looks like this (at the bottom):

Now we’re complete with app-level changes, so let’s move to our command line to deal with the server-side requirements. Don’t worry; we’re almost done!

Setting up PhantomJS

The last part of our Scaffolding is to install and run PhantomJS alongside our development environment. You should already have npm, so install phantom like this: npm install phantomjs Once that’s completed, navigate to your application root directory (the one where we put the other angular-seo file, angular-seo-server.js) and run the following command:

phantomjs --disk-cache=no angular-seo-server.js 9090

This will start a phantomJS server with no disk caching (we’ll use that during production in another tutorial) on port 9090. It’s important to note that PhantomJS’s port needs to be different from the port that your application runs on. Notice that we have set the last parameter (the application) URL to be running on port 9000; that port number comes from the grunt file native to Yeoman’s AngularJS scaffolding.

In other words, yo angular gives us the option to run grunt server, which sets up a localhost webserver to test our app on port 9000.

So think of it like this:

  • PhantomJS runs on port 9090 and listens for requests on port 9000.
  • If those requests contain the ?_escaped_fragment= URL instead of the hashtag URL, then PhantomJS knows to pre-render the page and serve it because the only way we wouldn’t be asking for a hashtag url is if the requestor is a crawler.
  • If those requests contain hashtags, then this is a human (browser) accessing the app and we can go ahead and bypass PhantomJS altogether.

Now that we’ve got PhantomJS running, let’s go ahead and run our development server, too: grunt serveNow we’ve got our development environment running a web server on at port 9000 (or localhost, depending on what you like to call it), and a second web server running on port 9090 that will listen to traffic on port 9000 to see if that traffic is coming from a crawler. Fantastic!

Testing Your Pre-Rendered HTML

The last thing I would encourage everyone to do is test whether your site is serving pre-rendered HTML to requests that contain the ?_escaped_fragment= url. You do this by going back to your terminal and typing:curl 'http://localhost:9090/?_escaped_fragment_= This will pull from your PhantomJS server a request for whatever is routed to the '/' route, which should be (if you haven’t modified the Yeoman AngularJS scaffolding) the views/main.html file. The terminal should output a fully rendered HTML page. Check the contents of the <div class="container ng-scope" ng-view"> tag, and you should see a bunch of HTML underneath. It works!

Going Live

To take this to production, you’ll need to make one more adjustment on the server. Add a detection block in your site’s configuration on your server that will check if the escaped_fragment_url is being requested, because if it is, we’ll want to proxy the user over to PhantomJS instead of serving from our main server on port 80. If you’re in Nginx2 (like I am), you can do this:

if ($args ~ escaped_fragment) { # Proxy to PhantomJS instance here }

However you do it, just remember to have your PhantomJS running on a different port than your web server.

Common Problems

(This section is reserved for commenters whose problems are solved. If you have any questions or concerns, leave a comment and let’s sort it out together!) Problem: The curl test is not outputting pre-rendered html pages. Solution: You need to ensure that your root route '/' is what you used for the server address when you instantiated phantomjs. For example, if you’re routing your application’s root to '/index.html', you need to change the server address from the example above to http://localhost:9090/index.html/?_escaped_fragment_=' Whew! I know it seems daunting, but once you have it all setup, it’s really very simple.


  1. If you want to see what a page looks like without pre-rendered HTML, just open up your AngularJS app in view-source:your-app-url and take a look. Notice anything? Your view partials are not loaded on this page; JavaScript loads these html files dynamically. If you think about this from a search engine’s point of view, how will you be able to see the web content unless you’re viewing the site from a browser?
  2. In a future tutorial, we’ll talk about capitalizing on Nginx’s amazing static file serving capabilities by setting up a failover for a prerendered cache of html files in a snapshots/ folder. Basically, Nginx receives the request and checks a local snapshots/ folder to see if the requested file exists. If it does, it will check it’s last cache time. If it’s too old, it will recache the file with PhantomJS and then serve the cached file. If it’s not too old, it will simply serve the cached file.
  • Marcin Koczorowski

    does this solution requires to have hash bangs in urls ? what about a beautiful not hashing solution. I html5mode on.

  • Howard.zuo

    The same question as @marcinkoczorowski:disqus

  • Esteban Vera

    como puedo lograr esto en un servidor apache?

  • Florian Cellier

    Cool but what is the # Proxy to PhantomJS? I try this

    rewrite .* /$request_uri? break;
    proxy_pass http://localhost:9090;

    But the URL is not interpreted and say that route is can’t get the route !

    • Try omitting the localhost and use instead

  • Very interesting. Well I havn’t any experience with AngularJs but yes I normally do SEO for websites in various technologies like, php and etc. Currently I’m working on one project that is Even In that there is no use of angular js.

  • I get this error:
    Failed to execute ‘postMessage’ on ‘DOMWindow’: The target origin provided (‘http://localhost:9000’) does not match the recipient window’s origin (‘http://localhost:9090’)

    Of course no JS files load but apart from taking ages to load it doesn’t fetch the data completely.

  • Abdul Khalid

    I have followed each step religiously but google is not crawling my site. I am getting correct response when I am doing Curl on the same url but when i search the link on google, the url does not show any description.

    • Use Google’s webcrawler/SEO checker to see what it sees. Is it a caching problem?

  • Hendra Kurniawan

    I follow all the step, but when I test with curl, I’m not getting a prerendered html, instead I got not prerendered html

    Can someone help me ?


  • Mukesh

    This will start a phantomJS server with no disk caching (we’ll use that during production in another tutorial) on port 9090

    Do you have the cached version of the tutorial?

    • I do not. But PhantomJS docs are fantastic and you can probably get all you need from their site.


    Hi, when I entered the command that contains curl, it outputs an empty URL. For incorrect URLs also, it outputs empty html. The result is like, ”


      Hi I was finally able to sort out this issue. The issue was exactly what is mentioned in common problems. Thanks for this awesome guide. It saved a lot of time. But still, there is an issue I’m working on and that is, some of the content like images, are not loading.Im getting Error : ‘Failed to load resource: net::ERR_CONNECTION_RESET’ in console for image files.

  • Pingback: Navigating the React.JS Ecosystem | GuarniBlog()

  • Omi Amarwal

    Hello Jesse Lawson,
    this soluation is working fine as i follow your code flow in my live project. and it’s working only home page eg.(view-source: the meta data of home page are rendering in port 8888 but when i open product details page then it’s not working there. eg.(view-source: it’s showing in browser … please provide me a soluation for this… Thanks

  • Amir

    some questions:
    1. do I still have to do that? google have said they read js based sites. (looks like they’re not, tho)

    2. i do not use hashbang syntax, with the !#, so my site looks like any other site:
    how will i detect the starting point?. what should i use instead of

    3. did you publish that article about how to configure nginx to hold the static pages? link would be great.