diff --git a/lib/static/views.js b/lib/static/views.js
index bad667eee2e40e0667572de7ded35d67ccdea2e4..784c9d31c4fdb23a2713aa015e84cf2b634e4e88 100644
--- a/lib/static/views.js
+++ b/lib/static/views.js
@@ -242,6 +242,25 @@ exports.setup = function(app) {
     });
   }
 
+  // /common/js/templates.js is dynamically built each time
+  if (!config.get('use_minified_resources')) {
+    var templates = require('../templates');
+    var dialogTemplatesPath = path.join(__dirname, '../../resources/static/dialog/views')
+    app.get('/common/js/templates.js', function(req, res) {
+      res.send(templates.generate(dialogTemplatesPath));
+    });
+
+    var siteTemplatesPath = path.join(__dirname, "../../resources/views");
+    var sitePartialTemplatesPath = path.join(__dirname, "../../resources/views/partial");
+    app.get('/test/mocks/site-templates.js', function(req, res) {
+      // combine main templates and partials into one big set for development
+      // mode.
+      var siteTemplates = templates.generate(siteTemplatesPath, "site/");
+      siteTemplates += templates.generate(sitePartialTemplatesPath, "partial/");
+      res.send(siteTemplates);
+    });
+  }
+
   // REDIRECTS
   const REDIRECTS = {
     "/developers" : "https://developer.mozilla.org/docs/persona"
diff --git a/lib/static_resources.js b/lib/static_resources.js
index 72d9ea3397611465d5f7866ec461e67f48c1f97a..b4f013d038034bf3850599fc7ca6255118a3eee5 100644
--- a/lib/static_resources.js
+++ b/lib/static_resources.js
@@ -20,7 +20,6 @@ var common_js = [
   '/common/js/lib/winchan.js',
   '/common/js/lib/underscore.js',
   '/common/js/lib/bidbundle.js',
-  '/common/js/lib/ejs.js',
   '/common/js/lib/micrajax.js',
   '/common/js/lib/urlparse.js',
   '/common/js/lib/gobbledygook.js',
diff --git a/lib/templates.js b/lib/templates.js
new file mode 100644
index 0000000000000000000000000000000000000000..e515eae0002c1503788c0a259911b10d6732e639
--- /dev/null
+++ b/lib/templates.js
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+
+const
+fs = require('fs'),
+path = require('path'),
+ejs = require('ejs'),
+config = require('./configuration');
+
+var bundles = {};
+
+exports.generate = function generate(templatesDir, namePrefix, lastGen) {
+  if (!namePrefix) namePrefix = "";
+
+  var bundle = bundles[templatesDir] || (bundles[templatesDir] = {});
+  lastGen = lastGen || bundle.lastGen || 0;
+  var templateData = bundle.data;
+
+  var fileNames = fs.readdirSync(templatesDir);
+  var templates = [];
+
+  // is a regen necessary?
+  try {
+    for (var i = 0; i < fileNames.length; i++) {
+      if (lastGen < fs.statSync(path.join(templatesDir, fileNames[i])).mtime) {
+        throw "newer";
+      }
+    }
+    // no rebuild needed
+    console.log("templates [%s] up to date", templatesDir);
+    return templateData;
+  } catch (e) {
+    console.log("creating templates [%s]", templatesDir);
+  }
+
+  for(var index = 0, max = fileNames.length; index < max; index++) {
+    var fileName = fileNames[index];
+    if(fileName.match(/\.ejs$/)) {
+      var templateName = namePrefix + fileName.replace(/\.ejs/, '');
+      var templateText = fs.readFileSync(path.join(templatesDir, fileName), "utf8");
+
+      // remove HTML comments
+      templateText = templateText.replace(/<!--[\s\S]*?-->/g, '');
+
+      templates[templateName] = ejs.compile(templateText, {
+        client: true,
+        compileDebug: !config.get('use_minified_resources')
+      });
+    }
+  }
+
+  templateData = "BrowserID.Templates = BrowserID.Templates || {};";
+  for (var t in templates) {
+    if (templates.hasOwnProperty(t)) {
+      templateData += "\nBrowserID.Templates['" + t + "'] = " + String(templates[t]) + ";";
+    }
+  }
+
+  bundle.lastGen = Date.now();
+  bundle.data = templateData;
+  return templateData;
+};
diff --git a/lockdown.json b/lockdown.json
index 3a66f7cf01718e65e3611b463a7d97420fae3b17..c1a5ac7268b982dcda3ee1de682e35583952393e 100644
--- a/lockdown.json
+++ b/lockdown.json
@@ -83,7 +83,7 @@
     "0.2.0": "d46b5eb799ea82e51b8788f1ae37098b63119409"
   },
   "ejs": {
-    "0.4.3": "8143c3656955b8934db5d9da83e9be73176f1f4f"
+    "0.8.3": "db8aac47ff80a7df82b4c82c126fe8970870626f"
   },
   "esprima": {
     "0.9.9": "1b90925c975d632d7282939c3bb9c3a423c30490"
diff --git a/package.json b/package.json
index 33fb10b80c79bf1e28cb609a5e53358edbedbc1d..f4e57f1dd6f776500804df48075ffede9e4f566c 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
         "connect-cachify": "0.0.10",
         "connect-cookie-session": "0.0.2",
         "connect-logger-statsd": "0.0.1",
-        "ejs": "0.4.3",
+        "ejs": "0.8.3",
         "etagify": "0.0.2",
         "express": "2.5.0",
         "gobbledygook": "0.0.3",
diff --git a/resources/static/common/js/lib/ejs.js b/resources/static/common/js/lib/ejs.js
deleted file mode 100644
index d2396e0bd9beb2a29ff3a773f1c8ebd2ffb2b6f1..0000000000000000000000000000000000000000
--- a/resources/static/common/js/lib/ejs.js
+++ /dev/null
@@ -1,514 +0,0 @@
-(function(){
-
-
-var rsplit = function(string, regex) {
-	var result = regex.exec(string),retArr = new Array(), first_idx, last_idx, first_bit;
-	while (result != null)
-	{
-		first_idx = result.index; last_idx = regex.lastIndex;
-		if ((first_idx) != 0)
-		{
-			first_bit = string.substring(0,first_idx);
-			retArr.push(string.substring(0,first_idx));
-			string = string.slice(first_idx);
-		}
-		retArr.push(result[0]);
-		string = string.slice(result[0].length);
-		result = regex.exec(string);
-	}
-	if (! string == '')
-	{
-		retArr.push(string);
-	}
-	return retArr;
-},
-chop =  function(string){
-    return string.substr(0, string.length - 1);
-},
-extend = function(d, s){
-    for(var n in s){
-        if(s.hasOwnProperty(n))  d[n] = s[n]
-    }
-}
-
-
-window.EJS = function( options ){
-	options = typeof options == "string" ? {view: options} : options
-    this.set_options(options);
-	if(options.precompiled){
-		this.template = {};
-		this.template.process = options.precompiled;
-		EJS.update(this.name, this);
-		return;
-	}
-    if(options.element)
-	{
-		if(typeof options.element == 'string'){
-			var name = options.element
-			options.element = document.getElementById(  options.element )
-			if(options.element == null) throw name+'does not exist!'
-		}
-		if(options.element.value){
-			this.text = options.element.value
-		}else{
-			this.text = options.element.innerHTML
-		}
-		this.name = options.element.id
-		this.type = '['
-	}else if(options.url){
-        options.url = EJS.endExt(options.url, this.extMatch);
-		this.name = this.name ? this.name : options.url;
-        var url = options.url
-        //options.view = options.absolute_url || options.view || options.;
-		var template = EJS.get(this.name /*url*/, this.cache);
-		if (template) return template;
-	    if (template == EJS.INVALID_PATH) return null;
-        try{
-            this.text = EJS.request( url+(this.cache ? '' : '?'+Math.random() ));
-        }catch(e){}
-
-		if(this.text == null){
-            throw( {type: 'EJS', message: 'There is no template at '+url}  );
-		}
-		//this.name = url;
-	}
-	var template = new EJS.Compiler(this.text, this.type);
-
-	template.compile(options, this.name);
-
-
-	EJS.update(this.name, this);
-	this.template = template;
-};
-/* @Prototype*/
-EJS.prototype = {
-	/**
-	 * Renders an object with extra view helpers attached to the view.
-	 * @param {Object} object data to be rendered
-	 * @param {Object} extra_helpers an object with additonal view helpers
-	 * @return {String} returns the result of the string
-	 */
-    render : function(object, extra_helpers){
-        object = object || {};
-        this._extra_helpers = extra_helpers;
-		var v = new EJS.Helpers(object, extra_helpers || {});
-		return this.template.process.call(object, object,v);
-	},
-    update : function(element, options){
-        if(typeof element == 'string'){
-			element = document.getElementById(element)
-		}
-		if(options == null){
-			_template = this;
-			return function(object){
-				EJS.prototype.update.call(_template, element, object)
-			}
-		}
-		if(typeof options == 'string'){
-			params = {}
-			params.url = options
-			_template = this;
-			params.onComplete = function(request){
-				var object = eval( request.responseText )
-				EJS.prototype.update.call(_template, element, object)
-			}
-			EJS.ajax_request(params)
-		}else
-		{
-			element.innerHTML = this.render(options)
-		}
-    },
-	out : function(){
-		return this.template.out;
-	},
-    /**
-     * Sets options on this view to be rendered with.
-     * @param {Object} options
-     */
-	set_options : function(options){
-        this.type = options.type || EJS.type;
-		this.cache = options.cache != null ? options.cache : EJS.cache;
-		this.text = options.text || null;
-		this.name =  options.name || null;
-		this.ext = options.ext || EJS.ext;
-		this.extMatch = new RegExp(this.ext.replace(/\./, '\.'));
-	}
-};
-EJS.endExt = function(path, match){
-	if(!path) return null;
-	match.lastIndex = 0
-	return path+ (match.test(path) ? '' : this.ext )
-}
-
-
-
-
-/* @Static*/
-EJS.Scanner = function(source, left, right) {
-
-    extend(this,
-        {left_delimiter: 	left +'%',
-         right_delimiter: 	'%'+right,
-         double_left: 		left+'%%',
-         double_right:  	'%%'+right,
-         left_equal: 		left+'%=',
-         // set - Persona addition. The backend understands <%-, which acts
-         // identical to the frontend's <%=.  <%= on the backend escapes
-         // characters to their HTML code equivalents.  For unit testing, we
-         // write backend templates on the front end, so we have to be able to
-         // process <%-.  Creating an alias here.  Using it wherever
-         // left_equal is found.
-         left_dash: 		left+'%-',
-         left_comment: 	left+'%#'})
-
-	this.SplitRegexp = left=='[' ? /(\[%%)|(%%\])|(\[%=)|(\[%#)|(\[%)|(%\]\n)|(%\])|(\n)/ : new RegExp('('+this.double_left+')|(%%'+this.double_right+')|('+this.left_equal+')|('+this.left_dash+')|('+this.left_comment+')|('+this.left_delimiter+')|('+this.right_delimiter+'\n)|('+this.right_delimiter+')|(\n)') ;
-
-	this.source = source;
-	this.stag = null;
-	this.lines = 0;
-};
-
-EJS.Scanner.to_text = function(input){
-	if(input == null || input === undefined)
-        return '';
-    if(input instanceof Date)
-		return input.toDateString();
-	if(input.toString)
-        return input.toString();
-	return '';
-};
-
-EJS.Scanner.prototype = {
-  scan: function(block) {
-   var scanline = this.scanline,
-       regex = this.SplitRegexp;
-	 if (! this.source == '')
-	 {
-	 	 var source_split = rsplit(this.source, /\n/);
-	 	 for(var i=0; i<source_split.length; i++) {
-		 	 var item = source_split[i];
-			 this.scanline(item, regex, block);
-		 }
-	 }
-  },
-  scanline: function(line, regex, block) {
-	 this.lines++;
-	 var line_split = rsplit(line, regex);
- 	 for(var i=0; i<line_split.length; i++) {
-	   var token = line_split[i];
-       if (token != null) {
-		   	try{
-	         	block(token, this);
-		 	}catch(e){
-				throw {type: 'EJS.Scanner', line: this.lines};
-			}
-       }
-	 }
-  }
-};
-
-
-EJS.Buffer = function(pre_cmd, post_cmd) {
-	this.line = new Array();
-	this.script = "";
-	this.pre_cmd = pre_cmd;
-	this.post_cmd = post_cmd;
-	for (var i=0; i<this.pre_cmd.length; i++)
-	{
-		this.push(pre_cmd[i]);
-	}
-};
-EJS.Buffer.prototype = {
-
-  push: function(cmd) {
-	this.line.push(cmd);
-  },
-
-  cr: function() {
-	this.script = this.script + this.line.join('; ');
-	this.line = new Array();
-	this.script = this.script + "\n";
-  },
-
-  close: function() {
-	if (this.line.length > 0)
-	{
-		for (var i=0; i<this.post_cmd.length; i++){
-			this.push(pre_cmd[i]);
-		}
-		this.script = this.script + this.line.join('; ');
-		this.line = null;
-	}
-  }
-
-};
-
-
-EJS.Compiler = function(source, left) {
-    this.pre_cmd = ['var ___ViewO = [];'];
-	this.post_cmd = new Array();
-	this.source = ' ';
-	if (source != null)
-	{
-		if (typeof source == 'string')
-		{
-		    source = source.replace(/\r\n/g, "\n");
-            source = source.replace(/\r/g,   "\n");
-			this.source = source;
-		}else if (source.innerHTML){
-			this.source = source.innerHTML;
-		}
-		if (typeof this.source != 'string'){
-			this.source = "";
-		}
-	}
-	left = left || '<';
-	var right = '>';
-	switch(left) {
-		case '[':
-			right = ']';
-			break;
-		case '<':
-			break;
-		default:
-			throw left+' is not a supported deliminator';
-			break;
-	}
-	this.scanner = new EJS.Scanner(this.source, left, right);
-	this.out = '';
-};
-EJS.Compiler.prototype = {
-  compile: function(options, name) {
-  	options = options || {};
-	this.out = '';
-	var put_cmd = "___ViewO.push(";
-	var insert_cmd = put_cmd;
-	var buff = new EJS.Buffer(this.pre_cmd, this.post_cmd);
-	var content = '';
-	var clean = function(content)
-	{
-	    content = content.replace(/\\/g, '\\\\');
-        content = content.replace(/\n/g, '\\n');
-        content = content.replace(/"/g,  '\\"');
-        return content;
-	};
-	this.scanner.scan(function(token, scanner) {
-		if (scanner.stag == null)
-		{
-			switch(token) {
-				case '\n':
-					content = content + "\n";
-					buff.push(put_cmd + '"' + clean(content) + '");');
-					buff.cr();
-					content = '';
-					break;
-				case scanner.left_delimiter:
-				case scanner.left_equal:
-				case scanner.left_dash:
-				case scanner.left_comment:
-					scanner.stag = token;
-					if (content.length > 0)
-					{
-						buff.push(put_cmd + '"' + clean(content) + '")');
-					}
-					content = '';
-					break;
-				case scanner.double_left:
-					content = content + scanner.left_delimiter;
-					break;
-				default:
-					content = content + token;
-					break;
-			}
-		}
-		else {
-			switch(token) {
-				case scanner.right_delimiter:
-					switch(scanner.stag) {
-						case scanner.left_delimiter:
-							if (content[content.length - 1] == '\n')
-							{
-								content = chop(content);
-								buff.push(content);
-								buff.cr();
-							}
-							else {
-								buff.push(content);
-							}
-							break;
-            case scanner.left_dash:
-						case scanner.left_equal:
-							buff.push(insert_cmd + "(EJS.Scanner.to_text(" + content + ")))");
-							break;
-					}
-					scanner.stag = null;
-					content = '';
-					break;
-				case scanner.double_right:
-					content = content + scanner.right_delimiter;
-					break;
-				default:
-					content = content + token;
-					break;
-			}
-		}
-	});
-	if (content.length > 0)
-	{
-		// Chould be content.dump in Ruby
-		buff.push(put_cmd + '"' + clean(content) + '")');
-	}
-	buff.close();
-	this.out = buff.script + ";";
-	var to_be_evaled = '/*'+name+'*/this.process = function(_CONTEXT,_VIEW) { try { with(_VIEW) { with (_CONTEXT) {'+this.out+" return ___ViewO.join('');}}}catch(e){e.lineNumber=null;throw e;}};";
-
-	try{
-		eval(to_be_evaled);
-	}catch(e){
-		if(typeof JSLINT != 'undefined'){
-			JSLINT(this.out);
-			for(var i = 0; i < JSLINT.errors.length; i++){
-				var error = JSLINT.errors[i];
-				if(error.reason != "Unnecessary semicolon."){
-					error.line++;
-					var e = new Error();
-					e.lineNumber = error.line;
-					e.message = error.reason;
-					if(options.view)
-						e.fileName = options.view;
-					throw e;
-				}
-			}
-		}else{
-			throw e;
-		}
-	}
-  }
-};
-
-
-//type, cache, folder
-/**
- * Sets default options for all views
- * @param {Object} options Set view with the following options
- * <table class="options">
-				<tbody><tr><th>Option</th><th>Default</th><th>Description</th></tr>
-				<tr>
-					<td>type</td>
-					<td>'<'</td>
-					<td>type of magic tags.  Options are '&lt;' or '['
-					</td>
-				</tr>
-				<tr>
-					<td>cache</td>
-					<td>true in production mode, false in other modes</td>
-					<td>true to cache template.
-					</td>
-				</tr>
-	</tbody></table>
- *
- */
-EJS.config = function(options){
-	EJS.cache = options.cache != null ? options.cache : EJS.cache;
-	EJS.type = options.type != null ? options.type : EJS.type;
-	EJS.ext = options.ext != null ? options.ext : EJS.ext;
-
-	var templates_directory = EJS.templates_directory || {}; //nice and private container
-	EJS.templates_directory = templates_directory;
-	EJS.get = function(path, cache){
-		if(cache == false) return null;
-		if(templates_directory[path]) return templates_directory[path];
-  		return null;
-	};
-
-	EJS.update = function(path, template) {
-		if(path == null) return;
-		templates_directory[path] = template ;
-	};
-
-	EJS.INVALID_PATH =  -1;
-};
-EJS.config( {cache: true, type: '<', ext: '.ejs' } );
-
-
-
-/**
- * @constructor
- * By adding functions to EJS.Helpers.prototype, those functions will be available in the
- * views.
- * @init Creates a view helper.  This function is called internally.  You should never call it.
- * @param {Object} data The data passed to the view.  Helpers have access to it through this._data
- */
-EJS.Helpers = function(data, extras){
-	this._data = data;
-    this._extras = extras;
-    extend(this, extras );
-};
-/* @prototype*/
-EJS.Helpers.prototype = {
-    /**
-     * Renders a new view.  If data is passed in, uses that to render the view.
-     * @param {Object} options standard options passed to a new view.
-     * @param {optional:Object} data
-     * @return {String}
-     */
-	view: function(options, data, helpers){
-        if(!helpers) helpers = this._extras
-		if(!data) data = this._data;
-		return new EJS(options).render(data, helpers);
-	},
-    /**
-     * For a given value, tries to create a human representation.
-     * @param {Object} input the value being converted.
-     * @param {Object} null_text what text should be present if input == null or undefined, defaults to ''
-     * @return {String}
-     */
-	to_text: function(input, null_text) {
-	    if(input == null || input === undefined) return null_text || '';
-	    if(input instanceof Date) return input.toDateString();
-		if(input.toString) return input.toString().replace(/\n/g, '<br />').replace(/''/g, "'");
-		return '';
-	}
-};
-    EJS.newRequest = function(){
-	   var factories = [function() { return new ActiveXObject("Msxml2.XMLHTTP"); },function() { return new XMLHttpRequest(); },function() { return new ActiveXObject("Microsoft.XMLHTTP"); }];
-	   for(var i = 0; i < factories.length; i++) {
-	        try {
-	            var request = factories[i]();
-	            if (request != null)  return request;
-	        }
-	        catch(e) { continue;}
-	   }
-	}
-
-	EJS.request = function(path){
-	   var request = new EJS.newRequest()
-	   request.open("GET", path, false);
-
-	   try{request.send(null);}
-	   catch(e){return null;}
-
-	   if ( request.status == 404 || request.status == 2 ||(request.status == 0 && request.responseText == '') ) return null;
-
-	   return request.responseText
-	}
-	EJS.ajax_request = function(params){
-		params.method = ( params.method ? params.method : 'GET')
-
-		var request = new EJS.newRequest();
-		request.onreadystatechange = function(){
-			if(request.readyState == 4){
-				if(request.status == 200){
-					params.onComplete(request)
-				}else
-				{
-					params.onComplete(request)
-				}
-			}
-		}
-		request.open(params.method, params.url)
-		request.send(null)
-	}
-
-
-})();
diff --git a/resources/static/common/js/renderer.js b/resources/static/common/js/renderer.js
index 4f74c0c414b83e8af803fdc1be5f61bbdcfd174d..ad8dae95a7a0dfa43f767ffa7901a74353637f19 100644
--- a/resources/static/common/js/renderer.js
+++ b/resources/static/common/js/renderer.js
@@ -7,34 +7,23 @@ BrowserID.Renderer = (function() {
   "use strict";
 
   var bid = BrowserID,
-      dom = bid.DOM,
-      templateCache = {};
+      dom = bid.DOM;
 
   function getTemplateHTML(templateName, vars) {
-    var config,
-        templateText = bid.Templates[templateName],
-        vars = vars || {};
-
-    if(templateText) {
-      config = {
-        text: templateText
-      };
-    }
-    else {
-      // TODO - be able to set the directory
-      config = {
-        url: "/dialog/views/" + templateName + ".ejs"
-      };
-    }
-
-    var template = templateCache[templateName];
-    if(!template) {
-      template = new EJS(config);
-      templateCache[templateName] = template;
+    var templateFn = bid.Templates[templateName];
+    if (!templateFn) throw "Template not found: " + templateName;
+
+    var localVars = _.extend({}, vars);
+    if(!localVars.partial) {
+      localVars.partial = function(name) {
+        // partials are not supported by the client side EJS. Create
+        // a standin that does what partial rendering would do on the backend.
+        return getTemplateHTML(name, vars);
+      }
     }
 
-    var html = template.render(vars);
-    return html;
+    // arguments are: locals, filters (which cant be used client-side), escapeFn
+    return templateFn.call(null, localVars);
   }
 
   function render(target, templateName, vars) {
diff --git a/resources/static/dialog/views/authenticate.ejs b/resources/static/dialog/views/authenticate.ejs
index 285c4f58a1112e0d360486a909ba613c164e0a09..ad963538c07c8799f5e569e554b4873d3fa98703 100644
--- a/resources/static/dialog/views/authenticate.ejs
+++ b/resources/static/dialog/views/authenticate.ejs
@@ -4,7 +4,7 @@
 
   <div class="form_section">
       <p class="start">
-          <%= format(gettext('%s uses Persona instead of usernames to sign you in.'), ["<strong>" + siteName +"</strong>"]) %>
+          <%- format(gettext('%s uses Persona instead of usernames to sign you in.'), ["<strong>" + siteName + "</strong>"]) %>
 
       </p>
 
@@ -58,7 +58,7 @@
 
 
       <p class="submit tospp">
-         <%= format(gettext("By proceeding, you agree to %(site)'s <a %(terms)>Terms</a> and <a %(privacy)>Privacy Policy</a>."),
+         <%- format(gettext("By proceeding, you agree to %(site)'s <a %(terms)>Terms</a> and <a %(privacy)>Privacy Policy</a>."),
                     { site: "Persona",
                       terms: 'href="https://login.persona.org/tos" target="_new"',
                       privacy: 'href="https://login.persona.org/privacy" target="_new"' }) %>
diff --git a/resources/static/dialog/views/confirm_email.ejs b/resources/static/dialog/views/confirm_email.ejs
index a796667bc6c500bd03bee8a8ede2f2926e40412c..f11114fe33b8bc3647f706b14ecc7175b7627de0 100644
--- a/resources/static/dialog/views/confirm_email.ejs
+++ b/resources/static/dialog/views/confirm_email.ejs
@@ -5,10 +5,10 @@
     <h2><%= gettext('Confirm your email address') %></h2>
 
     <p>
-      <%= format(gettext('Check your email at %s.'), ["<strong>" + email + "</strong>"]) %>
+      <%- format(gettext('Check your email at %s.'), ["<strong>" + escape(email) + "</strong>"]) %>
     </p>
 
     <p>
-      <%= format(gettext('Click the link in the confirmation email. You\'ll then immediately be signed in to %s.'), ["<strong>" + siteName + "</strong>"]) %>
+      <%- format(gettext('Click the link in the confirmation email. You\'ll then immediately be signed in to %s.'), ["<strong>" + siteName + "</strong>"]) %>
     </p>
 
diff --git a/resources/static/dialog/views/error.ejs b/resources/static/dialog/views/error.ejs
index 14f7c8f39c28e2b44bf2f1387247e7695ff3eec3..c32abe1793583a8453a84ce6de4501acf6bb1c68 100644
--- a/resources/static/dialog/views/error.ejs
+++ b/resources/static/dialog/views/error.ejs
@@ -13,7 +13,7 @@
     <h2 id="error_403">
       <%= gettext("Persona requires cookies to remember you.") %>
     </h2>
-    <%= format(gettext("Please close this window, <a %s>enable cookies</a> and try again"), [" target='_blank' href='http://support.mozilla.org/kb/Websites%20say%20cookies%20are%20blocked'"]) %>
+    <%- format(gettext("Please close this window, <a %s>enable cookies</a> and try again"), [" target='_blank' href='http://support.mozilla.org/kb/Websites%20say%20cookies%20are%20blocked'"]) %>
   <% } else if(typeof title === "string") { %>
     <h2>
       <span class="emphasis"><%= title %></span>
diff --git a/resources/static/dialog/views/required_email.ejs b/resources/static/dialog/views/required_email.ejs
index 500327fbfee2c46573f4bf473542d07848b2d8b5..2934b42982d63e4199d31bd06a1b9526da1e8c44 100644
--- a/resources/static/dialog/views/required_email.ejs
+++ b/resources/static/dialog/views/required_email.ejs
@@ -63,7 +63,7 @@
           </p>
           <% if (personaTOSPP) { %>
             <p class="tospp">
-               <%= format(gettext("By proceeding, you agree to %(site)'s <a %(terms)>Terms</a> and <a %(privacy)>Privacy Policy</a>."),
+               <%- format(gettext("By proceeding, you agree to %(site)'s <a %(terms)>Terms</a> and <a %(privacy)>Privacy Policy</a>."),
                           { site: "Persona",
                             terms: 'href="https://login.persona.org/tos" target="_new"',
                             privacy: 'href="https://login.persona.org/privacy" target="_new"' }) %>
diff --git a/resources/static/dialog/views/rp_info.ejs b/resources/static/dialog/views/rp_info.ejs
index 4ae1e5a072a978a33d7676d706c272d0d75c83d0..43a4b3140cc82e70349b8321af6418cbea4e1847 100644
--- a/resources/static/dialog/views/rp_info.ejs
+++ b/resources/static/dialog/views/rp_info.ejs
@@ -19,7 +19,7 @@
 
 <% if(privacyPolicy && termsOfService) { %>
   <p id="rptospp" class="tospp">
-    <%= format(gettext("By proceeding, you agree to %(site)'s <a %(terms)>Terms</a> and <a %(privacy)>Privacy Policy</a>."),
+    <%- format(gettext("By proceeding, you agree to %(site)'s <a %(terms)>Terms</a> and <a %(privacy)>Privacy Policy</a>."),
                {
                  terms: 'href="' + termsOfService + '" id="rp_tos" target="_blank"',
                  privacy: 'href="' + privacyPolicy + '" id="rp_pp" target="_blank"',
diff --git a/resources/static/dialog/views/set_password.ejs b/resources/static/dialog/views/set_password.ejs
index 74a75f3d11d4da5eacea04ecc4e7506a94e415ea..ed213f78304faa1a4913dda802d58e8fe0a2c85a 100644
--- a/resources/static/dialog/views/set_password.ejs
+++ b/resources/static/dialog/views/set_password.ejs
@@ -71,7 +71,7 @@
 
       <% if (personaTOSPP) { %>
         <p id="persona_tospp" class="submit tospp">
-            <%= format(gettext("By proceeding, you agree to %(site)'s <a %(terms)>Terms</a> and <a %(privacy)>Privacy Policy</a>."),
+            <%- format(gettext("By proceeding, you agree to %(site)'s <a %(terms)>Terms</a> and <a %(privacy)>Privacy Policy</a>."),
                        { site: "Persona",
                          terms: 'href="https://login.persona.org/tos" target="_new"',
                          privacy: 'href="https://login.persona.org/privacy" target="_new"' }) %>
diff --git a/resources/static/dialog/views/test_template_with_partial.ejs b/resources/static/dialog/views/test_template_with_partial.ejs
new file mode 100644
index 0000000000000000000000000000000000000000..11c1a6c41043aef2d0a499fa90039be1a26e5a2a
--- /dev/null
+++ b/resources/static/dialog/views/test_template_with_partial.ejs
@@ -0,0 +1,6 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<%- partial('test_template_no_input') %>
+
diff --git a/resources/static/dialog/views/verify_primary_user.ejs b/resources/static/dialog/views/verify_primary_user.ejs
index 695924ac81a007098eea328f7f365730cf6de3d4..76f26652ef056d4d04386c8ecbf058314f3de672 100644
--- a/resources/static/dialog/views/verify_primary_user.ejs
+++ b/resources/static/dialog/views/verify_primary_user.ejs
@@ -22,7 +22,7 @@
 
     <% if (personaTOSPP) { %>
       <p id="persona_tospp" class="submit tospp">
-         <%= format(gettext("By proceeding, you agree to %(site)'s <a %(terms)>Terms</a> and <a %(privacy)>Privacy Policy</a>."),
+         <%- format(gettext("By proceeding, you agree to %(site)'s <a %(terms)>Terms</a> and <a %(privacy)>Privacy Policy</a>."),
                     { site: "Persona",
                       terms: 'href="https://login.persona.org/tos" target="_new"',
                       privacy: 'href="https://login.persona.org/privacy" target="_new"' }) %>
diff --git a/resources/static/pages/js/manage_account.js b/resources/static/pages/js/manage_account.js
index 119febf71a1c0f2fb6c8466b5a6b9fa704911452..e75f0610f0b1ed09b6020e76ebee51f02db15add 100644
--- a/resources/static/pages/js/manage_account.js
+++ b/resources/static/pages/js/manage_account.js
@@ -79,16 +79,18 @@ BrowserID.manageAccount = (function() {
 
     dom.setInner(list, "");
 
-    // Set up to use mustache style templating, the normal Django style blows
-    // up the node templates
-    _.templateSettings = {
-        interpolate : /\{\{(.+?)\}\}/g
-    };
+    function substitute(text, values, re) {
+      re = re || /\{\{([^\{\}]+)\}\}/g;
+      return String(text).replace(re, function(m, name) {
+        return (values[name] != null) ? values[name] : '';
+      });
+    }
+
     var template = dom.getInner("#templateUser");
 
     _(emails).each(function(item) {
-      var e = item.address,
-          identity = _.template(template, { email: e });
+      var e = _.escape(item.address);
+      var identity = substitute(template, { email: e });
 
       var idEl = dom.appendTo(identity, list),
           deleteButton = dom.getDescendentElements(".delete", idEl);
@@ -211,8 +213,7 @@ BrowserID.manageAccount = (function() {
 
       var self=this,
           oncomplete = options.ready,
-          template = new EJS({ text: dom.getInner("#templateManage") }),
-          manage = template.render({});
+          manage = dom.getInner("#templateManage");
 
       dom.insertAfter(manage, "#hAlign");
 
diff --git a/resources/static/test/cases/common/js/renderer.js b/resources/static/test/cases/common/js/renderer.js
index 2c3b8f116e19dfcd174b7a7d4e88bcf89dbac0bb..9932204f591187321024754c7407f42a546072dc 100644
--- a/resources/static/test/cases/common/js/renderer.js
+++ b/resources/static/test/cases/common/js/renderer.js
@@ -20,12 +20,6 @@
     }
   });
 
-  test("render template loaded using XHR", function() {
-    renderer.render("#formWrap .contents", "test_template_with_input");
-
-    ok($("#templateInput").length, "template written when loaded using XHR");
-  });
-
   test("render template from memory", function() {
     renderer.render("#formWrap .contents", "inMemoryTemplate");
 
@@ -39,6 +33,14 @@
 
   });
 
+  test("render template with partial", function() {
+    equal($("#focusButton").length, 0, "template not yet loaded");
+
+    renderer.render("#formWrap .contents", "test_template_with_partial");
+
+    ok($("#focusButton").length, "template loaded with partial");
+  });
+
 }());
 
 
diff --git a/resources/static/test/cases/pages/js/manage_account.js b/resources/static/test/cases/pages/js/manage_account.js
index 42cbd863bda92036f04215201e4c32a16fd8f18b..e9e6cd9823ea689eb515ac3cb3e879c7b2788155 100644
--- a/resources/static/test/cases/pages/js/manage_account.js
+++ b/resources/static/test/cases/pages/js/manage_account.js
@@ -9,6 +9,7 @@
   var bid = BrowserID,
       xhr = bid.Mocks.xhr,
       errorScreen = bid.Screens.error,
+      user = bid.User,
       network = bid.Network,
       storage = bid.Storage,
       testHelpers = bid.TestHelpers,
@@ -82,12 +83,11 @@
     xhr.useResult("multiple");
 
     createController(mocks, function() {
-      equal($("#emailList").children().length, 2, "there two children added");
-
-      var firstLI = $("#testuser2_testuser_com");
-      var secondLI = $("#testuser_testuser_com");
-
-      equal(firstLI.next().is(secondLI), true, "names are in alphabetical order");
+      var sortedEmails = user.getSortedEmailKeypairs();
+      _.each(sortedEmails, function(addressInfo, index) {
+        var displayedAddress = $("#emailList .email").get(index).innerHTML;
+        equal(displayedAddress, addressInfo.address, "emails are displayed in sorted order");
+      });
 
       start();
     });
diff --git a/resources/static/test/mocks/templates.js b/resources/static/test/mocks/templates.js
index b41be3c7874e2bca81ce352b7645b8926c2a3691..5cccd45ee28727b9274d383c84a4fe704b8a2843 100644
--- a/resources/static/test/mocks/templates.js
+++ b/resources/static/test/mocks/templates.js
@@ -3,6 +3,7 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 BrowserID.Templates = {
-  inMemoryTemplate: "<div id='templateInput'></div>"
+  inMemoryTemplate: function (locals, filters, escape) {
+    return '<div id="templateInput"></div>'
+  }
 };
-
diff --git a/resources/views/index.ejs b/resources/views/index.ejs
index 77c2b4af563974f63d7e8a87db4f56f03e9e4a4a..68c5cdcb37a290b933dcb1aab5553b8502f22aec 100644
--- a/resources/views/index.ejs
+++ b/resources/views/index.ejs
@@ -19,9 +19,14 @@
       </div>
   </div>
 
+  <!--
+    These "templates" below aren't available as EJS client-side, since all
+    EJS tags are processed when this view is served by the server.
+  -->
+
   <script type="text/html" id="templateUser">
-    <li class="identity cf" id="{{ email.replace('@', '_').replace('.', '_') }}">
-      <div class="email">{{ email }}</div>
+    <li class="identity cf">
+      <div class="email">{{email}}</div>
       <button class="delete"><%- gettext('remove') %></button>
     </li>
   </script>
diff --git a/resources/views/test.ejs b/resources/views/test.ejs
index 01012a8017554c028823666e2d74654f979677b8..c4662d1d1a44911fd5fce20e552abfb0a3eee59e 100644
--- a/resources/views/test.ejs
+++ b/resources/views/test.ejs
@@ -68,7 +68,6 @@
     <script src="/include.js"></script>
     <script src="/common/js/lib/jquery-1.7.1.min.js"></script>
     <script src="/common/js/lib/underscore.js"></script>
-    <script src="/common/js/lib/ejs.js"></script>
     <script src="/common/js/lib/gobbledygook.js"></script>
     <script src="/common/js/javascript-extensions.js"></script>
     <script src="/common/js/lib/bidbundle.js"></script>
@@ -84,11 +83,13 @@
     <script src="mocks/mocks.js"></script>
     <script src="mocks/xhr.js"></script>
     <script src="mocks/templates.js"></script>
+    <script src="mocks/site-templates.js"></script>
     <script src="mocks/provisioning.js"></script>
     <script src="mocks/window.js"></script>
     <script src="mocks/winchan.js"></script>
     <script src="mocks/cachify.js"></script>
 
+    <script src="/common/js/templates.js"></script>
     <script src="/common/js/renderer.js"></script>
     <script src="/common/js/class.js"></script>
     <script src="/common/js/mediator.js"></script>
diff --git a/scripts/create_templates.js b/scripts/create_templates.js
index 831fd61ec37d831c8bdf17f663379d4350b31f78..6d91d995aac99e5ebe3aca1a1675cdac2c6863fb 100755
--- a/scripts/create_templates.js
+++ b/scripts/create_templates.js
@@ -6,42 +6,21 @@
 
 const
 fs = require("fs"),
-path = require('path');
+path = require('path'),
+templates = require('../lib/templates');
 
+var existsSync = fs.existsSync || path.existsSync;
 var dir = process.env.TEMPLATE_DIR || process.cwd();
 var output_dir = process.env.BUILD_DIR || dir;
-
-var templates = {};
+var outputFile = path.join(output_dir, "templates.js");
 
 function generateTemplates() {
-  var fileNames = fs.readdirSync(dir)
-
-  // is a regen even neccesary?
-  try {
-    var lastGen = fs.statSync(path.join(output_dir, "templates.js")).mtime;
-    for (var i = 0; i < fileNames.length; i++) {
-      if (lastGen < fs.statSync(path.join(dir, fileNames[i])).mtime) {
-        throw "newer";
-      }
-    };
-    // no rebuild needed
-    console.log("templates.js is up to date");
-    return;
-  } catch (e) {
-    console.log("creating templates.js");
-  }
-
-  for(var index = 0, max = fileNames.length; index < max; index++) {
-    var fileName = fileNames[index];
-    if(fileName.match(/\.ejs$/)) {
-      var templateName = fileName.replace(/\.ejs/, '');
-      templates[templateName] = fs.readFileSync(dir + "/" + fileName, "utf8")
-    }
+  var lastGen = existsSync(outputFile) ? fs.statSync(outputFile).mtime : 0;
+  var templateData = templates.generate(dir, null, lastGen);
+  if (templateData) {
+    // no data most likely means we're already up-to-date
+    fs.writeFileSync(path.join(output_dir, "templates.js"), templateData, "utf8");
   }
-
-  var templateData = "BrowserID.Templates =" + JSON.stringify(templates) + ";";
-
-  fs.writeFileSync(output_dir + "/templates.js", templateData, "utf8");
 };
 
 // run or export the function
diff --git a/scripts/postinstall.js b/scripts/postinstall.js
index 9af5bec429e8dd82748b8a95b986d203adc4a619..93e64ff06830bcb0b2cc4c842a62c9a764f03392 100644
--- a/scripts/postinstall.js
+++ b/scripts/postinstall.js
@@ -28,7 +28,6 @@ function copy(src, dest) {
 }
 
 copy('../node_modules/jwcrypto/bidbundle.js', '../resources/static/common/js/lib/bidbundle.js');
-relativeLink('../resources/views', '../resources/static/dialog/views/site');
 
 
 // generate ephemeral keys
diff --git a/tests/static-resource-test.js b/tests/static-resource-test.js
index 8a91b133109db90b3c8a4414c105a5ddd234b66d..ad7fc916e3e81344616195c588ba63e478df30bf 100755
--- a/tests/static-resource-test.js
+++ b/tests/static-resource-test.js
@@ -55,9 +55,9 @@ suite.addBatch({
         assert.equal(files[minFile][nonLocalizedIndex],
                      res[minRes][nonLocalizedIndex]);
       });
-      // Fragile - filename with :locale...
-      // When fixing this test case... console.log(res[Object.keys(res)[0]]);
-      var localeIndex = 9;
+
+      // testing :locale has been replaced
+      var localeIndex = res[minRes].indexOf('/i18n/:locale/client.json');
       assert.notEqual(files[minFile][localeIndex],
                       res[minRes][localeIndex]);
       var counter = 0;