Building Your First Form: Contact Us
In this assignment, you will create a functional contact form that allows users to submit messages through your website. This form will demonstrate the complete cycle of web form processing: displaying a form to users, handling their submissions with POST requests, validating the data both on the client and server sides, and storing the results in your PostgreSQL database.
You will build this feature using proper MVC architecture, creating dedicated controllers for form handling, models for database operations, and views for user interaction. This assignment introduces server-side validation using the express-validator library, which provides professional-grade validation tools used in production web applications.
Understanding the Problem
Static websites can only display information, but dynamic web applications need to collect and process user input. Contact forms are one of the most common ways websites interact with their visitors, allowing users to ask questions, report issues, or provide feedback without requiring email clients or external services.
However, accepting user input creates several challenges that static sites never face. Users might submit empty forms, provide invalid data, or even attempt to inject malicious content. Your application needs to validate all incoming data, handle errors gracefully, and store valid submissions securely in your database.
This assignment addresses these challenges by implementing both client-side validation (for immediate user feedback) and server-side validation (for security and data integrity). You will create a database table for storing submissions, build routes and controllers to handle form processing, and implement comprehensive validation to ensure data quality.
Preparation
Before building the contact form, you need to create a database table to store contact form submissions. You will use a SQL file approach that follows the same pattern as your existing seed.sql file, but in a separate file dedicated to your practice assignments.
Create a new file at src/models/sql/practice.sql. This file will serve as a dedicated space for all your practice assignment database changes throughout the course. You will add the contact form table now, and future assignments may add additional tables or modifications to this same file. Add the following content:
-- Practice database tables for assignments
-- This file accumulates changes from multiple assignments
-- Add new tables and modifications here as you work through the course
-- Contact form table
CREATE TABLE IF NOT EXISTS contact_form (
id SERIAL PRIMARY KEY,
subject VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
submitted TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Next, modify your src/models/setup.js file to execute this practice SQL file as part of the seeding process. Open setup.js and locate the line that says console.log('Database seeded successfully');. Immediately BEFORE this line (after the await db.query(seedSQL); but before the success log), add the following code:
// Run practice.sql if it exists (for student assignments)
const practicePath = join(__dirname, 'sql', 'practice.sql');
if (fs.existsSync(practicePath)) {
const practiceSQL = fs.readFileSync(practicePath, 'utf8');
await db.query(practiceSQL);
console.log('Practice database tables initialized');
}
This code checks if practice.sql exists and executes it as part of the seed process. The file check ensures your application still works even if you have not created the practice file yet. This practice SQL will run every time you trigger a database re-seed by deleting data from the faculty table.
Restart your server and verify that the table was created by checking your server console. You should see the message "Practice database tables initialized" after "Database seeded successfully". Use pgAdmin to examine your database tables and confirm that a new contact_form table exists with columns for id, subject, message, and submitted timestamp. Do not proceed until you confirm the table exists and your server starts without errors.
Assignment Instructions
1. Install Express-Validator
Install the express-validator library, which provides professional-grade validation tools for Express applications:
pnpm install express-validator
2. Configure Express to Handle POST Requests
Ensure your Express application is set up to parse URL-encoded form data from POST requests. Open your server.js file and add the following middleware in the section where you configure Express middleware, right after the code that serves static files:
// Allow Express to receive and process POST data
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
The first line enables parsing of form data sent with application/x-www-form-urlencoded content type, which is what HTML forms use by default. The second line enables parsing of JSON data, which will be useful for future assignments involving APIs.
This middleware configuration handles text-based form data and JSON, but it does not handle file uploads. File uploads require different middleware that processes multipart/form-data, which is covered in an optional assignment later in the course. For this contact form assignment, you will only work with text input.
3. Add Contact Form Styling
Create a CSS file to style your contact form. Create public/css/contact.css with the following content:
/* Contact Form Styles */
.contact-form {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #fff;
h2 {
color: #2c5aa0;
border-bottom: 2px solid #2c5aa0;
padding-bottom: 0.5rem;
margin: 0 0 1.5rem 0;
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
input,
textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.3s ease;
&:focus {
outline: none;
border-color: #2c5aa0;
}
}
textarea {
resize: vertical;
min-height: 150px;
}
}
button[type="submit"] {
width: 100%;
padding: 1rem;
background-color: #2c5aa0;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.3s ease;
&:hover {
background-color: #1e3a6f;
}
}
}
/* Contact Responses Styles */
.contact-responses {
max-width: 800px;
margin: 2rem auto;
.response-item {
background-color: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
.response-subject {
color: #2c5aa0;
margin: 0 0 1rem 0;
font-size: 1.25rem;
}
.response-message {
color: #333;
line-height: 1.6;
margin: 0 0 1rem 0;
white-space: pre-wrap;
}
.response-date {
color: #666;
font-size: 0.875rem;
margin: 0;
}
}
.no-responses {
text-align: center;
color: #666;
font-style: italic;
padding: 2rem;
}
}
This CSS provides clean, modern styling for both the contact form and the responses page. The nested selectors use modern CSS nesting syntax to keep related styles organized together.
4. Set Up Dynamic CSS Loading
Add middleware to your routes configuration to load the contact form CSS only on contact-related pages. Open src/controllers/routes.js and add the following middleware with your other route-specific middleware (near the catalog and faculty middleware):
// Add contact-specific styles to all contact routes
router.use('/contact', (req, res, next) => {
res.addStyle('<link rel="stylesheet" href="/css/contact.css">');
next();
});
This ensures the contact CSS file is loaded for any route starting with /contact, following the same pattern you implemented in the Housekeeping Refactor assignment.
5. Create the Contact Controller
Create the controller file for your contact form functionality. Create src/controllers/forms/contact.js. You might notice that this file will contain controller functions, validation rules, and route definitions all together in one file. This is a common and practical pattern in Express applications for features of this size.
Add the following content to src/controllers/forms/contact.js:
import { Router } from 'express';
import { body, validationResult } from 'express-validator';
import { createContactForm, getAllContactForms } from '../../models/forms/contact.js';
const router = Router();
/**
* Display the contact form page.
*/
const showContactForm = (req, res) => {
res.render('forms/contact/form', {
title: 'Contact Us'
});
};
/**
* Handle contact form submission with validation.
* If validation passes, save to database and redirect.
* If validation fails, log errors and redirect back to form.
*/
const handleContactSubmission = async (req, res) => {
// Check for validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
// Log validation errors for developer debugging
console.error('Validation errors:', errors.array());
// Redirect back to form without saving
return res.redirect('/contact');
}
// Extract validated data
const { subject, message } = req.body;
try {
// Save to database
await createContactForm(subject, message);
console.log('Contact form submitted successfully');
// Redirect to responses page on success
res.redirect('/contact/responses');
} catch (error) {
console.error('Error saving contact form:', error);
res.redirect('/contact');
}
};
/**
* Display all contact form submissions.
*/
const showContactResponses = async (req, res) => {
let contactForms = [];
try {
contactForms = await getAllContactForms();
} catch (error) {
console.error('Error retrieving contact forms:', error);
}
res.render('forms/contact/responses', {
title: 'Contact Form Submissions',
contactForms
});
};
/**
* GET /contact - Display the contact form
*/
router.get('/', showContactForm);
/**
* POST /contact - Handle contact form submission with validation
*/
router.post('/',
[
body('subject')
.trim()
.isLength({ min: 2 })
.withMessage('Subject must be at least 2 characters'),
body('message')
.trim()
.isLength({ min: 10 })
.withMessage('Message must be at least 10 characters')
],
handleContactSubmission
);
/**
* GET /contact/responses - Display all contact form submissions
*/
router.get('/responses', showContactResponses);
export default router;
In this assignment, validation errors are logged to the console for developer debugging, and the user is simply redirected back to the form. In a future assignment, you will learn how to display helpful error messages to users when validation fails, providing better user experience. For now, the focus is on implementing secure server-side validation and understanding the validation workflow.
Now integrate these routes into your main routes file. Open src/controllers/routes.js, import the contact routes at the top with your other imports:
import contactRoutes from './forms/contact.js';
Then add the contact routes to your router after your other route definitions:
// Contact form routes
router.use('/contact', contactRoutes);
6. Create the Contact Model
Create the model file that handles database operations for contact form submissions. Create src/models/forms/contact.js with the following content:
import db from '../db.js';
/**
* Inserts a new contact form submission into the database.
*
* @param {string} subject - The subject of the contact message
* @param {string} message - The message content
* @returns {Promise<Object>} The newly created contact form record
*/
const createContactForm = async (subject, message) => {
const query = `
INSERT INTO contact_form (subject, message)
VALUES ($1, $2)
RETURNING *
`;
const result = await db.query(query, [subject, message]);
return result.rows[0];
};
/**
* Retrieves all contact form submissions, ordered by most recent first.
*
* @returns {Promise<Array>} Array of contact form records
*/
const getAllContactForms = async () => {
const query = `
SELECT id, subject, message, submitted
FROM contact_form
ORDER BY submitted DESC
`;
const result = await db.query(query);
return result.rows;
};
export { createContactForm, getAllContactForms };
Notice how the model uses parameterized queries with $1 and $2 placeholders instead of concatenating user input directly into SQL strings. This is critical for preventing SQL injection attacks, where malicious users could manipulate your database by inserting SQL commands into form fields.
The PostgreSQL driver automatically escapes and sanitizes the values you pass in the second argument to db.query(), ensuring that user input is treated as data rather than executable SQL code. Never concatenate user input into SQL queries, even if you think the input is safe.
7. Understanding express-validator
The express-validator library provides middleware functions that validate incoming request data. In your controller file, you defined validation rules as an array of middleware functions in the route definition, before your controller function. Each validation rule runs in sequence, checking specific conditions about the submitted data.
The body() function creates a validator for a specific field in the request body. You can chain methods to specify validation rules:
-
trim()removes whitespace from the beginning and end of the value -
isLength({ min: 2 })requires the value to be at least 2 characters long -
withMessage()provides a custom error message if validation fails
In your controller function, the validationResult(req) function collects all validation errors from the request. If errors.isEmpty() returns false, validation failed and you should not process the data. The errors.array() method returns an array of error objects that you can log or display to users.
This separation of concerns keeps your validation rules close to your route definitions while keeping your controller functions focused on handling the validated data. The validation middleware runs before your controller function, ensuring that your controller only receives valid data.
express-validator provides many built-in validation methods you can use:
-
isEmail()validates email addresses -
isURL()validates URLs -
isNumeric()ensures the value contains only numbers -
isLength({ min, max })checks string length -
matches(pattern)validates against a regular expression -
custom()creates custom validation logic
You can also chain sanitization methods like trim(), escape(), and normalizeEmail() to clean and standardize input data before validation.
8. Create the Contact Form Views
Create the user interface for your contact form. First, create the directory structure: src/views/forms/contact/. Then create src/views/forms/contact/form.ejs with the following content:
<%- include('../../partials/header') %>
<main>
<h1>Contact Us</h1>
<p>
Have questions or feedback? Send us a message using the form below and we'll get back to you as soon as possible.
</p>
<form method="POST" action="/contact" class="contact-form">
<div class="form-group">
<label for="subject">Subject</label>
<input type="text" id="subject" name="subject" placeholder="What is this message about?">
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" placeholder="Please provide details about your question or feedback..."></textarea>
</div>
<button type="submit">Send Message</button>
</form>
</main>
<%- include('../../partials/footer') %>
Next, create src/views/forms/contact/responses.ejs to display submitted messages:
<%- include('../../partials/header') %>
<main>
<h1>Contact Form Submissions</h1>
<% if (contactForms && contactForms.length > 0) { %>
<div class="contact-responses">
<% contactForms.forEach(form => { %>
<div class="response-item">
<h3 class="response-subject"><%= form.subject %></h3>
<p class="response-message"><%= form.message %></p>
<p class="response-date">Submitted: <%= new Date(form.submitted).toLocaleDateString() %></p>
</div>
<% }); %>
</div>
<% } else { %>
<p class="no-responses">No contact form submissions yet.</p>
<% } %>
</main>
<%- include('../../partials/footer') %>
Notice how the form uses the POST method to submit data to the server at the /contact endpoint. The input fields have name attributes that correspond to the keys expected in the controller's request body. This naming consistency is crucial for the server to correctly parse and process submitted data.
Since the contact form views sit two levels deep in the directory structure (forms/contact/), the relative paths to shared partials require going up two directory levels. The header becomes ../../partials/header and the footer becomes ../../partials/footer. Include paths must always match your view's actual location relative to the partials directory.
To make your contact form easier to access during development and testing, add navigation links to your header partial. Open src/views/partials/header.ejs and locate your existing navigation list. Add the following list items inside your <nav><ul> structure, alongside your other navigation links:
<li><a href="/contact">Contact</a></li>
<li><a href="/contact/responses">Contact Responses</a></li>
The first link provides easy access to the contact form for users, while the second link allows you to quickly view submissions during development. In a production application, you would typically protect the responses page with authentication so that only administrators can view submissions. For now, leaving it accessible makes testing and development more convenient.
9. Test the Form Functionality
Start your server and thoroughly test your contact form feature. Focus on verifying that the server-side validation and database operations work correctly.
First, test successful submissions:
- Visit http://127.0.0.1:3000/contact to see the form.
- Submit a message with both subject and message fields properly filled out.
- Check your server console for the success message.
- Visit http://127.0.0.1:3000/contact/responses to verify your submission appears in the database.
Next, test the server-side validation by intentionally submitting forms that violate the validation rules. Watch your server console for validation error messages:
- Try submitting with an empty subject field.
- Try submitting with a one-character subject.
- Try submitting with an empty message field.
- Try submitting with a message shorter than 10 characters.
In each case, the form should redirect back to the contact page without saving invalid data to the database. You should see validation errors logged in your server console that explain why the submission was rejected. Verify in the responses page that invalid submissions do not appear in your database.
Also verify that your CSS loads correctly by inspecting the page source or using browser developer tools to confirm that contact.css is loaded on contact pages but not on other pages like the home page or about page.
10. Add Client-Side Validation
Now add HTML5 validation attributes to improve the user experience by catching validation errors before the form is submitted. Update your src/views/forms/contact/form.ejs file by modifying the input elements to include validation attributes:
<input type="text" id="subject" name="subject"
placeholder="What is this message about?"
minlength="2" required>
<textarea id="message" name="message" rows="6"
placeholder="Please provide details about your question or feedback..."
minlength="10" required></textarea>
The required attribute prevents form submission if the field is empty, and minlength enforces the same minimum length requirements as your server-side validation. Test the client-side validation by trying to submit the form with invalid data. The browser should prevent submission and display helpful error messages before the form data is sent to the server.
After adding client-side validation, verify that valid data still processes correctly by submitting a properly filled form and checking that it appears in your responses page. Client-side validation improves user experience, but server-side validation remains essential for security since client-side validation can be bypassed.
You might wonder why we implement validation twice, both on the client and server. Each layer serves a different purpose and neither can replace the other.
Client-side validation provides immediate feedback to users, catching obvious errors before they wait for a server response. This improves user experience and reduces unnecessary server requests. However, client-side validation can be bypassed by malicious users or disabled in the browser.
Server-side validation provides security and data integrity. Since it runs on your server, users cannot bypass it. This is your application's last line of defense against invalid or malicious data. Server-side validation is not optional, even when client-side validation is present.
In professional applications, you always implement both layers. Think of client-side validation as a convenience for honest users, and server-side validation as protection against all users, honest or not.
Key Concepts Summary
This assignment introduced several important concepts for building interactive web applications. You learned how to handle POST requests in Express, which are essential for processing form submissions and other user input. The express-validator library provided professional-grade validation tools that ensure data integrity and security.
The MVC architecture you implemented separates concerns effectively: models handle database operations, controllers process requests and coordinate between models and views, and views handle user interface presentation. This organization makes your code more maintainable and easier to extend.
You also implemented both client-side and server-side validation, understanding that client-side validation improves user experience while server-side validation provides security. The SQL file approach you used for database setup follows industry best practices for managing database schema changes and keeps your database code organized and maintainable.
Most importantly, you created a complete feature that accepts user input, validates it, stores it in a database, and provides an interface to view the results. This pattern forms the foundation for many web application features, from user registration to content management systems.
Experiment and Extend
Try adding additional fields to your contact form, such as a name field or email address. Remember to update the database table structure in your practice.sql file using ALTER TABLE statements, add validation rules for the new fields in your routes file, update your model functions to handle the additional columns, and modify your views to display the additional information.
Experiment with different validation rules using express-validator. Try adding email validation with isEmail(), phone number validation with matches() and a regular expression pattern, or custom validation rules that check for specific content requirements using the custom() method.
Consider adding a simple navigation link to your contact form in your site's main navigation, and add a link from the contact form to the responses page so you can easily view submissions during development. You could also experiment with adding a delete button to each response that allows you to remove submissions from the database.