Heads up! This blog post hasn't been updated in over 2 years. CodePen is an ever changing place, so if this post references features, you're probably better off checking the docs. Get in touch with support if you have further questions.
Serverless.
It’s kind of a phenomenon. All sorts of web developers can make use of it, including front-end developers! Here’s one way to think about it: they allow you to take your front-end skills and do things that typically only a back-end can do. Depending on what you all make use of, serverless is possibly more accurately referred to as Cloud Functions or Functions as a Service.
Here’s a rather remarkable thing serverless can make possible on the client: saving and reading things from a database. That’s right! It’s possible to have a database and deal with it entirely through front-end code. Technically, the database still exists on a server, so the word serverless can feel a little disingenuous, but it’s not a server that you need to buy and build and maintain and deal with directly.
Why bother with doing a database this way? For one thing, it means you can host the rest of your site much more easily. Wanna build a React-powered blog on GitHub pages? You totally can! Wanna use the super fast static file host Netlify to build a Vue-based community-driven recipe site? Do it.
I bet where you can see this is going: serverless opens up tons of possibilities for what you can build right here on CodePen! Let’s dig into this and build ourselves a fully functional blog.
That’s right, even though CodePen itself doesn’t have data storage, you could use serverless technology to handle all that.
COOL RIGHT?! I sure think so. Let’s do this.
Table of Contents
- Tools of the Trade
- Set Up a Firebase Project
- Set Up Cloud Functions
- API development: List All Posts
- Setting up the Front End
- Single Posts (and improving our API)
- Creating New Blog Posts
- Handle URL Changes (and cleaning up)
- Keep Going!
Just looking for the final product and code?
- GitHub Repo: For the cloud functions, sample data, and code snippets.
- Final Demo
- CodePen Project
Tools of the Trade
From here on out, we’re going to assume you know the basics of the command line and have npm installed on your machine.
There are lots of possible tools in the world of serverless, but for this demo, in addition to building the front end on CodePen, we’ll be doing the serverless stuff on Firebase, so you’ll need a Google account.
Step 1) Set up a Firebase project
Let’s create a project and then import some placeholder data (literally: some fake blog posts) that we can use initially as we develop our API.
- Login to your Google account and navigate to your Firebase console
-
Select the big Add Project
-
Create the project by providing a project name. The project ID is provided for you, but you can edit it, if you’d like. The name is just for finding your project again in your list of projects, but the ID is used in your actual code.
We’re just working on the web here, but note that your Firebase projects can be used on both iOS and Android apps as well (like, simultaneously).
Let’s consider the data for a blog post.
We have to put our content hats on for a minute and consider the bits of data that a blog post might have. Let’s keep it real simple and go with title
, content
, posted date
, and author
. I’m sure you can imagine a more elaborate data structure, but this’ll do for now. You aren’t locked to this, it can always be changed later.
Given these, we can structure our first implementation data like this, with the created
field being a Unix timestamp:
{
"posts": {
"101": {
"title": "Introduction to the Thesis of Theseus",
"content": "Lorem ipsum dolor sit amet, ...",
"created": 1483355533000,
"author": "Duncan"
},
"102": {
...
}
}
}
Let’s import some fake data.
This will be easier for us to play around with some data in Firebase we can get our hands on.
- Download the
posts.json
file as raw from here, Or create your own JSON file with the fields you can see in that example. -
To initialize the data storage with our posts, select Database from the left side menu under the DEVELOP section.
-
Click the “Get Started” button.
-
Open up the … menu on the right side of the menus, above the dismiss button, for access to the import/export menus
-
Select Import JSON menu item and the Browse button, selecting the
posts.json
file downloaded to import.If you have an existing project you’re reusing, this action will overwrite any data you currently have stored in your project, making a new project recommended for this tutorial.
-
Select “Import” to save the initial data.
We now have our Firebase project created, development data in our data storage, and are ready to start developing in earnest!
Step 2) Set Up Cloud Functions
First, let’s make something clear: You don’t need to work with Cloud Functions use Firebase realtime databases. We could make this project entirely through client-side JavaScript. We went the route of Cloud Functions because they are powerful and a big part of understanding the possibilities of **serverless*. Now let’s get into what they are.
Firebase Cloud Functions are (nominally) small pieces of code that run on discrete data. In our case, we want to start with a function that will provide a list of all posts.
While the setup is slightly different for Cloud Functions than for many front-end libraries, developers comfortable with JavaScript on the front-end will feel at home with the Firebase Cloud Functions.
- On the command line, install the firebase tools globally:
npm install -g firebase-tools
- Once installed, create and/or navigate to your project directory. This is where the back-end, serverless code resides locally. We’ll develop, test, and deploy from this project directory.
In the project directory, run:
firebase login
- The login command will provide a URL and open your default browser, where you can log in to your google account, and grant access to the firebase command line tools.
Of note, I find that the authentication process happens entirely in the browser particularly awesome. That the process can be done from other devices if you’re on a headless server, say, is even more spectacular. You can, for example, confirm the tools in your phone’s browser. Totally awesome.
$ firebase login ? Allow Firebase to collect anonymous CLI usage and error reporting information? No Visit this URL on any device to log in: https://accounts.google.com/o/oauth2/auth?client_id=123- 456.apps.googleusercontent.com&scope=... Waiting for authentication... ✔ Success! Logged in as kitt@example.com
With a successful login, we can develop our project locally and deploy our tested Cloud Functions to Firebase.
-
In the project directory, run:
firebase init functions
Firebase has a number of features, functions is only one of them. You can also have hosting, file storage, and event triggers. We’ll use only functions initially.
? Select a default Firebase project for this directory: [don't setup a default project] ❯ Duncan (duncan-131) Firebase Demo Project (fir-demo-project) [create a new project] === Functions Setup A `functions` directory will be created in your project with a Node.js package pre-configured. Functions can be deployed with firebase deploy. ? What language would you like to use to write Cloud Functions? JavaScript ✔ Wrote functions/package.json ✔ Wrote functions/index.js? Do you want to install dependencies with npm now? (Y/n)
Allow firebase to install the dependencies by saying yes, to the dependencies install prompt:
? Do you want to install dependencies with npm now? Yes ... ✔ Firebase initialization complete!
We’ll now have the following files, and be ready to start developing our back-end API:
.firebaserc
firebase.json
functions/index.js
functions/package.json
functions/node_modules
Step 3) API development: List All Posts
- Open up the
functions/index.js
file in your favorite editor.To make development easier, the Firebase functions module is already included. Our code will use this library to create functions and triggers in firebase.
We want to access the database, too, so include the firebase-admin module, too.
// Firebase SDK call to create Cloud Functions and setup triggers. const functions = require('firebase-functions'); // The Firebase Admin SDK to access the Firebase Realtime Database. const admin = require('firebase-admin'); // new admin.initializeApp(functions.config().firebase); // new
- The first end API point we want lists all the posts we have in our blog.
In the
functions/index.js
file, add this code:// List all the posts under the path /posts/ exports.posts = functions.https.onRequest((req, res) => { return admin.database().ref('/posts').once('value').then(function(snapshot) { res.status(200).send(JSON.stringify(snapshot)); }); });
We are defining the cloud function
posts
. In it, we are using the Firebase real-time database (essentially a JSON object) for all the values below the/posts
reference, which is at the top (or root) of our JSON.Accessing values in the JSON is via the path into the JSON.
once()
is a database action that triggers data retrieval one time. In our code, we want thevalue
event. Thevalue
event is sent every time data is changed at or below the reference specified in theref()
call. Because every data change will trigger thevalue
event, use it sparingly.When the database reading is done and we have the
once()
call complete, we can send the snapshot of our data back on the request. -
Now that we have code for our first API endpoint, we want to test it. We never, ever want to deploy untested code.
To test locally before deploying, the
firebase
command has aserve
function that creates a local server which uses the remote datastore – we can access our database locally.First run:
firebase use --add
And select your project you’ve already started. Then to start the local server, from your project directory, run:
firebase serve --only functions
This will start the local server and print a URL like:
http://localhost:5000/PROJECT_NAME/PROJECT_REGION/posts
If the command doesn’t open a new browser window, copy the localhost URL into your browser.
$ firebase serve --only functions === Serving from '/Users/codepen/duncan-131'... i functions: Preparing to emulate functions. ✔ functions: posts: http://localhost:5000/duncan-131/us-central1/posts
If all went well, you’ll see the static JSON uploaded earlier.
-
Next, we want to upload our functions to Firebase. On the command line, in the project directory:
firebase deploy --only functions
The successful deployment will print out our API URL:
$ firebase deploy === Deploying to 'duncan-131'... i deploying functions i functions: ensuring necessary APIs are enabled... ✔ functions: all necessary APIs are enabled i functions: preparing functions directory for uploading... i functions: packaged functions (1.04 KB) for uploading ✔ functions: functions folder uploaded successfully i functions: updating function posts... ✔ functions[posts]: Successful update operation. Function URL (posts): [https://us-central1-duncan-131.cloudfunctions.net/posts](https://us-central1-duncan-131.cloudfunctions.net/posts) ✔ Deploy complete! Project Console: https://console.firebase.google.com/project/duncan-131/overview
Viewing this URL, we see our development JSON:
Yay! We’re ready to use our API now.
Step 4) Setting up the Front End
This is where CodePen comes in! We need someone where to build our front end. If you’re just playing around, creating a Pen will do. If you’re looking to really build this thing out, you’ll probably want to create a Project (so ultimately you can break things up into multiple files, upload other resources, deploy it, etc.).
Here’s where we’re at so far:
See the Pen A Serverless Blog by CodePen (@codepen) on CodePen.
Let’s start with some clean HTML
We’ll ultimately be asking for posts data from our API, and formatting it into HTML. It’s usually a smart idea to start with good HTML first. Here’s an example of that, including a div with an id we can easily target with JavaScript to insert our articles ourselves later.
<main class="site-wrap">
<div id="posts-list">
<article class="article-block">
<h2>This is the title of a blog post</h2>
<time>December 4, 2018</time>
<div class="excerpt">
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Odit harum in ea consequuntur sit neque. Natus accusantium, ducimus, veritatis voluptas animi perspiciatis voluptate similique nihil quam vero dolorem atque error?</p>
</div>
<a href="#0">Read Post</a>
</article>
</div>
</main>
Next, let’s load in the posts via an Ajax call.
var blog_api_url = 'YOUR_FIREBASE_CLOUD_FUNCTIONS_URL';
var posts_list = document.getElementById('posts-list');
var posts_container = posts_list.querySelector('.posts-container');
var loadJsonFromFirebase = function(url, callback) {
var xhr = new XMLHttpRequest();
xhr.addEventListener("load", function () {
callback(JSON.parse(this.response));
});
xhr.open("GET", url);
xhr.send();
};
loadJsonFromFirebase(blog_api_url, function(data) {
let list = document.createElement('div');
Object.keys(data).forEach(function(key) {
let ts = data[key].created;
list.innerHTML += `<article class="article-block">
<h2>${data[key].title}</h2>
${ts.toDateString()}
<div class="excerpt">
<p>${data[key].content.substr(0, 150)}...</p>
</div>
<a href="#${key}">Read Post</a>
</article>`;
});
posts_container.insertBefore(list, posts_container.firstChild);
});
Uh oh! CORS problems…
If we refresh the demo right now, you’ll see our posts don’t load. Viewing the JavaScript console, you can see the problem. We didn’t set up our CORS headers:
To add CORS headers, we can include the CORS module. This command, run from the project’s functions
directory, will grab this module for us and add it to the package.json
file:
npm install --save cors
Next, we’ll update functions/index.js
to use the cors()
call. Start by including the cors
package at the beginning of the file, then wrap the returned data with the cors()
call:
const cors = require('cors')({origin: true});
// The Cloud Functions for Firebase SDK to create Cloud Functions and setup triggers.
const functions = require('firebase-functions');
// The Firebase Admin SDK to access the Firebase Realtime Database.
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);
// List all the posts under the path /posts/
exports.posts = functions.https.onRequest((req, res) => {
cors(req, res, () => {
return admin.database().ref('/posts').once('value').then(function(snapshot) {
res.status(200).send(JSON.stringify(snapshot));
});
});
}
For the changes to take effect, we need to send them to Firebase, which we do with another deploy call. Run this from the project’s functions directory:
firebase deploy --only functions
Now when we reload the demo, our data loads! Yay!
The final code for this section is located in this GitHub repo for your reference.
Step 5) Single Posts (and improving our API)
As with most APIs like this, it should allow us to fetch a list of posts, or full individual posts as well.
Good API design would have us create GET URL’s like /posts
for the entire list (as we already have) and /posts/ID
for a single post.
This means we need to add a parameter to our API URL.
The exports.posts
syntax we used previously doesn’t allow parameters in the URL, so we need to adjust our firebase functions code to use an Express app, which does allow URL parameters in its routes.
We begin this change by installing Express in the functions
directory, and saving the dependency to the package.json
file at the same time:
npm install express --save
Next, we’ll adjust the request handling function by loading Express, then adding the two /posts
and /posts/ID
routes, as detailed in the Express routing guide :
const cors = require("cors")({ origin: true });
// The Cloud Functions for Firebase SDK to create functions & triggers.
const functions = require("firebase-functions");
// The Firebase Admin SDK to access the Firebase Realtime Database.
const admin = require("firebase-admin");
admin.initializeApp(functions.config().firebase);
// The express app used for routing
const app = require("express")();
// List all the posts under the path /posts
app.get(
["/", "/:id"],
functions.https.onRequest((req, res) => {
const postid = req.params.id;
let reference = "posts";
reference += postid ? "/" + postid : "";
cors(req, res, () => {
return admin
.database()
.ref(reference)
.once("value")
.then(function(snapshot) {
if (snapshot.val() !== null) {
res.status(200).send(JSON.stringify(snapshot));
} else {
res.status(200).send({});
}
});
});
})
);
// set the routes up under the /posts/ endpoint
exports.posts = functions.https.onRequest(app);
As always, test locally:
firebase serve --only functions
When testing, /posts/
should return a JSON list of all of our test posts. The route /posts/101
should return the JSON for the single post with ID 101
. The route /posts/aaa
should return an empty JSON object, an error state we’ll fix shortly.
What we might notice in testing is that while /posts/
returns our full JSON posts list, /posts
returns an error Cannot GET null
. This is because the router is receiving an empty URL path. This is an odd quirk of the router that we can work around by replacing our onRequest
call with a check for the missing trailing slash:
exports.posts = functions.https.onRequest((req, res) => {
// Handle routing of /posts without a trailing /,
if (!req.path) {
// prepending "/" keeps query params, path params intact
req.url = `/${req.url}`;
}
return app(req, res);
});
Once we are happy with our manual testing, we can deploy our full list and individual posts API:
firebase deploy --only functions
Once we have our individual post API endpoint, we can use it! Let’s add the HTML and JavaScript to display a single post.
Add the HTML element that an individual post can load into:
<article id="article" class="article-whole">
</article>
Here’s the JavaScript that will grab, format, and display a single post:
...
var getQueryParam = function(param) {
let params = window.location.search.substr(1);
params = params.split("&");
let paramList = {};
for (let i = 0; i {
let post_id = e.target.dataset.post;
if (post_id) {
// load the post from ajax call
loadJsonFromFirebase(blog_api_url + '/' + post_id, function(data) {
let div = document.createElement('div');
let ts = data.created;
div.innerHTML = `
<h1>${data.title}</h1>
${ts.toDateString()}
<div class="article-body">
<p>${data.content}</p>
</div>
`;
post_full.replaceChild(div, post_full.firstChild);
// hide the full list
posts_container.classList.add('start-hidden');
// show the single post
post_full.classList.remove('start-hidden');
});
}
};
// handle the bubbled-up click event
posts_list.addEventListener('click', showPostClick);
...
Now, when we click on the “Read More” links from our posts list, the individual blog entry displays.
We can quickly see where we can add UX enhancements such as adding a spinner when we select “Read More” to indicate to the viewer a post is loading, or a link to return to the list of posts. Creating new posts, however, is more exciting, so let’s do that first.
Here’s our progress so far:
See the Pen A Serverless Blog by CodePen (@codepen) on CodePen.
Step 6) Creating New Blog Posts
What we’ve done so far is fetch existing posts and display that. That’s great, but if we’re really building a blog, we need a way to create new blog posts as well.
Our current process for creating a new post would be to upload a JSON directly through the Firebase interface to add a new post. Possible, but that’s no way to live. Let’s allow ourselves to create a new blog post right from the site itself!
Keeping in line with API best practices, we want to add a POST action to our /posts
API endpoint. The fields we have from our GET endpoint are: author
, content
, created
, and title
. The server should handle the created
time field, so let’s focus on the content
and title
fields.
To the /posts
API endpoint with the POST method, we’ll send JSON formatted like this:
{
"title": "The Post Title",
"content": "This is our example content for this post. It is currently unformatted. Formatting is important, though!"
}
To handle this incoming data, let’s use the Express .post
routing method, adding the new code just before the exports.posts
call:
// create a new post
app.post(
"/",
functions.https.onRequest((req, res) => {
cors(req, res, () => {
// set the content
let content = req.body.content ? req.body.content.trim() : null;
if (content === null) {
res.status(200).send({ error: "Missing content" });
return;
}
// title can be provided, or extracted from the content
let title = req.body.title
? req.body.title.trim()
: req.body.content.substr(0, 20) + "...";
// we want the server to set the time, so use the firebase timestamp
let postDate = admin.database.ServerValue.TIMESTAMP;
let postData = { title: title, content: content, created: postDate };
// create a new ID with empty values
let postKey = admin
.database()
.ref("posts")
.push().key;
// set() will overwrite all values in the entry
// update() will overwrite only the values passed in
admin
.database()
.ref("/posts")
.child(postKey)
.set(postData, function() {
// Read the saved data back out
return admin
.database()
.ref("/posts/" + postKey)
.once("value")
.then(function(snapshot) {
if (snapshot.val() !== null) {
res.status(200).send(JSON.stringify(snapshot));
} else {
res.status(200).send({ error: "Unable to save post" });
}
});
});
});
})
);
We are wrapping our call in the cors()
function again.
Of note, we use the set()
method here for the creation of the post. The set()
method will take all the incoming keys and save them and their data, and only them, removing any other JSON key values in the object. The update()
method will save the new values of only the keys provided, making update()
great for edits, and set()
good for creation.
After adding the post function, we again test locally with firebase serve --only functions
. To POST to the endpoint, we can use curl:
curl -X POST -H "Content-Type:application/json" -d '{ "title" : "this is the post title", "content" : "this is the development post content"}' http://localhost:5000/PROJECT/REGION/posts
To make testing easier, save the CURL calls into a bash script. The bash script makes initial testing of multiple calls with different JSON values easier, too.
If the CURL POST call works, we will see the additional post item on the posts list when we refresh the pen.
When everything is working, do the ol’ firebase deploy --only functions
to deploy.
With these changes, we’ve done several things with our data:
- we’ve ignored the
author
field - we’ve just opened up our application to a huge security hole.
The former is part of the development iteration process, which test cases will help find. The last is a huge concern. We don’t check or clean the incoming data for hacks, we don’t check or clean the data we send back out, and we currently allow anyone to post to the URL, as we don’t have any authentication on the POST endpoint.
Let’s fix that last one by adding authentication. The first thing we want to do is lock down the API by limiting access. Navigate to the project’s Firebase console:
Selecting the Add Firebase to your web app will trigger a popup, with code that will handle authentication in the blog app.
Copy these values, and paste them at the bottom of the HTML in the Pen.
Next, we’ll select the sign-in methods allowed for the blog. In the Firebase console, under the Development tab on the left, select the Authentication menu.
Select the big Set Up Sign-In Method button for a list of providers.
In the list, select the providers you’d like to support. Firebase provides a fantastic list of providers.
Select at least one.
Next, add “codepen.io” to the list of authorized domains on the sign-in method page. If you’re using Projects, add “codepen.plumbing” and “codepen.website” as well.
Next, let’s check for authentication on the incoming POST call in our app.post()
call.
Authentication tokens from the client can be sent in the request headers or in the request body, depending on how we send them from the blog front-end. Let’s send them in the request body’s JSON to reduce complexity, using the token
key.
With this, we can use request.body.token
value to read the request’s authentication token we’re going to pass to the server, and Firebase’s verifyIdToken()
function to confirm the user is valid:
cors(req, res, () => {
// we have something TO post, confirm we are ALLOWED to post
const tokenId = req.body.token;
admin
.auth()
.verifyIdToken(tokenId)
.then(function(decodedUser) {
// the rest of the app.post function inside the
})
.catch(err => res.status(401).send(err));
});
Using the decoded user fields, we can extract the user’s name for storing in our post’s JSON. This value is good for display, but not unique. As such, we’ll use it in this iteration, but refactor to use the unique ID Firebase provides later.
/*
* @see [https://firebase.google.com/docs/auth/admin/verify-id-tokens](https://firebase.google.com/docs/auth/admin/verify-id-tokens)
* decoded User fields: aud, auth_time, email, email_verified, exp,
* iat, iss, name, picture, sub, uid, user_id
*/
// For the first pass, use user's Name. This isn't unique.
let postAuthor = decodedUser.name;
Next, let’s clean the input data from the user.
Following OWASP’s XSS Prevention Cheat Sheet recommendations, we can use the sanitize-html module to strip all HTML from the submitted post data. We can later iterate and add back tags we want to preserve.
As before with the cors
module, in our functions
directory, we add the sanitize-html
module with npm
npm install --save sanitize-html
Then we’ll, again, update functions/index.js
, but this time to use the sanitizeHtml()
call. Start by including the sanitize-html
package after our cors
initializer:
...
const cors = require('cors')({origin: true});
const sanitizeHtml = require('sanitize-html');
...
Then, clean up our content
and title
variables using the sanitizeHtml
call:
...
let content = req.body.content ? sanitizeHtml(req.body.content, { allowedTags: [], allowedAttributes: [] }) : null;
...
let title = req.body.title ? sanitizeHtml(req.body.title, { allowedTags: [], allowedAttributes: [] }) : content.substr(0, 20) + '...';
...
And our last data change will be to include the newly created post’s ID in the returned JSON:
let postJSON = snapshot.val();
postJSON.id = postKey;
res.status(200).send(JSON.stringify(postJSON));
The updated app.post()
function in full:
// create a new post
app.post(
"/",
functions.https.onRequest((req, res) => {
cors(req, res, () => {
let content = req.body.content ? sanitizeHtml(req.body.content, { allowedTags: [], allowedAttributes: [] }) : null;
if (content === null) {
res.status(200).send({ error: "Missing content" });
return;
}
// we have something TO post, confirm we are ALLOWED to post
const tokenId = req.body.token;
admin
.auth()
.verifyIdToken(tokenId)
.then(function(decodedUser) {
// title can be provided, or extracted from the content
let title = req.body.title ? sanitizeHtml(req.body.title, { allowedTags: [], allowedAttributes: [] }) : content.substr(0, 20) + '...';
// we want the server to set the time, so use firebase timestamp
let postDate = admin.database.ServerValue.TIMESTAMP;
/*
* @see [https://firebase.google.com/docs/auth/admin/verify-id-tokens](https://firebase.google.com/docs/auth/admin/verify-id-tokens)
* decoded User fields: aud, auth_time, email, email_verified, exp, iat, iss, name, picture, sub, uid, user_id */
// For the first pass, use user's Name. This isn't unique
let postAuthor = decodedUser.name;
// assembled data
let postData = {
author: postAuthor,
title: title,
content: content,
created: postDate
};
// create a new ID with empty values
let postKey = admin
.database()
.ref("posts")
.push().key;
// set() will overwrite all values in the entry
admin
.database()
.ref("/posts")
.child(postKey)
.set(postData, function() {
// Read the saved data back out
return admin
.database()
.ref("/posts/" + postKey)
.once("value")
.then(function(snapshot) {
if (snapshot.val() !== null) {
let postJSON = snapshot.val();
postJSON.id = postKey;
res.status(200).send(JSON.stringify(postJSON));
} else {
res.status(200).send({ error: "Unable to save post" });
}
});
});
})
.catch(err => res.status(401).send(err));
});
})
);
Now when we test locally with our updated post function, again with firebase serve --only functions
, we have authentication failures.
If we test on a second server, we also have CORS errors again. We can add the CORS middleware in the Express routing with this code outside of the app.get()
and app.post()
calls:
app.use(function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept"
);
next();
});
Test and deploy: firebase serve --only functions
followed by firebase deploy --only functions
.
The last bit we need to add is the ability to submit blog posts from our front-end. We previously added the Firebase authentication code to our HTML. Let’s add a “Sign In” button to trigger the Firebase authentication process, and a “New Post” button, which we can display after sign-in:
<a id="sign-in-button" class="button sign-in-button">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z"></path>
</svg>
Sign In
</a>
<a id="new-post-button" class="button sign-in-button start-hidden">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z"></path>
</svg>
New Post
</a>
Then, add the JavaScript to enable the authentication, which will trigger toggling the buttons’ displays:
// track authenticated user to avoid triggering on refresh
var currentUID;
// Bindings on load.
document.addEventListener("DOMContentLoaded", function() {
document
.getElementById("sign-in-button")
.addEventListener("click", function() {
var provider = new firebase.auth.GoogleAuthProvider();
firebase.auth().signInWithPopup(provider);
});
// Listen for auth state changes
firebase.auth().onAuthStateChanged(onLogInOutChange);
});
// toggle buttons on sign in/out auth changes
var onLogInOutChange = function(user) {
// Ignore token refresh events
if (user && currentUID === user.uid) {
return;
}
// If logged in, show the new post button
if (user) {
currentUID = user.uid;
document.getElementById("sign-in-button").style.display = "none";
document.getElementById("new-post-button").style.display = "block";
} else {
currentUID = null;
document.getElementById("sign-in-button").style.display = "block";
document.getElementById("new-post-button").style.display = "none";
}
};
After adding the JavaScript, give the log in a try. The “New Post” button should display after authentication, and only if a user is authenticated.
We can’t rely on the button’s display state for authentication status – a simple HTML change in the pen can display the “New Post” button. However, because we check authentication status in the POST API method, we are okay if this HTML change happens, as it won’t introduce any security issues.
Now that we have our “New Post” button displaying after authentication, add the new post form:
<section id="new-post" class="article-form" style="display: none;">
<h2>New Post</h2>
<form id="message-form" action="#">
<h4><label for="new-post-title">Title</label></h4>
<input type="text" id="new-post-title">
<h4><label for="new-post-content">Content</label></h4>
<textarea rows="3" id="new-post-content"></textarea>
<button type="submit">Add post</button>
</form>
</section>
And trigger its display on the “New Post” button click:
// show the new post form
document.getElementById('new-post-button').addEventListener('click', function() {
document.getElementById('new-post').style.display = '';
});
Next, connect the message submit button to the form submit to our API endpoint. We’ll want to include the authentication token that Firebase provides for the submission.
// Saves message on form submit.
let messageForm = document.getElementById("message-form");
messageForm.onsubmit = function(e) {
e.preventDefault();
let postTitle = document.getElementById("new-post-title");
let postContent = document.getElementById("new-post-content");
let title = postTitle.value;
let content = postContent.value;
if (content) {
// if the user is logged in, continue
firebase
.auth()
.currentUser.getIdToken(/* forceRefresh */ true)
.then(function(idToken) {
var xhr = new XMLHttpRequest();
xhr.addEventListener("load", function() {
// on success, display post @ top of the list and hide the form
postTitle.value = "";
postContent.value = "";
document.getElementById("new-post").style.display = "none";
let postDetails = JSON.parse(this.response);
let div = document.createElement("div");
let ts = postDetails.created;
div.innerHTML += `<article class="article-block">
<h2>${postDetails.title}</h2>
${ts.toDateString()}
<div class="excerpt">
<p>${postDetails.content.substr(0, 150)}...</p>
</div>
<a>Read Post</a>
</article>`;
posts_container.insertBefore(div, posts_container.firstChild);
});
xhr.open("POST", blog_api_url);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(
JSON.stringify({ title: title, content: content, token: idToken })
);
})
.catch(function(error) {
// otherwise, TODO: handle error, user isn't authenticated yet
});
} else {
// TODO: display box around the missing content field
}
};
We’re here!
See the Pen A Serverless Blog by CodePen (@codepen) on CodePen.
Step 7) Handle URL Changes (and cleaning up)
We now have a list of all of our posts, we can view single posts, and we can create new posts. The site has the authentication “feature” that anyone with a Google account can create a new post, as well as the limitation that we are unable to link to a specific post.
We also know we can make the post rendering easier by creating a template, so let’s start there, before tackling URL handling and authentication limits.
First up, create a render function that can render both a full post, as well as an excerpt:
const renderPost = function(postId, postData, summary = false) {
let ts = new Date(postData.created);
if (summary) {
return `<article class="article-block">
<h2>${postData.title}</h2>
${ts.toDateString()}
<div class="excerpt">
<p>${postData.content.substr(0, 150)}...</p>
</div>
<a>Read Post</a>
</article>`;
} else {
return `<article class="article-block">
<h2>${postData.title}</h2>
${ts.toDateString()}
<div class="excerpt">
<p>${postData.content}</p>
</div>
</article>`;
}
}
Then, change the rendering calls to use the new function:
// example, in messageForm.onsubmit function
...
let postDetails = JSON.parse(this.response);
let div = document.createElement('div');
// this is the replaced rendering section
div.innerHTML += renderPost(postDetails.id, postDetails, false);
posts_container.insertBefore(div, posts_container.firstChild);
...
// example, in showPostClick
...
let div = document.createElement('div');
// this is the replaced rendering function
div.innerHTML = renderPost(post_id, data, false);
post_full.replaceChild(div, post_full.firstChild);
...
// example in initial page data load
...
let list = document.createElement('div');
Object.keys(data).forEach(function(key) {
// this is the new replaced code
list.innerHTML += renderPost(key, data[key], true);
});
posts_container.insertBefore(list, posts_container.firstChild);
...
Next, let’s handle the URL change states – we want the browser URL to reflect the blog’s content, either a specific post or the list of posts, that we load via the Firebase AJAX call. This is the perfect use for the HTML5 History API, updating the URL on content changes, and using the updated URL to load individual posts.
First, a function to extract the post_id
from the URL:
const getAnchorParam = function() {
return (window.location.href.split('#').length > 1) ? window.location.href.split('#')[1] : null;
}
Then, using it when the page loads, replacing the previous page load code with this new function:
// load everything up
let post_id = getAnchorParam();
if (post_id) {
loadJsonFromFirebase(blog_api_url + post_id, function(postData) {
let list = document.createElement('div');
list.innerHTML += renderPost(postData.id, postData, true);
posts_container.insertBefore(list, posts_container.firstChild);
});
} else {
loadJsonFromFirebase(blog_api_url, function(data) {
let list = document.createElement('div');
Object.keys(data).forEach(function(key) {
list.innerHTML += renderPost(key, data[key], true);
});
posts_container.insertBefore(list, posts_container.firstChild);
});
}
And last, we need to add our URL pushState() and popstate() handlers:
At the end of our showPostClick
function, add the code to save the post_id
to the History API queue:
const showPostClick = (e) => {
let post_id = e.target.dataset.post;
if (post_id) {
...
// update the URL if we are not traversing history state
if (!e.skipPushState) {
history.pushState( { post_id: post_id }, null, '#/' + post_id);
}
}
}
And again at the end of the showListClick
function:
const showListClick = (e) => {
// hide the single post
post_full.classList.add('start-hidden');
// show the full list
posts_container.classList.remove('start-hidden');
// adjust the URL back to the full list
if (!e.skipPushState) {
history.pushState({ post_id: 'full-list' }, null, window.location.href.split('#')[0]);
}
Then, we can handle the browser button state by adding a window popstate
listener:
window.addEventListener('popstate', function(e) {
let post_id = e.state ? (e.state.post_id ? e.state.post_id : null) : null;
// we are using render function that adjust the pushstate, skip when calling here.
e.skipPushState = true;
// when the post_id is null, we don't have any managed history, so do nothing
if (post_id == null) {
return;
} else if (post_id == 'full-list') {
showListClick(e);
} else {
// add our missing data-post value so that we can use the showPostClick function
e.target.dataset = e.target.dataset ? e.target.dataset : {};
e.target.dataset.post = post_id;
showPostClick(e);
}
});
In a Pen here on CodePen, the preview displays in an iframe, which hides the URL. If we switch to a CodePen Project we can deploy it to see the URL changes with the pushState
and popState
events.
We’re here!
See the Pen A Serverless Blog by CodePen (@codepen) on CodePen.
Lastly, we want to limit who can write data to our blog. Currently, the demo permissions are open: anyone authenticated with a Google account can read and write posts. We can change these access rules in the Firebase Console.
Navigate to your Firebase console
Select the Database Tab under the Develop menu on the left side, then select the Rules tab in the main window.
We can see the default rules are “Site visitors are authenticated.” We can update our rules so that anyone can access the API by changing the .read
value:
{
"rules": {
".read": true,
".write": "auth != null"
}
}
If we have different API endpoints, more than just our posts
endpoint, we could limit who can read the endpoints, by specifying the endpoints in our rules:
{
"rules": {
"posts": {
".read": true,
".write": "auth != null"
},
"admin": {
".read": "auth.uid === 'abcdef'",
".write": "auth.uid === 'abcdef'"
}
}
}
An easy example for a single user blog is to limit the write access to a single user:
{
"rules": {
"posts": {
".read": true,
".write": "auth.uid === 'abcdef'"
}
}
}
A user’s UID (abcdef
is an invalid UID) can be found in the Authentication section of the Develop menu, in the Users tab in the main window:
This limits who can create a post to only a single authenticated account, which wraps up this serverless tutorial. We can view all our posts, view a single post, submit a new post, use the browser history, and authenticate users, all without spinning up a single server.
Keep on going!
While we now have the basics of a blog hosted on CodePen using Firebase, all serverless, in a production scenario, we would set up development, staging, and production versions, then keep iterating: improving the design and adding features. Here are some ideas:
- Loading icons indicating API calls are happening
- A Markdown editor for styling
- Comments on posts from authenticated guest users
- Voting or favoriting posts
- Editing of posts by implementing the PUT action in our API
- Communal blogs with multiple authors, including filtering by author
- Refactor the author field with unique IDs provided by Firebase authentication, along with their displayed name
- Pagination
- Use something like Angular, Vue, or React
Again, we do all of this without having to spin up a traditional server. Awesome!