Handling slug URLs in Ember and Phoenix

Posted on Nov 2, 2015

Elixir Phoenix Ember JS

I've recently started playing around with the PEEP stack (Postgres Elixir Ember Phoenix). One of the first things I wanted achieve is using SEO-friendly slugs instead of ids in Ember application's URLs.

This article presumes you know how to set up a fresh Ember project and a fresh Phoenix application as we will be diving head first into the code.

Version info:

These are the tools I'm using for this application.

  • Node 4.2.1
  • Ember 1.13.8 / 2.0.2
  • Elixir 1.0.5
  • Phoenix 1.0.3
  • PostgreSQL 9.4.5

Notes

There is no special configuration required for each to work together. Although, I recommend launching Ember with the proxy argument as shown below.

1
ember serve --proxy=http://localhost:4000

This means you don't have to worry about Content Security Policy between Ember and your API.

If you haven't already, check out Maxwell Holder's Build a Blog with Phoenix and Ember.js. This article really helped me get started with using Ember and Phoenix together.

I'll be demonstrating how to set up 2 pages: a page with a list of products and a page for individual products which have a slug instead of an ID. This includes querying the slug on the API as well.

Show me the code!

The code is available on Github.

Now on to the tutorial!

Setting up Phoenix

First run mix ecto.create to make sure the database is set up correctly.

Phoenix provders a really useful generator for APIs. Run the following command to get the cruft of the work done for you.

1
mix phoenix.gen.json Product products name:string slug:string blurb:text preview:string featured:boolean

The above command generates the view, controller, migration and model files.

The fields include a name, a slug, a blurb for a short product description, preview which will be a link to an image and featured which is a boolean which states if the product is featured or not.

To make this accessible we need to add resource to the router.

1
2
3
4
5
# web/router.ex
scope "/api", Api do 
  pipe_through :api
  resources "/products", ProductController, only: [:index, :show]
end

Git Commit

We've added the API pipeline which we will be modifying shortly. For now, migrate the database in the terminal

1
mix ecto.migrate

As of now the API works, however, it needs a couple of tweeks to be compatible with Ember and to support slugs.

Ember compatible API

Phoenix wraps JSON objects and collections with the "data" attribute but Ember (currently in 2.1 and below) uses the model's singular name for objects and plural name for collections.

1
2
// Phoenix currently
{"data":[]}
1
2
3
// What Ember wants
{"products":[]} // Collection
{"product":[]} // Single Objects

Phoenix makes this really easy to change.

In product_view.ex change data in both the index and show render methods.

The render method which pattern matches index.json should change data to products and the render method which pattern matches show.json should change data to product.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# web/views/product_view.ex
defmodule Api.ProductView do
  use Api.Web, :view

  def render("index.json", %{products: products}) do
    %{products: render_many(products, Api.ProductView, "product.json")}
  end

  def render("show.json", %{product: product}) do
    %{product: render_one(product, Api.ProductView, "product.json")}
  end

  def render("product.json", %{product: product}) do
    %{id: product.id,
      name: product.name,
      slug: product.slug,
      blurb: product.blurb,
      preview: product.preview,
      featured: product.featured,
  end
end

Git commit

Just add slugs

To search for slug instead of the id primary key, we need to replace the Repo.get with a query that uses Repo.one or Repo.one! in the product controller - using the exclamation marked version will throw an error if nothing is found. I recommend this route as you can configure Ember to redirect elsewhere in this situation.

1
2
3
4
5
6
7
def show(conn, %{"id" => slug}) do
  query = from p in Product,
    where: p.slug == ^slug,
    select: p
  product = Repo.one!(query)
  render(conn, "show.json", product: product)
end

Git commit

On line 1 id has been altered to say slug, this is just so we're clear that we're dealing with a slug. The map key is still id, though, as the product resource route added previously is set up to pattern match for id. To change it to slug requires adding a separate route.

Lines 2 to 4 is the query to find the slug in the database.

Line 5 uses Repo.one! to fetch the first match for the query or throw an error.

CORS in production

Although using ember serve –proxy will solve this issue in development, it's worth adding Corsica for production environments.

After following the installation instructions modify the API pipeline in the router:

1
2
3
4
  pipeline :api do
    plug :accepts, ["json"]
    plug Corsica, origins: ["localhost:4200", "example.com"]
  end

Git commit

This will modify response headers to show which sources can load the API.

Make sure that you restart your Phoenix server after installing Corsica.

1
mix phoenix.server

Before you continue to the Ember section make sure you have some data in the database! For this example I've used a lorem ipsum generator and Fill Murray for the preview column.

Ember

Let's use Twitter Bootstrap to make the application look presentable.

1
ember install ember-bootstrap

Note that because I have named my app "App" I have had to rename my app.css to style.css.

Modify the application template to use the bootstrap grid

1
2
3
4
5
6
7
8
9
10
11
12
13
{{!-- app/templates/application.hbs --}}
<div class="container">
  <div class="row">
    <div class="col-xs-12">
      <h1 id="title">Ember Phoenix Slugs</h1>
    </div>
  </div>
  <div class="row">
    <div class="col-xs-12">
      {{outlet}}
    </div>
  </div>
</div>

Git commit

Make sure you restart the Ember server after installing the addon.

1
ember serve --proxy=http://localhost:4000

Before we can hook up Ember to the API, we need to generate the application adapter to add the 'api' namespace that we've set up in Phoenix.

1
ember g adapter application

Open up the newly created adapter file and add the namespace property.

1
2
3
4
5
6
// app/adapters/application.js
import DS from 'ember-data';

export default DS.RESTAdapter.extend({
  namespace: 'api'
});

Git commit

Now we can set up the model we'll use to connect to the API

1
ember g model product
1
2
3
4
5
6
7
8
9
// app/models/product.js
import DS from 'ember-data';

export default DS.Model.extend({
  name: DS.attr(),
  slug: DS.attr(),
  blurb: DS.attr(),
  preview: DS.attr(),
});

Generate the index route to connect to the product model.

1
ember g route index
1
2
3
4
5
6
7
8
// app/routes/index.js
import Ember from 'ember';

export default Ember.Route.extend({
  model: function() {
    return this.store.findAll('product');
  }
});

Here is the template for the index. It loops through all the products and links to them in the image and headings.

1
2
3
4
5
6
7
8
9
10
11
12
13
{{!-- app/templates/index.hbs --}} 
<h2>Products</h2>
<div class="row">
  {{#each model as |product|}}
    <div class="col-xs-3">
      {{#link-to 'product' product}}
        <img src={{product.preview}} class="product--image">
        <h3>{{product.name}}</h3>
      {{/link-to}}
      <p>{{product.blurb}}</p>
    </div>
  {{/each}}
</div>

Git commit

Note that if you're using Ember 2 or onwards you can remove the curly brackets around the image source value.

Next we need to generate the product route.

Product page

As of now Ember will throw an error due to using a product route which doesn't exist yet. Let's fix that.

1
ember g route product
1
2
3
4
5
6
7
8
9
10
11
12
// app/routes/product.js
import Ember from 'ember';

export default Ember.Route.extend({
  model: function(params) {
    this.set('product', this.modelFor('product'));
    return this.store.find('product', params.product_slug);
  },
  serialize: function(model, params) {
    return { product_slug: model.get('slug') };
  }
});

On line 6 we try to find the product by the product_slug.

The serialize method needs to be implemented when an attribute other than id is used for the primary key. We are telling the Ember that when a product object is passed through the link-to method to use the slug instead. product_slug will match in the route we are creating below (On line 10):

1
2
3
4
5
6
7
8
9
10
11
12
// app/router.js
import Ember from 'ember';
import config from './config/environment';

var Router = Ember.Router.extend({
  location: config.locationType
});

Router.map(function() {
  this.route('product', {path: '/products/:product_slug'});
});
export default Router;

The template:

1
2
3
4
5
6
{{!-- app/templates/index.hbs --}} 
<h2>{{model.name}}</h2>
<img src={{model.preview}} class="product--image">
<h3>{{model.name}}</h3>
<p>{{model.blurb}}</p>
{{link-to 'Back' 'index' class="btn btn-default"}}

Git commit

Error page

If the slug can't be found then Phoenix will return a 404 error. The easiest way to handle this in Ember is create a product-error template which Ember will show automatically.

1
2
3
4
{{!-- app/templates/product-error.hbs --}} 
<h2>Whoops</h2>
<p>This page could not be found.</p>
{{link-to 'Back' 'index' class="btn btn-default"}}

Git commit

Setting slug as the primary key

Right now the application works, however, while refreshing an individual product page if you Ember tries to find a product with an id of slug, then a slug of slug. This results in two separate objects rather than one.

This sounds rather confusing and is best illustrated through Ember inspector:

Extra object

To get around this we can change the product model primary key to slug by generating a product serializer and updating the primaryKey property.

1
ember g serializer product
1
2
3
4
5
6
// app/serializers/product.js
import DS from 'ember-data';

export default DS.RESTSerializer.extend({
  primaryKey: 'slug'
});

By doing this the slug attribute on each product object gets moved to the id field and leaves slug as undefined. We will need to update the product route to reflect this change.

1
2
3
4
5
6
7
8
9
10
11
12
// app/routes/product.js
import Ember from 'ember';

export default Ember.Route.extend({
  model: function(params) {
    this.set('product', this.modelFor('product'));
    return this.store.find('product', params.product_slug);
  },
  serialize: function(model, params) {
    return { product_slug: model.get('id') };
  }
});

Git commit

Notice now on line 10 how model.get() is looking for id rather than slug.

Alternatively, we could remove the serialize method and update the router.js to look for :id rather than :product_slug.

Preview

Product listing page

Product page

Whoops 404 page

Summary

In this tutorial we have gone through how to set up index and show methods in a Phoenix application to work with URL slugs.

We have connected this API to an Ember application which can view product listings and link to individual product pages.

Like this article? Sign up to updates - no spam!