Build a REST API with AI Coding: Complete Walkthrough
Step-by-step guide to building a production-ready REST API using AI prompts. Covers schema design, CRUD endpoints, auth, testing, and deployment.
Backend development without grinding through documentation for hours.
We’re building a complete Bookmarks API — a REST API where users can save, tag, search, and manage bookmarks. It includes authentication, input validation, error handling, and deployment. All built through AI prompts.
| What You’ll Build | |
|---|---|
| Project | Bookmarks REST API |
| Tech | Node.js + Express + SQLite (via better-sqlite3) |
| Features | CRUD endpoints, JWT auth, input validation, search, pagination |
| Deployment | Railway |
| Time | ~2 hours |
| Difficulty | ⭐⭐ Intermediate |
If you’ve never vibecoded before, start with Build Your First AI App in 30 Minutes — it’s much simpler and teaches the core loop.
Step 1: Project Setup and Schema Design
Before writing any code, we need to think about data. This is the one area where you should plan before prompting.
The Prompt:
I’m building a Bookmarks REST API with Node.js, Express, and SQLite (using better-sqlite3). Design the database schema and project structure for me.
Entities:
- Users: id, email, password_hash, created_at
- Bookmarks: id, user_id (FK), url, title, description, tags (JSON array), created_at, updated_at
Give me:
- Project file structure
- package.json with all dependencies
- Database initialization script that creates tables
- A db.js module that exports the database connection
Setup commands:
mkdir bookmarks-api && cd bookmarks-api
npm init -y
npm install express better-sqlite3 bcryptjs jsonwebtoken cors dotenv
npm install -D nodemon
db.js — Database connection:
const Database = require('better-sqlite3');
const path = require('path');
const db = new Database(path.join(__dirname, 'bookmarks.db'));
// Enable WAL mode for better performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// Create tables
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
url TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_bookmarks_user ON bookmarks(user_id);
CREATE INDEX IF NOT EXISTS idx_bookmarks_url ON bookmarks(user_id, url);
`);
module.exports = db;
Why SQLite? For a project like this, SQLite is perfect — zero setup, no external database service, and it works great up to millions of rows. You can always switch to Postgres later.
File structure:
bookmarks-api/
├── db.js
├── server.js
├── middleware/
│ └── auth.js
├── routes/
│ ├── auth.js
│ └── bookmarks.js
├── .env
├── package.json
└── bookmarks.db (auto-created)
Step 2: Build Authentication
Every API needs auth. Let’s build JWT-based registration and login.
The Prompt:
Create the auth routes for my Express API (routes/auth.js). I need:
- POST /api/auth/register — accepts email + password, hashes password with bcrypt, stores in SQLite, returns JWT
- POST /api/auth/login — accepts email + password, verifies password, returns JWT
- JWT should contain user id and email, expire in 7 days
- Validate email format and password length (min 8 chars)
- Return proper HTTP status codes and error messages
Also create middleware/auth.js that verifies the JWT and attaches user to req.
middleware/auth.js:
const jwt = require('jsonwebtoken');
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = header.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
module.exports = authenticate;
routes/auth.js:
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const db = require('../db');
const router = express.Router();
function generateToken(user) {
return jwt.sign(
{ id: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// Register
router.post('/register', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
if (!isValidEmail(email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
// Check if user exists
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
const password_hash = await bcrypt.hash(password, 12);
const result = db.prepare(
'INSERT INTO users (email, password_hash) VALUES (?, ?)'
).run(email, password_hash);
const token = generateToken({ id: result.lastInsertRowid, email });
res.status(201).json({ token, user: { id: result.lastInsertRowid, email } });
} catch (err) {
console.error('Register error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = generateToken({ id: user.id, email: user.email });
res.json({ token, user: { id: user.id, email: user.email } });
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;
Important detail: Notice we return the same “Invalid credentials” message for both wrong email and wrong password. This prevents attackers from figuring out which emails are registered.
Step 3: CRUD Endpoints
Now the main feature — bookmark management.
The Prompt:
Create routes/bookmarks.js for my Express API. All routes require authentication. I need:
- GET /api/bookmarks — list user’s bookmarks with pagination (?page=1&limit=20) and search (?q=searchterm that searches title, url, and tags)
- POST /api/bookmarks — create a bookmark (url required, title required, description optional, tags optional as array)
- GET /api/bookmarks/:id — get single bookmark (only if owned by user)
- PUT /api/bookmarks/:id — update a bookmark (only if owned by user)
- DELETE /api/bookmarks/:id — delete a bookmark (only if owned by user)
Validate inputs. Return proper status codes. Prevent duplicate URLs per user.
const express = require('express');
const db = require('../db');
const authenticate = require('../middleware/auth');
const router = express.Router();
router.use(authenticate);
// List bookmarks with pagination + search
router.get('/', (req, res) => {
const { page = 1, limit = 20, q } = req.query;
const offset = (Math.max(1, parseInt(page)) - 1) * parseInt(limit);
const lim = Math.min(100, Math.max(1, parseInt(limit)));
let query, countQuery, params;
if (q) {
const search = `%${q}%`;
query = `
SELECT * FROM bookmarks
WHERE user_id = ? AND (title LIKE ? OR url LIKE ? OR tags LIKE ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?
`;
countQuery = `
SELECT COUNT(*) as total FROM bookmarks
WHERE user_id = ? AND (title LIKE ? OR url LIKE ? OR tags LIKE ?)
`;
params = [req.user.id, search, search, search];
} else {
query = `
SELECT * FROM bookmarks
WHERE user_id = ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
`;
countQuery = `SELECT COUNT(*) as total FROM bookmarks WHERE user_id = ?`;
params = [req.user.id];
}
const bookmarks = db.prepare(query).all(...params, lim, offset);
const { total } = db.prepare(countQuery).get(...params);
// Parse tags from JSON string
const parsed = bookmarks.map(b => ({
...b,
tags: JSON.parse(b.tags || '[]')
}));
res.json({
bookmarks: parsed,
pagination: {
page: parseInt(page),
limit: lim,
total,
totalPages: Math.ceil(total / lim)
}
});
});
// Create bookmark
router.post('/', (req, res) => {
const { url, title, description = '', tags = [] } = req.body;
if (!url || !title) {
return res.status(400).json({ error: 'URL and title are required' });
}
try {
new URL(url); // Validate URL format
} catch {
return res.status(400).json({ error: 'Invalid URL format' });
}
if (!Array.isArray(tags)) {
return res.status(400).json({ error: 'Tags must be an array' });
}
// Check duplicate
const existing = db.prepare(
'SELECT id FROM bookmarks WHERE user_id = ? AND url = ?'
).get(req.user.id, url);
if (existing) {
return res.status(409).json({ error: 'URL already bookmarked' });
}
const result = db.prepare(`
INSERT INTO bookmarks (user_id, url, title, description, tags)
VALUES (?, ?, ?, ?, ?)
`).run(req.user.id, url, title, description, JSON.stringify(tags));
const bookmark = db.prepare('SELECT * FROM bookmarks WHERE id = ?')
.get(result.lastInsertRowid);
res.status(201).json({
...bookmark,
tags: JSON.parse(bookmark.tags)
});
});
// Get single bookmark
router.get('/:id', (req, res) => {
const bookmark = db.prepare(
'SELECT * FROM bookmarks WHERE id = ? AND user_id = ?'
).get(req.params.id, req.user.id);
if (!bookmark) {
return res.status(404).json({ error: 'Bookmark not found' });
}
res.json({ ...bookmark, tags: JSON.parse(bookmark.tags) });
});
// Update bookmark
router.put('/:id', (req, res) => {
const existing = db.prepare(
'SELECT * FROM bookmarks WHERE id = ? AND user_id = ?'
).get(req.params.id, req.user.id);
if (!existing) {
return res.status(404).json({ error: 'Bookmark not found' });
}
const {
url = existing.url,
title = existing.title,
description = existing.description,
tags
} = req.body;
if (url) {
try { new URL(url); } catch {
return res.status(400).json({ error: 'Invalid URL format' });
}
}
const finalTags = tags !== undefined ? JSON.stringify(tags) : existing.tags;
db.prepare(`
UPDATE bookmarks
SET url = ?, title = ?, description = ?, tags = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
`).run(url, title, description, finalTags, req.params.id, req.user.id);
const updated = db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(req.params.id);
res.json({ ...updated, tags: JSON.parse(updated.tags) });
});
// Delete bookmark
router.delete('/:id', (req, res) => {
const result = db.prepare(
'DELETE FROM bookmarks WHERE id = ? AND user_id = ?'
).run(req.params.id, req.user.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Bookmark not found' });
}
res.status(204).send();
});
module.exports = router;
Step 4: Wire It Up
The Prompt:
Create the main server.js file that wires together Express with the auth and bookmarks routes. Include CORS, JSON body parsing, error handling middleware, and a health check endpoint.
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/bookmarks', require('./routes/bookmarks'));
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
.env file:
JWT_SECRET=your-secret-key-change-this-in-production
PORT=3000
Add to package.json scripts:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Step 5: Test It
Fire it up and test every endpoint:
npm run dev
Register a user:
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "password123"}'
Response:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": { "id": 1, "email": "[email protected]" }
}
Save that token! Use it for all subsequent requests.
Create a bookmark:
curl -X POST http://localhost:3000/api/bookmarks \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{
"url": "https://vibewerks.com",
"title": "VibeWerks - Learn Vibecoding",
"description": "The best resource for learning to code with AI",
"tags": ["learning", "ai", "coding"]
}'
List bookmarks with search:
curl "http://localhost:3000/api/bookmarks?q=vibe&page=1&limit=10" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
Update a bookmark:
curl -X PUT http://localhost:3000/api/bookmarks/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
-d '{"tags": ["learning", "ai", "coding", "favorite"]}'
Delete a bookmark:
curl -X DELETE http://localhost:3000/api/bookmarks/1 \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
You should get a 204 No Content response.
What Went Wrong: Debugging
Problem 1: “Cannot find module ‘better-sqlite3’”
This happens when better-sqlite3 fails to compile its native module. It needs Python and a C++ compiler.
Fix on Mac:
xcode-select --install
npm rebuild better-sqlite3
Fix on Windows:
npm install --global windows-build-tools
npm rebuild better-sqlite3
Alternative: If you can’t get it working, switch to sql.js (pure JavaScript SQLite) — same API, no native compilation. Ask AI:
Replace better-sqlite3 with sql.js in my project. Keep the same API surface.
Problem 2: JWT Token Not Working
You copy the token but get 401 Unauthorized. Common causes:
- Extra whitespace — make sure you’re not copying a newline character
- Expired token — if you set expiry too short during testing
- Wrong secret — your
.envisn’t loaded (checkdotenvis required at the top of server.js)
Debug by decoding the token at jwt.io — paste it in and check the payload and expiry.
Problem 3: Tags Stored as String Instead of Array
SQLite doesn’t have a native array type, so we store tags as a JSON string. If you forget to JSON.parse() on read or JSON.stringify() on write, you’ll get mangled data.
The code above handles this correctly, but if you modify it, remember: always parse on the way out, stringify on the way in.
Step 6: Add Rate Limiting
Before deploying, add basic rate limiting to prevent abuse.
The Prompt:
Add rate limiting to my Express API. Use express-rate-limit. Limit auth routes to 5 requests per minute and bookmark routes to 30 requests per minute.
npm install express-rate-limit
Add to server.js:
const rateLimit = require('express-rate-limit');
const authLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
message: { error: 'Too many requests. Try again in a minute.' }
});
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
message: { error: 'Rate limit exceeded.' }
});
app.use('/api/auth', authLimiter, require('./routes/auth'));
app.use('/api/bookmarks', apiLimiter, require('./routes/bookmarks'));
Step 7: Deploy to Railway
Railway is the easiest way to deploy a Node.js API. Free tier gives you 500 hours/month.
Prepare for deployment:
- Add a
.gitignore:
node_modules/
bookmarks.db
.env
- Initialize git and push:
git init
git add .
git commit -m "Initial commit: Bookmarks API"
-
Create a Railway account at railway.app
-
Install the CLI and deploy:
npm i -g @railway/cli
railway login
railway init
railway up
-
Set environment variables in Railway dashboard:
JWT_SECRET— generate a strong random stringPORT— Railway sets this automatically, but add it just in case
-
Your API is live at the URL Railway provides (something like
bookmarks-api-production.up.railway.app)
Test the deployed API:
curl https://your-app.up.railway.app/api/health
Should return {"status": "ok", "timestamp": "..."}.
Important: SQLite on Railway works, but the database file won’t persist across deploys. For a production app, you’d want to use Railway’s Postgres add-on. But for a portfolio project or demo, SQLite is fine.
The Complete API Reference
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/health | No | Health check |
| POST | /api/auth/register | No | Create account |
| POST | /api/auth/login | No | Get JWT token |
| GET | /api/bookmarks | Yes | List bookmarks (paginated) |
| POST | /api/bookmarks | Yes | Create bookmark |
| GET | /api/bookmarks/:id | Yes | Get single bookmark |
| PUT | /api/bookmarks/:id | Yes | Update bookmark |
| DELETE | /api/bookmarks/:id | Yes | Delete bookmark |
Query parameters for GET /api/bookmarks:
page— page number (default: 1)limit— items per page (default: 20, max: 100)q— search query (searches title, URL, tags)
Next Steps
You have a working, deployed API. Here’s where to go from here:
- Add a frontend — build a React or Astro app that consumes this API (see Build a SaaS Landing Page with AI)
- Switch to Postgres — for production persistence on Railway
- Add bookmark import — parse browser bookmark exports (HTML format)
- Add OpenAPI docs — use Swagger to auto-generate API documentation
- Add bookmark previews — fetch Open Graph metadata when a bookmark is saved
Check out our other guides:
- Build a Chrome Extension with AI — build a frontend that could use this API
- Prompts That Actually Work — level up your AI prompting
- 7 Mistakes Every New Vibecoder Makes — common traps to avoid
Here’s what’s amazing about vibecoding a backend: the boring parts (validation, error handling, auth boilerplate) are exactly what AI is best at. You focus on the data model and business logic. AI handles the plumbing. That’s the vibe. 🚀