jsguides

Node.js Essentials: Building a Web App with Express

Express.js is the de facto standard web framework for Node.js. In this Node.js Essentials tutorial, you’ll build a complete blog application from scratch while learning the routing, middleware, and form-handling patterns that power real web apps. Its minimalist design and flexible middleware system make Express perfect for everything from REST APIs to full-stack web applications.

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. The -y flag accepts all defaults so you don’t have to answer prompts manually, which is helpful during setup.

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

Now install Express. The npm install command downloads Express and adds it to your node_modules directory and package.json dependencies.

npm install express

Create a file called app.js. This minimal server creates an Express app and starts listening on port 3000. The callback fires once the server is ready to accept requests.

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

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

Run it with Node. You’ll see the console message confirming the server started.

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.

Once middleware is set up, you can capture dynamic parts of the URL using route parameters. The colon syntax (:id) tells Express to extract that segment and store it in req.params. This is how most REST APIs expose individual resources (/users/:id, /posts/:slug, and so on), and Express handles it with zero configuration beyond the route definition.

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. The body-parser package decodes both JSON payloads and URL-encoded form data so you can access submitted values through req.body.

npm install body-parser

Then configure it in your app. You need both parsers: bodyParser.json() for API clients that send JSON, and bodyParser.urlencoded() for HTML forms. The extended: true option lets you use nested objects in form fields.

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}`);
});

Now you need an HTML form that submits to this route. The form sends a POST request with title and content fields, matching what the handler expects. After a successful submission, the user is redirected to the new post’s page.

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. The complete server wires together all the pieces: middleware for request logging and body parsing, routes for listing posts, showing the form, handling submissions, and displaying individual posts, plus a catch-all 404 handler at the end. Notice how the middleware runs before any route, and the 404 handler is defined last so it only fires when no earlier route matches.

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. Express includes this middleware directly, so no extra package is needed. Call express.static() with a directory path and everything inside becomes publicly accessible. The mount path variant ('/assets') lets you keep a clean URL structure even when files live in a different folder on disk.

// 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 using four parameters. Express recognizes the (err, req, res, next) signature and treats it as an error handler:

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(). Without the try/catch and explicit next(error) call, Express cannot catch promise rejections from async handlers, and the request hangs instead of getting an error response.

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.

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.

Next steps

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

See Also