jsguides

Node.js Essentials: Building a Web App with Express

Express.js is the de facto standard web framework for Node.js. Its minimalist design, powerful routing, and flexible middleware system make it perfect for building everything from REST APIs to full-stack web applications. In this tutorial, you’ll build a complete blog application from scratch.

What You’ll Build

By the end of this tutorial, you’ll have created a functional blog application with:

  • A home page displaying all blog posts
  • Individual post pages with dynamic routing
  • A simple form to create new posts
  • Clean URLs and proper error handling

This builds on the HTTP server knowledge from our previous tutorial on Building an HTTP Server.

Setting Up Your Project

First, create a new directory and initialize your project:

mkdir my-blog && cd my-blog
npm init -y

Now install Express:

npm install express

Create a file called app.js:

const express = require('express');
const app = express();
const port = 3000;

app.listen(port, () => {
  console.log(`Blog app listening at http://localhost:${port}`);
});

Run it:

node app.js

You’ll see: Blog app listening at http://localhost:3000

Understanding Middleware

Middleware functions are the backbone of Express. They’re functions that have access to the request object (req), response object (res), and the next middleware function (next).

Here’s how a typical request flows through middleware:

// Middleware that logs each request
app.use((req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next(); // Passes control to the next middleware
});

// Another middleware example
app.use((req, res, next) => {
  req.timestamp = new Date(); // Add custom data to request
  next();
});

Pro Tip: Middleware order matters! Define your middleware before your routes to ensure it runs on every request.

Working with Route Parameters

Express makes dynamic routing simple with route parameters. Here’s how to create a blog post page:

// Sample blog data
const posts = {
  '1': {
    title: 'Getting Started with Node.js',
    content: 'Node.js is a JavaScript runtime built on Chrome\'s V8 engine...'
  },
  '2': {
    title: 'Understanding Express Middleware',
    content: 'Middleware functions are the building blocks of Express apps...'
  }
};

// Route parameter - :id captures the dynamic part
app.get('/posts/:id', (req, res) => {
  const post = posts[req.params.id];

  if (!post) {
    return res.status(404).send('Post not found');
  }

  res.send(`
    <h1>${post.title}</h1>
    <p>${post.content}</p>
    <a href="/">Back to home</a>
  `);
});

Visit http://localhost:3000/posts/1 to see your first post.

Processing Form Data

To handle form submissions, you’ll need middleware to parse the request body:

npm install body-parser

Then configure it in your app:

const bodyParser = require('body-parser');

// Parse JSON bodies
app.use(bodyParser.json());

// Parse URL-encoded bodies (forms)
app.use(bodyParser.urlencoded({ extended: true }));

// Handle form submission
app.post('/posts', (req, res) => {
  const { title, content } = req.body;

  if (!title || !content) {
    return res.status(400).send('Title and content are required');
  }

  const id = Date.now().toString();
  posts[id] = { title, content };

  res.redirect(`/posts/${id}`);
});

Create an HTML form:

app.get('/new-post', (req, res) => {
  res.send(`
    <form action="/posts" method="POST">
      <input type="text" name="title" placeholder="Post title" required>
      <textarea name="content" placeholder="Post content" required></textarea>
      <button type="submit">Publish</button>
    </form>
  `);
});

Building the Complete Blog App

Here’s the full application combining everything we’ve learned:

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;

// Middleware
app.use(bodyParser.urlencoded({ extended: true }));
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
  next();
});

// In-memory storage (use a database in production)
const posts = {};

// Home page - list all posts
app.get('/', (req, res) => {
  const postList = Object.entries(posts).map(([id, post]) => `
    <li><a href="/posts/${id}">${post.title}</a></li>
  `).join('');

  res.send(`
    <h1>My Blog</h1>
    <ul>${postList || '<li>No posts yet</li>'}</li></ul>
    <a href="/new-post">Create new post</a>
  `);
});

// New post form
app.get('/new-post', (req, res) => {
  res.send(`
    <form action="/posts" method="POST">
      <div>
        <label>Title:</label>
        <input type="text" name="title" required>
      </div>
      <div>
        <label>Content:</label>
        <textarea name="content" required></textarea>
      </div>
      <button type="submit">Publish</button>
    </form>
  `);
});

// Create new post
app.post('/posts', (req, res) => {
  const { title, content } = req.body;
  const id = Date.now().toString();
  posts[id] = { title, content };
  res.redirect(`/posts/${id}`);
});

// View single post
app.get('/posts/:id', (req, res) => {
  const post = posts[req.params.id];

  if (!post) {
    return res.status(404).send('Post not found');
  }

  res.send(`
    <h1>${post.title}</h1>
    <p>${post.content}</p>
    <a href="/">← Back to home</a>
  `);
});

// 404 handler
app.use((req, res) => {
  res.status(404).send('Page not found');
});

app.listen(port, () => {
  console.log(`Blog app running at http://localhost:${port}`);
});

Serving Static Files

For a real application, you’ll want to serve CSS, images, and client-side JavaScript. Use the built-in static middleware:

// Serve files from the 'public' directory
app.use(express.static('public'));

// With a mount path
app.use('/assets', express.static('public'));

Now you can put CSS files in a public folder and link them like:

<link rel="stylesheet" href="/assets/style.css">

Frequently Asked Questions

What’s the difference between app.use() and app.get()?

app.use() is for middleware that runs on every request, regardless of the HTTP method. app.get() specifically handles GET requests. There are also app.post(), app.put(), app.delete() for other HTTP methods.

How do I handle errors in Express?

Express has a special middleware signature for errors. Define it at the end of your middleware chain:

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send('Something went wrong!');
});

Can I use async functions in Express routes?

Yes! Express supports async route handlers. Just wrap your async code and pass any errors to next():

app.get('/async-route', async (req, res, next) => {
  try {
    const data = await someAsyncOperation();
    res.json(data);
  } catch (error) {
    next(error);
  }
});

Summary

You’ve learned how to:

  1. Set up an Express application
  2. Create and configure middleware
  3. Build dynamic routes with parameters
  4. Process form data with body-parser
  5. Serve static files
  6. Handle errors and 404s

This blog app uses in-memory storage—perfect for learning but not for production. In a real application, you’d connect to a database like MongoDB, PostgreSQL, or even a simple JSON file.

Next Steps

Now that you’ve mastered Express basics, continue with the Node.js Essentials series:

Organizing a Real App

Once you move past the first demo, the most helpful improvement is structure. Routes, middleware, and data access code should live in different files so each concern has a clear home. That makes it easier to find the part of the app that needs work and reduces the chance that a change in one route breaks something unrelated. A small directory shape now can save a lot of cleanup later.

It also helps to think in request flow, not just in functions. A request arrives, the app checks input, applies route-specific logic, and then returns a response. If you can describe that path in plain language, your code will usually be easier to read too. Express is flexible enough to support many styles, but the simplest one is often the best choice for a project that is still growing.

Validation and Failure Paths

Real APIs need more than happy-path handlers. They need input checks, clear status codes, and error messages that tell the caller what went wrong. Validation can happen in middleware before the route handler runs, which keeps the route code focused on business logic. That separation also makes it easier to reuse the same checks across related endpoints.

The same idea applies to failures after the route starts working. If a database call fails, or a file write is rejected, the handler should pass that failure into the normal error path instead of trying to recover in place. A consistent failure shape makes client code easier to write and makes logging far more useful during debugging.

Growing Beyond a Demo

As the app expands, you will usually want configuration, persistence, and environment-specific settings. Express does not force a structure on you, which is both its strength and a trap if you never set one yourself. The app becomes easier to maintain when each layer has a clear role and the route file stays small enough to scan in one sitting.

A good sign that the code is on the right track is when a new route can be added without copying a lot of setup from an old one. When setup starts repeating, move the shared pieces into middleware or helper modules. That keeps the codebase easier to evolve and makes later refactors far less stressful.

Keeping Routes Small

A route should usually read like a short story: receive the request, do the work, send the response. If the route grows beyond that, the logic is probably better split across helpers or middleware. Small routes are easier to test because the expected behavior is easier to isolate and the setup is lighter.

This is one of the simplest ways to keep Express code approachable over time. A smaller route file also makes it easier to spot duplicate work, like repeated validation or repeated formatting. When those patterns become visible, you can move them into shared code instead of letting each route drift in its own direction.

See Also