Build NextPress, a Persistent Blog

Build a React blog that saves articles with Express and MySQL.

Introduction

30 Second Summary

A publishing tool only earns trust when your writing survives a restart. Losing finished work turns a useful workflow into a risky one.

In this project, you will build NextPress, a local article feed with a publishing form. The finished workflow connects React to an Express API while MySQL keeps each submitted article available after an API restart.

What You'll Build

You'll publish an article from the browser, restart the API, then refresh the feed to find the same article waiting for you.

By the end of this project, you'll have:

  • A styled article feed where you can watch clear loading, empty, success, and error states while local articles arrive.
  • A controlled publishing form where you enter a title, author, and body before seeing the new article appear at the top of the feed.
  • A persistence demonstration where you restart the API, refresh the browser, and see the same MySQL-backed article remain.
  • Secret Mission: Add a correctly pluralized word-count badge to every article and show the combined word count above the feed.

Do I need any experience before starting?

You'll need Laragon and Visual Studio Code installed on Windows.

Basic JavaScript, HTML, and CSS experience is enough because you do not need a GitHub account or a cloud service.

Before We Start

This first checkpoint locks in the project scope before any hands-on work begins. You are committing to one complete NextPress article create-and-read workflow before adding authentication, roles, media uploads, or deployment.

Verify Laragon and Install Dependencies

The NextPress workflow needs a dependable local runtime before the browser can reach an API. It also needs a responsive MySQL connection before persistence work begins.

Laragon brings these local services together. You will verify Node.js before installing the exact packages used by the client and server.

In this step, get ready to:
  • Verify Laragon's Node.js, npm, and MySQL tools.
  • Create the NextPress workspace with pinned package manifests.
  • Install the dependencies for both parts of NextPress.
Verify Laragon's local tools

Vite runs the client during development. It requires Node.js 20.19+ or 22.12+.

  • Press the Windows key to open Windows Search.
  • Type Laragon into Windows Search.
  • Press Enter to open Laragon.
  • Select Start All in Laragon.

Laragon starts the local services that support the project.

  • Select Menu in Laragon.
  • Select Terminal.
  • Verify the runtime and database connection by running these commands:
node --version
npm --version
mysql --xml -uroot -e "SHOW VARIABLES LIKE 'version%'"

What do these checks prove?

  • The node check reports the installed Node.js version.
  • The npm check confirms the package manager is available.
  • The mysql check connects to the local root account.
  • The -e option runs the version query before the client exits.

✔️ I see a supported Node.js version

Your local foundation passes the check. Node.js meets the 20.19+ or 22.12+ requirement.

You should also see an npm version number. The MySQL command should return version information from the local server.

ⓧ I see an older Node.js version

The installed Node.js version is below Vite's supported range. Laragon can add a newer runtime through its tool menu.

  • Return to the Laragon window.
  • Select Menu.
  • Select Tools.
  • Select Quick add.
  • Select Node.js from the available tools.

Laragon downloads the newer runtime through Quick add.

  • Close the existing Laragon Terminal.
  • Restart Laragon after the download finishes.
  • Select Start All.
  • Return to Menu.
  • Select Terminal.
  • Run the three verification commands shown above again.

ⓧ I see a missing tool or connection error

A missing command means Laragon does not currently expose that tool. A MySQL connection problem can also mean the local services have stopped.

  • Return to the Laragon window.
  • Select Start All.
  • Run the three verification commands again if MySQL was the only failed check.

Use Quick add when the Node.js or MySQL command is still unavailable.

  • Select Menu.
  • Select Tools.
  • Select Quick add.
  • Select the missing Node.js or MySQL tool.
  • Restart Laragon after the download finishes.
  • Run the three verification commands shown above again.

Still missing a local tool?

Confirm you reopened Laragon Terminal after using Quick add. An older terminal can keep the previous command path.

If the check still fails, help me diagnose my Laragon tool setup.

Create the NextPress workspace

A Visual Studio Code workspace keeps the client and server inside one project folder. Each side uses its own JSON package manifest because they depend on different libraries.

  • Press the Windows key to open Windows Search.
  • Type Visual Studio Code into Windows Search.
  • Press Enter to open Visual Studio Code.

You will see the Visual Studio Code window ready for a project folder.

  • Select File from the top menu.
  • Select Open Folder....
  • Navigate to C:\laragon\www in the folder dialog.
  • Select New Folder.

The folder dialog shows a name field for the new project folder.

  • Enter nextpress as the folder name.
  • Press Enter to create the folder.
  • Select Select Folder to open it as the workspace.

The Explorer view now shows nextpress as the open workspace.

  • Select the New Folder button in the Explorer view.

A folder name field opens under nextpress.

  • Enter client as the folder name.
  • Press Enter to create it.
  • Select the New Folder button again.

A second folder name field opens under nextpress.

  • Enter server as the folder name.
  • Press Enter to create it.

The Explorer view now shows separate client and server folders.

  • Select the client folder in the Explorer view.
  • Select the New File... button.

A file name field opens inside the client folder.

  • Enter package.json as the file name.
  • Press Enter to create the file.
  • Add the client package manifest by pasting this code into client\package.json:
{
  "name": "nextpress-client",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.1",
    "vite": "8.3.1"
  }
}

What does this client manifest control?

  • The scripts section defines the development and build tasks.
  • React provides the component system used by the article feed.
  • The React plugin lets Vite process the project's JSX files.
  • Exact package versions keep every learner on the same dependency set.
  • Save client\package.json.
  • Confirm the Explorer view lists package.json inside client.

Does the client manifest show a problem?

Check that every key uses double quotes. Check that the final item in each object has no trailing comma.

If the structure still looks wrong, help me compare my client package manifest.

  • Select the server folder in the Explorer view.
  • Select the New File... button.

A file name field opens inside the server folder.

  • Enter package.json as the file name.
  • Press Enter to create the file.
  • Add the server package manifest by pasting this code into server\package.json:
{
  "name": "nextpress-server",
  "private": true,
  "version": "1.0.0",
  "scripts": {
    "start": "node --env-file=.env index.cjs"
  },
  "dependencies": {
    "cors": "2.8.6",
    "express": "5.2.1",
    "mysql2": "3.24.4"
  }
}

What does this server manifest control?

  • The start script loads settings from .env before running index.cjs.
  • Express provides the API routing layer.
  • CORS permits requests from the local client origin.
  • MySQL2 connects the API to the local database.
  • Save server\package.json.
  • Confirm the Explorer view lists package.json inside server.

Does the server manifest show a problem?

Confirm the file sits inside the server folder. Check that the dependency names match the code exactly.

If the JSON remains invalid, help me inspect my server package manifest.

✔️ Awesome, I've got everything!

Both package manifests are saved in the correct folders. Your workspace is ready for dependency installation.

ⓧ I'd like to double check the full code

  • Compare client\package.json with this complete file:
{
  "name": "nextpress-client",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "6.1.1",
    "vite": "8.3.1"
  }
}

What should match?

Every client script and package version should match this reference. Save the file after correcting any difference.

  • Compare server\package.json with this complete file:
{
  "name": "nextpress-server",
  "private": true,
  "version": "1.0.0",
  "scripts": {
    "start": "node --env-file=.env index.cjs"
  },
  "dependencies": {
    "cors": "2.8.6",
    "express": "5.2.1",
    "mysql2": "3.24.4"
  }
}

What should match?

Every server script and dependency version should match this reference. Save the file after correcting any difference.

Install the client and server dependencies

The npm package manager reads each manifest before downloading its declared packages. Each installation creates a local node_modules folder beside that manifest.

  • Right-click the client folder in the Explorer view.
  • Select Open in Integrated Terminal.
  • Install the client dependencies by running:
npm install

What does this installation do?

The command reads client\package.json. It downloads the pinned React and Vite packages into client\node_modules.

  • Confirm the command finishes without an error.
  • Confirm node_modules appears inside the client folder.

Did the client installation fail?

Confirm the terminal path ends with nextpress\client. Confirm client\package.json is saved.

If npm still cannot install the packages, help me debug my client dependency installation.

  • Right-click the server folder in the Explorer view.
  • Select Open in Integrated Terminal.
  • Install the server dependencies by running:
npm install

What does this installation do?

The command reads server\package.json. It downloads Express, cors, and MySQL2 into server\node_modules.

  • Confirm the command finishes without an error.
  • Confirm node_modules appears inside the server folder.

Did the server installation fail?

Confirm the terminal path ends with nextpress\server. Confirm server\package.json is saved.

If npm still cannot install the packages, help me debug my server dependency installation.

Before you run the final checks, do you expect all three tools to answer successfully now?

  • Switch back to the Laragon Terminal from earlier.
  • Verify the complete local foundation by running these commands:
node --version
npm --version
mysql --xml -uroot -e "SHOW VARIABLES LIKE 'version%'"

What should you see?

  • Node.js prints a version that meets 20.19+ or 22.12+.
  • npm prints its installed version number.
  • MySQL returns version information from the local server.
  • Visual Studio Code shows a node_modules folder under both client and server.

Is the final check incomplete?

Return to the matching check above. Fix the runtime result before repeating either dependency installation.

For an environment-specific issue, help me trace my NextPress setup failure.

Your local foundation is ready. Next, you will turn this empty workspace into a browser-visible React article feed.

Build the Visible Article Feed

Your NextPress dependencies are installed. However, the browser still has nothing to render.

This step creates the first visible slice with React and Vite. A hard-coded article keeps networking failures out of the picture while you prove the rendering path works.

In this step, get ready to:
  • Create the Vite entry files that mount NextPress in the browser.
  • Render an article form and one hard-coded article card.
  • Style the feed and verify its responsive layout.
Create the Vite entry files

Vite starts from index.html. Its root element gives React a specific place to mount the application.

  • In the VS Code Explorer sidebar, select the client folder.
  • Use the new-file control to create index.html inside client.
  • Add the page shell by pasting this code:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>NextPress</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

What does this file do?

  • The root element becomes the mounting point for the React application.
  • The module script loads main.jsx as the browser entry point.
  • The viewport setting lets the layout respond to narrow screens.
  • Save client/index.html.
  • Confirm index.html appears directly inside the client folder in the Explorer sidebar.

Can't find the entry file?

Check that index.html sits directly inside client. Help me check my NextPress entry-file location.

The Vite configuration connects the React plugin to the development server. This lets Vite process the JSX used by the application.

  • Use the new-file control to create vite.config.js inside the client folder.
  • Configure the React plugin by pasting this code:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});

What does this configuration do?

  • The imports load Vite's configuration helper and the React plugin.
  • The plugin teaches Vite how to transform JSX during development.
  • Save client/vite.config.js.
  • Confirm vite.config.js appears beside index.html in the Explorer sidebar.

Is the configuration underlined?

Confirm the file uses the .js extension. Check that both imported package names match the code exactly. Help me debug my Vite configuration.

React needs one JavaScript entry file to create its browser root. That file also loads the component and stylesheet used by NextPress.

  • In the Explorer sidebar, select the client folder.
  • Use the new-folder control to create src inside client.
  • Select the new src folder.
  • Use the new-file control to create main.jsx inside src.
  • Mount the application by pasting this code:
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './App.css';

createRoot(document.getElementById('root')).render(<App />);

What does this entry file do?

  • The imports load React's browser root API and the two local application files.
  • The final line renders App inside the root element from index.html.
  • Save client/src/main.jsx.
  • Confirm main.jsx appears inside client/src in the Explorer sidebar.

Does the entry file show import errors?

The local imports become available after you create App.jsx and App.css below. Check the capitalization now because file names must match the imports. Help me inspect my React entry imports.

Render a hard-coded React feed

A hard-coded article isolates the browser rendering path from the API and database work that comes later. React state holds the sample array while map() turns each entry into a card.

  • In the Explorer sidebar, select the client/src folder.
  • Use the new-file control to create App.jsx inside client/src.
  • Add the sample article data by pasting this code:
import { useState } from 'react';

const sampleArticles = [
  {
    id: 1,
    title: 'Welcome to NextPress',
    author: 'NextPress team',
    body: 'This hard-coded article proves the React rendering path works.',
  },
];

What does the sample data represent?

  • The import makes React's state hook available to the component.
  • The sample array mirrors the article fields that the API supplies later.
  • The numeric id gives React a stable key for the rendered card.
  • Save client/src/App.jsx.
  • Confirm the file contains one object inside sampleArticles.

Does the sample array show a syntax error?

Check the commas after each article field. Confirm the array ends with ];. Help me debug my sample article array.

  • Place your cursor one blank line below the sampleArticles array.
  • Add the reusable article card by pasting this code:
function ArticleCard({ article }) {
  return (
    <article className="article-card">
      <p className="eyebrow">Article #{article.id}</p>
      <h2>{article.title}</h2>
      <p className="byline">By {article.author}</p>
      <p className="article-body">{article.body}</p>
    </article>
  );
}

How does the card work?

  • The component receives one article through its props.
  • The JSX places each field into a semantic article element.
  • The class names give the stylesheet precise targets for the card layout.
  • Save client/src/App.jsx.
  • Confirm ArticleCard appears below the sample array.

Is the card component underlined?

Check that every opening JSX tag has a matching closing tag. Confirm the function closes after its return statement. Help me debug my ArticleCard component.

The main component combines the page heading, form, and feed. Assemble the function from the next two adjacent blocks before saving it.

  • Place your cursor one blank line below ArticleCard.
  • Add the first half of App by pasting this code:
export default function App() {
  const [articles] = useState(sampleArticles);

  return (
    <main className="page-shell">
      <header className="hero">
        <p className="eyebrow">NextPress</p>
        <h1>Publish one durable article at a time.</h1>
        <p>A local React, Express, and MySQL vertical slice.</p>
      </header>

      <section className="workspace">
        <form className="article-form">
          <h2>New article</h2>
          <label>Title<input required /></label>
          <label>Author<input required /></label>
          <label>Body<textarea rows="8" required /></label>
          <button type="submit">Publish article</button>
        </form>

        <section className="feed">
          <div className="feed-heading">
            <h2>Latest articles</h2>
            <span>{articles.length} total</span>
          </div>

What does the first half build?

  • The state hook starts the feed with the sample article array.
  • The hero introduces NextPress above the workspace.
  • The form provides the visual fields that become interactive in the next step.
  • The feed heading reads the current article count from state.
  • Keep your cursor directly below the closing feed-heading element.
  • Complete the App function by pasting this code:
          <div className="article-list">
            {articles.map((article) => <ArticleCard key={article.id} article={article} />)}
          </div>
        </section>
      </section>
    </main>
  );
}

What completes the feed?

  • The article list maps every state item to an ArticleCard component.
  • The article ID becomes the React key for each card.
  • The closing tags complete the feed, workspace, and page shell.
  • Save client/src/App.jsx.
  • Confirm the final line contains the closing brace for App.

Does App show a JSX error?

Check that the second block sits directly below the first block. Confirm the article-list element remains inside the feed section. Help me debug my App component structure.

Style and run the feed

The component structure is ready. The stylesheet now turns that structure into a readable two-column workspace with a responsive single-column layout.

  • In the Explorer sidebar, select the client/src folder.
  • Use the new-file control to create App.css inside client/src.
  • Add the global page rules by pasting this code:
:root {
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  color: #172033;
  background: #f4f7fb;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
}

button,
input,
textarea {
  font: inherit;
}

What do the global rules control?

  • The root rule sets the page font, text colour, and background.
  • The universal box-sizing rule keeps element dimensions predictable.
  • The body rule removes the browser margin and preserves a usable minimum width.
  • The form controls inherit the page font.
  • Save client/src/App.css.
  • Confirm App.css appears beside App.jsx and main.jsx.

Are the global styles underlined?

Check every declaration for a trailing colon or missing closing brace. Help me debug my global CSS rules.

The application now has every file required for its first run. This check proves the entry file, JSX, state, and sample article work together.

  • Start an integrated terminal from the client folder's context menu.

Before you run the client, do you expect the browser to show the sample article or an empty feed?

  • Start the Vite development server by running this command:
npm run dev

What does this command do?

The script starts Vite with the settings from vite.config.js. Keep this terminal running while you work so the browser can receive each saved update.

  • Open http://localhost:5173 in your browser.

You should see the NextPress heading, the article form, and one article titled Welcome to NextPress. That first article proves your React rendering path is live.

Does the page fail to load?

Confirm the Vite terminal is still running from the client folder. Check the terminal for a file name that points to a missing import or JSX typo. Help me debug my first NextPress render.

  • Return to client/src/App.css.
  • Place your cursor one blank line below the form-control rule.
  • Add the button and page-shell styles by pasting this code:
button {
  border: 0;
  border-radius: 12px;
  padding: 0.85rem 1rem;
  color: white;
  background: #3157d5;
  cursor: pointer;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

.page-shell {
  width: min(1120px, calc(100% - 2rem));
  margin: 0 auto;
  padding: 3rem 0;
}

.hero {
  margin-bottom: 2rem;
}

What changes in this chunk?

  • The button gains a blue background, spacing, and rounded corners.
  • The page shell centres the content while preserving space at both edges.
  • The hero margin separates the introduction from the workspace.
  • Save client/src/App.css.
  • Return to the browser.

You should see the content centred on the page. The publish button now appears as a rounded blue control.

Does the button still look plain?

Reload the browser once. Confirm the stylesheet import in main.jsx reads ./App.css. Help me debug my button styles.

  • Return to client/src/App.css.
  • Place your cursor one blank line below the .hero rule.
  • Add the hero and workspace layout by pasting this code:
.hero h1 {
  max-width: 720px;
  margin: 0.35rem 0 0.75rem;
  font-size: clamp(2rem, 6vw, 4rem);
  line-height: 1;
}

.eyebrow {
  margin: 0;
  color: #3157d5;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.workspace {
  display: grid;
  grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
  gap: 1.5rem;
  align-items: start;
}

How does the main layout work?

  • The hero heading scales with the browser width while keeping a readable maximum size.
  • The eyebrow class creates the small blue labels used in the hero and cards.
  • The workspace grid places the form beside a flexible article feed.
  • Save client/src/App.css.
  • Return to the browser.

You should see a larger heading above a two-column workspace. The form occupies the narrower left column.

Is the workspace still one column?

Confirm the browser is wider than 760px. Check that the selector is .workspace with a leading full stop. Help me debug my workspace grid.

  • Return to client/src/App.css.
  • Place your cursor one blank line below the .workspace rule.
  • Add the shared card and form styles by pasting this code:
.article-form,
.article-card {
  border: 1px solid #dce3ef;
  border-radius: 18px;
  background: white;
  box-shadow: 0 12px 30px rgba(23, 32, 51, 0.06);
}

.article-form {
  display: grid;
  gap: 1rem;
  padding: 1.25rem;
  position: sticky;
  top: 1rem;
}

.article-form h2,
.feed h2 {
  margin: 0;
}

What gives the workspace structure?

  • The form and article card share the same white surface, border, corners, and shadow.
  • The form grid creates consistent spacing between its controls.
  • The sticky position keeps the form visible while a longer feed scrolls.
  • Save client/src/App.css.
  • Return to the browser.

You should see the form inside a white rounded panel. The sample article now uses a matching card surface.

Do the panels lack borders?

Confirm both selectors are separated by a comma. Check that the shared rule closes before the individual form rule begins. Help me debug my panel styles.

  • Return to client/src/App.css.
  • Place your cursor one blank line below the shared heading rule.
  • Style the form fields by pasting this code:
.article-form label {
  display: grid;
  gap: 0.4rem;
  font-weight: 700;
}

.article-form input,
.article-form textarea {
  width: 100%;
  border: 1px solid #c8d2e3;
  border-radius: 10px;
  padding: 0.75rem;
  color: inherit;
  background: #fbfcfe;
}

.article-form textarea {
  resize: vertical;
}

How do the fields become usable?

  • Each label becomes a small grid that separates its name from its control.
  • The inputs and textarea fill the form width with matching borders and spacing.
  • The textarea can grow vertically without disturbing the page width.
  • Save client/src/App.css.
  • Return to the browser.

You should see aligned labels above full-width fields. The textarea should resize only up or down.

Are the fields overflowing?

Confirm the global box-sizing rule is present. Check that the field width is 100%. Help me debug my article form fields.

  • Return to client/src/App.css.
  • Place your cursor one blank line below the textarea rule.
  • Style the feed heading and status areas by pasting this code:
.feed-heading {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  align-items: baseline;
  margin-bottom: 1rem;
}

.feed-heading span,
.status,
.byline {
  color: #657089;
}

.status {
  border-radius: 12px;
  padding: 0.8rem 1rem;
  background: #eaf0ff;
}

.article-list {
  display: grid;
  gap: 1rem;
}

What organises the feed?

  • The feed heading places its title and article total on opposite sides.
  • The muted colour separates supporting text from the main content.
  • The article list grid creates space between current and future cards.
  • Save client/src/App.css.
  • Return to the browser.

You should see Latest articles on the left with 1 total aligned on the right.

Is the total below the heading?

Confirm .feed-heading uses flex display. Check that the heading and total remain inside the same feed-heading element in App.jsx. Help me debug my feed heading alignment.

  • Return to client/src/App.css.
  • Place your cursor one blank line below the .article-list rule.
  • Finish the cards and responsive layout by pasting this code:
.article-card {
  padding: 1.35rem;
}

.article-card h2 {
  margin: 0.4rem 0 0.25rem;
}

.byline {
  margin-top: 0;
}

.article-body {
  margin-bottom: 0;
  line-height: 1.65;
  white-space: pre-wrap;
}

@media (max-width: 760px) {
  .workspace {
    grid-template-columns: 1fr;
  }

  .article-form {
    position: static;
  }
}

How does the final styling behave?

  • The article rules add internal spacing and readable text rhythm.
  • The body preserves line breaks that an author includes in an article.
  • The media query changes the workspace to one column on narrow screens.
  • The form returns to normal page flow when the layout stacks.
  • Save client/src/App.css.
  • Return to the browser.

You should see a finished article card with comfortable spacing. The article body should be easy to scan beneath its title and byline.

Does the final card look unstyled?

Confirm the card in App.jsx uses article-card as its class name. Check that the media query has two closing braces. Help me debug my final article-card styles.

✔️ Awesome, I've got everything!

Great work. Save every client file and keep the Vite development server running for the final responsive check.

ⓧ I'd like to double check the full code

Compare each client file with the complete versions below.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>NextPress</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

Entry-file checkpoint

This complete file provides the React mounting element and loads main.jsx.

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
});

Vite checkpoint

This complete file enables the React plugin for the Vite development server.

import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './App.css';

createRoot(document.getElementById('root')).render(<App />);

React entry checkpoint

This complete file imports the application and stylesheet before mounting App.

import { useState } from 'react';

const sampleArticles = [
  {
    id: 1,
    title: 'Welcome to NextPress',
    author: 'NextPress team',
    body: 'This hard-coded article proves the React rendering path works.',
  },
];

function ArticleCard({ article }) {
  return (
    <article className="article-card">
      <p className="eyebrow">Article #{article.id}</p>
      <h2>{article.title}</h2>
      <p className="byline">By {article.author}</p>
      <p className="article-body">{article.body}</p>
    </article>
  );
}

export default function App() {
  const [articles] = useState(sampleArticles);

  return (
    <main className="page-shell">
      <header className="hero">
        <p className="eyebrow">NextPress</p>
        <h1>Publish one durable article at a time.</h1>
        <p>A local React, Express, and MySQL vertical slice.</p>
      </header>

      <section className="workspace">
        <form className="article-form">
          <h2>New article</h2>
          <label>Title<input required /></label>
          <label>Author<input required /></label>
          <label>Body<textarea rows="8" required /></label>
          <button type="submit">Publish article</button>
        </form>

        <section className="feed">
          <div className="feed-heading">
            <h2>Latest articles</h2>
            <span>{articles.length} total</span>
          </div>
          <div className="article-list">
            {articles.map((article) => <ArticleCard key={article.id} article={article} />)}
          </div>
        </section>
      </section>
    </main>
  );
}

Application checkpoint

This complete file holds the sample state, article card, form layout, and mapped feed.

:root {
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
  color: #172033;
  background: #f4f7fb;
  font-synthesis: none;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
}

button,
input,
textarea {
  font: inherit;
}

button {
  border: 0;
  border-radius: 12px;
  padding: 0.85rem 1rem;
  color: white;
  background: #3157d5;
  cursor: pointer;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

.page-shell {
  width: min(1120px, calc(100% - 2rem));
  margin: 0 auto;
  padding: 3rem 0;
}

.hero {
  margin-bottom: 2rem;
}

.hero h1 {
  max-width: 720px;
  margin: 0.35rem 0 0.75rem;
  font-size: clamp(2rem, 6vw, 4rem);
  line-height: 1;
}

.eyebrow {
  margin: 0;
  color: #3157d5;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.workspace {
  display: grid;
  grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
  gap: 1.5rem;
  align-items: start;
}

.article-form,
.article-card {
  border: 1px solid #dce3ef;
  border-radius: 18px;
  background: white;
  box-shadow: 0 12px 30px rgba(23, 32, 51, 0.06);
}

.article-form {
  display: grid;
  gap: 1rem;
  padding: 1.25rem;
  position: sticky;
  top: 1rem;
}

.article-form h2,
.feed h2 {
  margin: 0;
}

.article-form label {
  display: grid;
  gap: 0.4rem;
  font-weight: 700;
}

.article-form input,
.article-form textarea {
  width: 100%;
  border: 1px solid #c8d2e3;
  border-radius: 10px;
  padding: 0.75rem;
  color: inherit;
  background: #fbfcfe;
}

.article-form textarea {
  resize: vertical;
}

.feed-heading {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  align-items: baseline;
  margin-bottom: 1rem;
}

.feed-heading span,
.status,
.byline {
  color: #657089;
}

.status {
  border-radius: 12px;
  padding: 0.8rem 1rem;
  background: #eaf0ff;
}

.article-list {
  display: grid;
  gap: 1rem;
}

.article-card {
  padding: 1.35rem;
}

.article-card h2 {
  margin: 0.4rem 0 0.25rem;
}

.byline {
  margin-top: 0;
}

.article-body {
  margin-bottom: 0;
  line-height: 1.65;
  white-space: pre-wrap;
}

@media (max-width: 760px) {
  .workspace {
    grid-template-columns: 1fr;
  }

  .article-form {
    position: static;
  }
}

Stylesheet checkpoint

This complete file styles the page, form, feed, article card, and narrow-screen layout.

Before you narrow the browser, which column do you expect to move below the other?

  • Resize the browser until its content area is narrower than 760px.

You should see the feed move below the form in one clean column. The fields and card should remain inside the browser width.

You have the first visible NextPress slice working. The heading, form, and sample article now render through one responsive React page.

Your browser rendering path is ready. Next, you will replace the fixed article with an API-backed workflow and expose why temporary memory cannot protect published content.

Add the In-Memory API

Your styled React feed proves the browser can render articles. The hard-coded array cannot accept anything from the form.

This step adds an Express API that keeps articles in application memory. You will connect the feed to it before testing what happens during an API restart.

In this step, get ready to:
  • Create GET and POST routes for an in-memory article collection.
  • Load articles into the React feed through the API.
  • Publish an article before testing an API restart.
Create the temporary Express API

The API needs one route that returns articles. It also needs another route that accepts a new article.

A temporary array gives both routes a shared source of data. This keeps the first request cycle small enough to inspect from end to end.

  • In Visual Studio Code's Explorer sidebar, expand the existing server folder.
  • Use the folder's context menu to create index.cjs inside server.
  • Add the API foundation and GET route by pasting this code into server\index.cjs:
const express = require('express');
const cors = require('cors');

const app = express();
const PORT = 3000;
let nextId = 2;
let articles = [{ id: 1, title: 'Temporary seed article', author: 'NextPress team', body: 'This article returns whenever the API restarts.' }];

app.use(cors());
app.use(express.json());

app.get('/api/articles', (req, res) => {
  res.json(articles);
});

app.listen(PORT, () => console.log(`NextPress API listening on http://localhost:${PORT}`));

How Does This API Work?

  • The articles array starts with one seed article so the first request always has visible data.
  • The CORS middleware allows the browser client to request data from the separate API origin.
  • The JSON middleware prepares incoming request bodies for the POST route you add next.
  • The GET route sends the current articles array back to the client.
  • Save server\index.cjs.
  • Start an integrated terminal at the existing server folder from its context menu.
  • Start the API by running this command:
npm start

What Does This Command Do?

The npm start command runs the start script from server\package.json. That script launches the API entry file.

You should see NextPress API listening on http://localhost:3000 in the new terminal.

  • Create a new browser tab.
  • Enter http://localhost:3000/api/articles in the address bar.

You should see a JSON array containing Temporary seed article. That is your first browser-to-API response working.

Cannot Load the Article Route?

Confirm that the API terminal is running from the server folder. Check that the terminal still shows the listening message.

If the route still does not load, help me debug the local Express API.

The GET route proves the API can read the temporary collection. The POST route completes the cycle by validating a request before adding its article to the same array.

  • In server\index.cjs, find the app.listen line.
  • Insert this POST route directly above that line:
app.post('/api/articles', (req, res) => {
  const title = req.body?.title?.trim();
  const author = req.body?.author?.trim();
  const body = req.body?.body?.trim();
  if (!title || !author || !body) return res.status(400).json({ message: 'Title, author, and body are required.' });
  const article = { id: nextId++, title, author, body };
  articles = [article, ...articles];
  res.status(201).json(article);
});

What Does the POST Route Do?

  • The route trims the submitted title, author, and body values.
  • The validation returns an error response when any required value is empty.
  • The nextId value gives each created article a new identifier.
  • The new article is placed at the start of the array before the API returns it.
  • Save server\index.cjs.
  • Return to the API terminal.
  • Press Ctrl+C to stop the current API process.
  • Start the updated API by running this command:
npm start

Why Restart the API?

The running process loaded the earlier version of server\index.cjs. Restarting it loads the new POST route into the API.

You should see the listening message again. The API now supports both article routes.

API Does Not Restart?

Check the POST route for a missing brace or parenthesis. Confirm that app.listen remains below the route.

If the process still stops during startup, help me find the syntax problem in server/index.cjs.

✔️ Awesome, I've got everything!

Your saved server\index.cjs now has the complete temporary API.

ⓧ I'd like to double check the full code

const express = require('express');
const cors = require('cors');

const app = express();
const PORT = 3000;
let nextId = 2;
let articles = [{ id: 1, title: 'Temporary seed article', author: 'NextPress team', body: 'This article returns whenever the API restarts.' }];

app.use(cors());
app.use(express.json());

app.get('/api/articles', (req, res) => {
  res.json(articles);
});

app.post('/api/articles', (req, res) => {
  const title = req.body?.title?.trim();
  const author = req.body?.author?.trim();
  const body = req.body?.body?.trim();
  if (!title || !author || !body) return res.status(400).json({ message: 'Title, author, and body are required.' });
  const article = { id: nextId++, title, author, body };
  articles = [article, ...articles];
  res.status(201).json(article);
});

app.listen(PORT, () => console.log(`NextPress API listening on http://localhost:${PORT}`));
Connect the React feed and form

The browser now has a live source for article data. A React effect can load that source after the component mounts.

The form also needs controlled inputs so React always knows the current field values. The submit handler can package those values into a POST request.

  • Switch back to the existing client\src\App.jsx tab.
  • Find the existing React import and the sampleArticles declaration.
  • Replace them with this API configuration:
import { useEffect, useState } from 'react';

const API_URL = 'http://localhost:3000/api/articles';
const emptyForm = { title: '', author: '', body: '' };

What Does This Configuration Do?

  • The useEffect import lets the component load articles after its first render.
  • The API_URL constant gives every request one shared endpoint.
  • The emptyForm object provides the initial values for all three fields.
  • Inside App(), select from export default function App() { through the existing articles state line.
  • Replace that selection with the state and loading effect below:
export default function App() {
  const [articles, setArticles] = useState([]);
  const [form, setForm] = useState(emptyForm);
  const [status, setStatus] = useState('Loading articles...');
  const [isSaving, setIsSaving] = useState(false);

  useEffect(() => {
    let ignore = false;
    async function loadArticles() {
      try {
        const response = await fetch(API_URL);
        const data = await response.json();
        if (!ignore) {
          setArticles(data);
          setStatus(data.length ? '' : 'No articles yet. Create the first one.');
        }
      } catch (error) {
        if (!ignore) setStatus(error.message);
      }
    }
    loadArticles();
    return () => { ignore = true; };
  }, []);

How Does Article Loading Work?

  • The component starts with an empty article list and a loading message.
  • The effect requests the GET route once after the component mounts.
  • The response replaces the empty list with the API's current articles.
  • The cleanup flag prevents an outdated request from changing state after the effect is cleaned up.
  • Save client\src\App.jsx.
  • Return to the existing NextPress browser tab.

Great progress. The hard-coded card is replaced by Temporary seed article, which proves the feed now reads from the API.

Feed Stuck on the Loading Message?

Confirm that the API terminal still shows its listening message. Check that API_URL matches the article route exactly.

If the seed article still does not load, help me debug the React request.

A controlled form stores each keystroke in the form state object. The same object becomes the JSON body when the visitor publishes an article.

  • Place this change handler directly below the completed useEffect block:
  function handleChange(event) {
    const { name, value } = event.target;
    setForm((current) => ({ ...current, [name]: value }));
  }

How Does the Change Handler Work?

Each input supplies its own name and current value. The state update changes only that matching field.

  • Select the existing return section from return ( through the final closing brace of App().
  • Replace that selection with the submit handler and connected interface below:
  async function handleSubmit(event) {
    event.preventDefault();
    setIsSaving(true);
    setStatus('Saving article...');
    try {
      const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) });
      const createdArticle = await response.json();
      if (!response.ok) throw new Error(createdArticle.message || 'The article could not be saved.');
      setArticles((current) => [createdArticle, ...current]);
      setForm(emptyForm);
      setStatus('Article saved.');
    } catch (error) {
      setStatus(error.message);
    } finally {
      setIsSaving(false);
    }
  }

  return <main className="page-shell"><header className="hero"><p className="eyebrow">NextPress</p><h1>Publish one durable article at a time.</h1><p>A local React, Express, and MySQL vertical slice.</p></header><section className="workspace"><form className="article-form" onSubmit={handleSubmit}><h2>New article</h2><label>Title<input name="title" value={form.title} onChange={handleChange} maxLength="160" required /></label><label>Author<input name="author" value={form.author} onChange={handleChange} maxLength="100" required /></label><label>Body<textarea name="body" value={form.body} onChange={handleChange} rows="8" required /></label><button type="submit" disabled={isSaving}>{isSaving ? 'Publishing...' : 'Publish article'}</button></form><section className="feed" aria-live="polite"><div className="feed-heading"><h2>Latest articles</h2><span>{articles.length} total</span></div>{status && <p className="status">{status}</p>}<div className="article-list">{articles.map((article) => <ArticleCard key={article.id} article={article} />)}</div></section></section></main>;
}

How Does Publishing Work?

  • The submit handler prevents the browser from reloading the page.
  • The POST request sends the current form object as JSON.
  • A successful response is prepended to the visible article list.
  • The form resets after the API confirms that it created the article.
  • Save client\src\App.jsx.
  • Return to the existing NextPress browser tab.
  • Enter Persistence test in the title field.
  • Enter NextPress learner in the author field.
  • Enter This article lives in application memory. in the body field.
  • Click Publish article.

You should see the new article at the top of the feed. The status message should read Article saved..

Article Does Not Appear?

Confirm that every input has a matching name property. Check that the form uses handleSubmit.

If publishing still fails, help me debug the controlled form and POST request.

✔️ Awesome, I've got everything!

Your saved client\src\App.jsx now loads and creates articles through the API.

ⓧ I'd like to double check the full code

import { useEffect, useState } from 'react';

const API_URL = 'http://localhost:3000/api/articles';
const emptyForm = { title: '', author: '', body: '' };

function ArticleCard({ article }) {
  return (
    <article className="article-card">
      <p className="eyebrow">Article #{article.id}</p>
      <h2>{article.title}</h2>
      <p className="byline">By {article.author}</p>
      <p className="article-body">{article.body}</p>
    </article>
  );
}

export default function App() {
  const [articles, setArticles] = useState([]);
  const [form, setForm] = useState(emptyForm);
  const [status, setStatus] = useState('Loading articles...');
  const [isSaving, setIsSaving] = useState(false);

  useEffect(() => {
    let ignore = false;
    async function loadArticles() {
      try {
        const response = await fetch(API_URL);
        const data = await response.json();
        if (!ignore) {
          setArticles(data);
          setStatus(data.length ? '' : 'No articles yet. Create the first one.');
        }
      } catch (error) {
        if (!ignore) setStatus(error.message);
      }
    }
    loadArticles();
    return () => { ignore = true; };
  }, []);

  function handleChange(event) {
    const { name, value } = event.target;
    setForm((current) => ({ ...current, [name]: value }));
  }

  async function handleSubmit(event) {
    event.preventDefault();
    setIsSaving(true);
    setStatus('Saving article...');
    try {
      const response = await fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) });
      const createdArticle = await response.json();
      if (!response.ok) throw new Error(createdArticle.message || 'The article could not be saved.');
      setArticles((current) => [createdArticle, ...current]);
      setForm(emptyForm);
      setStatus('Article saved.');
    } catch (error) {
      setStatus(error.message);
    } finally {
      setIsSaving(false);
    }
  }

  return <main className="page-shell"><header className="hero"><p className="eyebrow">NextPress</p><h1>Publish one durable article at a time.</h1><p>A local React, Express, and MySQL vertical slice.</p></header><section className="workspace"><form className="article-form" onSubmit={handleSubmit}><h2>New article</h2><label>Title<input name="title" value={form.title} onChange={handleChange} maxLength="160" required /></label><label>Author<input name="author" value={form.author} onChange={handleChange} maxLength="100" required /></label><label>Body<textarea name="body" value={form.body} onChange={handleChange} rows="8" required /></label><button type="submit" disabled={isSaving}>{isSaving ? 'Publishing...' : 'Publish article'}</button></form><section className="feed" aria-live="polite"><div className="feed-heading"><h2>Latest articles</h2><span>{articles.length} total</span></div>{status && <p className="status">{status}</p>}<div className="article-list">{articles.map((article) => <ArticleCard key={article.id} article={article} />)}</div></section></section></main>;
}
Restart the API and inspect the feed

A complete article workflow needs to survive the routine restarts that happen during development. This restart tests whether the current storage approach meets that requirement.

Before you restart the API, do you expect your published article to remain in the feed after a browser refresh?

  • Return to the terminal running the API.
  • Press Ctrl+C to stop only the API process.
  • Start a fresh API process by running this command:
npm start

What Does This Restart Change?

The command starts a fresh API process from the saved server\index.cjs file. The Vite client keeps running in its separate terminal.

  • Return to the existing NextPress browser tab.
  • Refresh http://localhost:5173.

You will see that your published article has disappeared. Only Temporary seed article returns.

Why Did the Article Disappear?

The articles array lived inside the Node.js process. Stopping that process discarded every change made after startup.

The fresh process recreated the array from server\index.cjs. That visible loss is the persistence problem your next step solves.

You now have a complete create-and-read workflow with a proven storage weakness. Next, you will replace temporary application memory with MySQL persistence.

Persist Articles in MySQL

The restart test exposed the limit of your Express API. Its in-memory articles disappeared when the process stopped.

This step moves those articles into MySQL while preserving the existing React request contract. Your form will create durable records that survive API restarts.

In this step, get ready to:
  • Store the local database settings in server/.env.
  • Replace the in-memory storage with a MySQL connection pool.
  • Prove that a published article survives an API restart.
Create the database settings file

Environment-specific database details belong in environment variables. The API can read these values without embedding local settings in its logic.

  • Select the server folder in the Visual Studio Code file tree.
  • Create server/.env using the file tree's new-file control.
  • Add the database settings by pasting this configuration:
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=

What Do These Settings Control?

  • The host points the API to the MySQL service on your machine.
  • The port identifies the local MySQL connection point.
  • The user selects your confirmed local root account.
  • The empty password matches the account configuration you verified earlier.
  • Save server/.env.
  • Confirm .env appears beside index.cjs in the server folder.

Cannot See the Environment File?

Check that the filename is exactly .env. Confirm it has no extra file extension.

If the location still looks wrong, help me check the environment file.

✔️ Awesome, I've got everything!

Your local database settings are saved beside the API entry file.

ⓧ I'd like to double check the full code

DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
Replace memory with MySQL

The API needs a durable storage layer before its routes can preserve articles. MySQL2 provides promise-based connections that fit the existing asynchronous route flow.

Why MySQL2 Instead of an ORM?

MySQL2 keeps the SQL boundary visible. You can see exactly when the API creates a table or inserts an article.

An ORM would add another abstraction before you have observed the persistence workflow directly. Prepared queries keep this first database slice focused.

  • Switch back to the API terminal from earlier.
  • Stop the API by pressing Ctrl+C.
  • Select server/index.cjs in the Visual Studio Code file tree.
  • Add this import below the existing cors import:
const mysql = require('mysql2/promise');

What Does This Import Do?

This loads the promise-based MySQL2 interface. The initializer can use await for database connections or queries.

  • Save server/index.cjs.
  • Confirm the new import appears directly below the cors import.
  • Add the database name plus pool variable below the PORT declaration by pasting these lines:
const DATABASE_NAME = 'nextpress';
let pool;

What Will These Values Hold?

  • The DATABASE_NAME constant gives every database operation one destination.
  • The pool variable will hold reusable MySQL connections.
  • Save server/index.cjs.
  • Check the updated file for syntax errors by starting the API with this command:
npm start

What Does This Check Prove?

The package script loads .env before running index.cjs. A listening message confirms the new import plus declarations parse successfully.

You should see NextPress API listening on http://localhost:3000 in the terminal.

API Stops During Startup?

Confirm mysql2 is listed in server/package.json. Check that the import uses the promise path shown above.

If the process still stops, help me debug the MySQL2 import.

  • Stop the API by pressing Ctrl+C.
  • Replace the existing app.use(cors()); line with this origin-specific middleware:
app.use(cors({ origin: 'http://localhost:5173' }));

Why Set a Specific Origin?

This CORS setting accepts browser requests from the local Vite client. The API routes remain available at the same addresses.

  • Save server/index.cjs.
  • Confirm the allowed origin matches http://localhost:5173 exactly.

The initializer first connects without a database name. That connection can create the database before the pool tries to use it.

  • Add the first version of initializeDatabase() above the existing app.get() route by pasting this function:
async function initializeDatabase() {
  const connection = await mysql.createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
  });

  await connection.query(`CREATE DATABASE IF NOT EXISTS \`${DATABASE_NAME}\``);
  await connection.end();
}

How Does Database Creation Work?

  • The connection reads your local settings from process.env.
  • The query creates the nextpress database when it is missing.
  • The temporary server-level connection closes after the query finishes.
  • Save server/index.cjs.
  • Check the new function's syntax by starting the API again:
npm start

What Happens in This Check?

The existing startup line still starts the API directly. A listening message confirms the initializer is valid JavaScript before you connect it to startup.

You should see the API listening message again.

  • Stop the API by pressing Ctrl+C.
  • Insert the pool plus table setup before the closing brace of initializeDatabase() by pasting this code:
  pool = mysql.createPool({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: DATABASE_NAME,
    waitForConnections: true,
    connectionLimit: 10,
    queueLimit: 0,
  });

  await pool.execute(`
    CREATE TABLE IF NOT EXISTS articles (
      id INT NOT NULL AUTO_INCREMENT,
      title VARCHAR(160) NOT NULL,
      author VARCHAR(100) NOT NULL,
      body TEXT NOT NULL,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
      PRIMARY KEY (id)
    )
  `);

What Does the Pool Initialize?

  • The connection pool reuses database connections across API requests.
  • The table stores every article as a durable row.
  • The generated identifier becomes the article ID returned to the client.
  • The timestamp records when MySQL creates each row.
  • Save server/index.cjs.
  • Confirm the table setup remains inside initializeDatabase().
  • Replace the existing app.listen() line at the bottom of server/index.cjs with this startup chain:
initializeDatabase().then(() => app.listen(PORT, () => console.log(`NextPress API listening on http://localhost:${PORT}`))).catch((error) => { console.error('Database startup failed:', error); process.exit(1); });

Why Initialize Before Listening?

The API starts listening only after the database plus table are ready. A startup failure stops the process before a route can use an unavailable pool.

  • Save server/index.cjs.
  • Create the database plus table by starting the API:
npm start

What Happens During Startup?

The initializer creates the database when needed. It creates the table next.

The listening message appears only after both operations succeed. Reaching that message proves the API has connected to its durable storage layer.

You should see NextPress API listening on http://localhost:3000.

Database Startup Failed?

Confirm Laragon remains running. Check every setting name in server/.env for spelling differences.

If startup still fails, help me debug the database connection.

The route addresses stay unchanged. Only their storage operations move from an array to database queries.

  • Stop the API by pressing Ctrl+C.
  • Replace the existing app.get() route with this database-backed version:
app.get('/api/articles', async (req, res) => {
  try {
    const [rows] = await pool.execute(`
      SELECT id, title, author, body, created_at
      FROM articles
      ORDER BY created_at DESC, id DESC
    `);
    res.json(rows);
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Could not load articles.' });
  }
});

How Does the Feed Load Now?

  • The pool selects rows from the articles table.
  • The ordering places the newest rows first.
  • The route returns those rows as JSON.
  • The error response gives the client a useful failure message.
  • Save server/index.cjs.
  • Start the API with the database-backed read route:
npm start

What Does the Client Request Now?

The existing React effect requests the same endpoint. The GET route now returns rows from MySQL.

  • Return to the browser at http://localhost:5173.
  • Refresh the page.

You should see No articles yet. Create the first one. when the new table is empty. The temporary seed article is gone because the feed now reflects database rows.

  • Switch back to the API terminal.
  • Stop the API by pressing Ctrl+C.
  • Replace the existing app.post() route with this database-backed version:
app.post('/api/articles', async (req, res) => {
  const title = req.body?.title?.trim();
  const author = req.body?.author?.trim();
  const body = req.body?.body?.trim();

  if (!title || !author || !body) return res.status(400).json({ message: 'Title, author, and body are required.' });
  if (title.length > 160 || author.length > 100) return res.status(400).json({ message: 'Title or author is too long.' });

  try {
    const [result] = await pool.execute('INSERT INTO articles (title, author, body) VALUES (?, ?, ?)', [title, author, body]);
    return res.status(201).json({ id: result.insertId, title, author, body });
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: 'Could not save the article.' });
  }
});

How Does the Insert Work?

The route validates each submitted field before writing a row. The length checks match the table limits.

The insert uses prepared statements with placeholders. MySQL2 sends the article values separately from the SQL text.

The response uses insertId so React can render the new article immediately.

  • Remove the old nextId declaration from server/index.cjs.
  • Remove the old articles array declaration.
  • Save server/index.cjs.
  • Start the completed database-backed API:
npm start

What Does the Completed Startup Prove?

The process initializes MySQL before exposing either route. GET plus POST now use the same pool.

You should see the API listening message. Keep this terminal running for the persistence test.

Routes Return a Database Error?

Confirm initializeDatabase() runs before app.listen(). Check that both routes use pool.execute().

If a route still fails, help me compare the MySQL routes.

✔️ Awesome, I've got everything!

Your API now creates its database plus table during startup. Its routes read or write durable rows.

ⓧ I'd like to double check the full code

const express = require('express');
const cors = require('cors');
const mysql = require('mysql2/promise');

const app = express();
const PORT = 3000;
const DATABASE_NAME = 'nextpress';
let pool;

app.use(cors({ origin: 'http://localhost:5173' }));
app.use(express.json());

async function initializeDatabase() {
  const connection = await mysql.createConnection({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
  });

  await connection.query(`CREATE DATABASE IF NOT EXISTS \`${DATABASE_NAME}\``);
  await connection.end();

  pool = mysql.createPool({
    host: process.env.DB_HOST,
    port: Number(process.env.DB_PORT),
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    database: DATABASE_NAME,
    waitForConnections: true,
    connectionLimit: 10,
    queueLimit: 0,
  });

  await pool.execute(`
    CREATE TABLE IF NOT EXISTS articles (
      id INT NOT NULL AUTO_INCREMENT,
      title VARCHAR(160) NOT NULL,
      author VARCHAR(100) NOT NULL,
      body TEXT NOT NULL,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
      PRIMARY KEY (id)
    )
  `);
}

app.get('/api/articles', async (req, res) => {
  try {
    const [rows] = await pool.execute(`
      SELECT id, title, author, body, created_at
      FROM articles
      ORDER BY created_at DESC, id DESC
    `);
    res.json(rows);
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Could not load articles.' });
  }
});

app.post('/api/articles', async (req, res) => {
  const title = req.body?.title?.trim();
  const author = req.body?.author?.trim();
  const body = req.body?.body?.trim();

  if (!title || !author || !body) return res.status(400).json({ message: 'Title, author, and body are required.' });
  if (title.length > 160 || author.length > 100) return res.status(400).json({ message: 'Title or author is too long.' });

  try {
    const [result] = await pool.execute('INSERT INTO articles (title, author, body) VALUES (?, ?, ?)', [title, author, body]);
    return res.status(201).json({ id: result.insertId, title, author, body });
  } catch (error) {
    console.error(error);
    return res.status(500).json({ message: 'Could not save the article.' });
  }
});

initializeDatabase().then(() => app.listen(PORT, () => console.log(`NextPress API listening on http://localhost:${PORT}`))).catch((error) => { console.error('Database startup failed:', error); process.exit(1); });
Prove the article survives a restart

The client already uses the correct endpoint for loading or creating articles. One response check ensures a failed GET request reaches the existing error state.

  • Select client/src/App.jsx in the Visual Studio Code file tree.
  • Find the GET request inside loadArticles().
  • Add this response check immediately below the fetch() line:
        if (!response.ok) {
          throw new Error('The article feed could not be loaded.');
        }

What Does This Check Protect?

A completed fetch can still contain an unsuccessful HTTP response. This check sends that response into the existing error handler.

  • Save client/src/App.jsx.
  • Return to the browser at http://localhost:5173.
  • Confirm the article form plus feed still render.

Feed Stopped Rendering?

Make sure the check sits between the GET request plus the call to response.json(). Check the braces around the new if block.

If the page stays blank, help me place the response check.

✔️ Awesome, I've got everything!

The client still loads articles through the existing API contract.

ⓧ I'd like to double check the full code

import { useEffect, useState } from 'react';

const API_URL = 'http://localhost:3000/api/articles';
const emptyForm = { title: '', author: '', body: '' };

function ArticleCard({ article }) {
  return (
    <article className="article-card">
      <p className="eyebrow">Article #{article.id}</p>
      <h2>{article.title}</h2>
      <p className="byline">By {article.author}</p>
      <p className="article-body">{article.body}</p>
    </article>
  );
}

export default function App() {
  const [articles, setArticles] = useState([]);
  const [form, setForm] = useState(emptyForm);
  const [status, setStatus] = useState('Loading articles...');
  const [isSaving, setIsSaving] = useState(false);

  useEffect(() => {
    let ignore = false;

    async function loadArticles() {
      try {
        const response = await fetch(API_URL);
        if (!response.ok) {
          throw new Error('The article feed could not be loaded.');
        }

        const data = await response.json();
        if (!ignore) {
          setArticles(data);
          setStatus(data.length ? '' : 'No articles yet. Create the first one.');
        }
      } catch (error) {
        if (!ignore) {
          setStatus(error.message);
        }
      }
    }

    loadArticles();
    return () => {
      ignore = true;
    };
  }, []);

  function handleChange(event) {
    const { name, value } = event.target;
    setForm((current) => ({ ...current, [name]: value }));
  }

  async function handleSubmit(event) {
    event.preventDefault();
    setIsSaving(true);
    setStatus('Saving article...');

    try {
      const response = await fetch(API_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(form),
      });
      const createdArticle = await response.json();

      if (!response.ok) {
        throw new Error(createdArticle.message || 'The article could not be saved.');
      }

      setArticles((current) => [createdArticle, ...current]);
      setForm(emptyForm);
      setStatus('Article saved.');
    } catch (error) {
      setStatus(error.message);
    } finally {
      setIsSaving(false);
    }
  }

  return (
    <main className="page-shell">
      <header className="hero">
        <p className="eyebrow">NextPress</p>
        <h1>Publish one durable article at a time.</h1>
        <p>A local React, Express, and MySQL vertical slice.</p>
      </header>

      <section className="workspace">
        <form className="article-form" onSubmit={handleSubmit}>
          <h2>New article</h2>
          <label>Title<input name="title" value={form.title} onChange={handleChange} maxLength="160" required /></label>
          <label>Author<input name="author" value={form.author} onChange={handleChange} maxLength="100" required /></label>
          <label>Body<textarea name="body" value={form.body} onChange={handleChange} rows="8" required /></label>
          <button type="submit" disabled={isSaving}>{isSaving ? 'Publishing...' : 'Publish article'}</button>
        </form>
        <section className="feed" aria-live="polite">
          <div className="feed-heading"><h2>Latest articles</h2><span>{articles.length} total</span></div>
          {status && <p className="status">{status}</p>}
          <div className="article-list">{articles.map((article) => <ArticleCard key={article.id} article={article} />)}</div>
        </section>
      </section>
    </main>
  );
}

Your final check follows the same user journey that failed with temporary memory. MySQL now owns the row after the API process ends.

  • Enter Persistence test in the Title field.
  • Enter NextPress learner in the Author field.
  • Enter This article survives an API restart. in the Body field.
  • Click Publish article.

You should see Persistence test at the top of the feed. The POST route has inserted a MySQL row.

Before you restart the API, do you expect the new article to disappear again?

  • Switch back to the API terminal.
  • Stop the API by pressing Ctrl+C.
  • Restart the API by running this command:
npm start

What Happens During the Restart?

The initializer finds the existing database plus table. The pool reconnects before the API starts listening.

You should see NextPress API listening on http://localhost:3000 again.

  • Return to the browser.
  • Refresh http://localhost:5173.

You should still see Persistence test in the feed. The article survives because its row remains in MySQL.

Before you test the empty form, do you expect the browser to send a new article request?

  • Clear the Title field.
  • Clear the Author field.
  • Clear the Body field.
  • Click Publish article.

The browser highlights the required Title input. The article count stays unchanged because browser validation prevents the request.

That closes the persistence gap you exposed earlier. NextPress now creates articles through the browser and preserves them across API restarts.

Secret mission

Add Word-Count Insights

Turn each article body into a useful reading insight without changing the API or database. Every card gains its own word-count badge. The feed header gains a combined word total.

Clean Up Your Resources

Clean Up Your Resources

Everything runs on your existing Windows machine, so there are no additional ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The local NextPress project at C:\laragon\www\nextpress, including both node_modules dependency folders.
  • The local MySQL nextpress database with its articles table.

Keep everything running

No action needed. Choose this if you are still actively building NextPress or plan to extend it soon.

  • Leave C:\laragon\www\nextpress in place for your next coding session.
  • Keep the nextpress database so your durable articles remain available.
  • Leave the Vite client running while you continue testing the feed.
  • Leave the Express API running while you continue publishing articles.

Pause - I'll come back to this later

Shut down the running processes to free local resources. Your project files and saved articles remain ready for your return.

  • Switch back to the terminal running the Vite client.
  • Press Ctrl+C to stop the Vite client.
  • Switch back to the terminal running the Express API.
  • Press Ctrl+C to stop the Express API.
  • In Laragon from earlier, select Stop All.

Both development processes and the local services are now stopped. Your nextpress database still contains every saved article.

Delete - I don't want to use this again

This is the only irreversible option. These steps remove your saved articles and the entire NextPress project from your machine.

  • Switch back to the terminal running the Vite client.
  • Press Ctrl+C to stop the Vite client.
  • Switch back to the terminal running the Express API.
  • Press Ctrl+C to stop the Express API.

Both development processes are now stopped. Their terminal prompts are ready for the cleanup.

  • Return to the Laragon window from earlier.
  • Select Menu.
  • Select Terminal.
  • Connect to the local MySQL client by running this command:
mysql --user=root

What does this command do?

This opens the local MySQL client using the root account. It uses the blank password configuration you confirmed for this machine.

  • Remove the local database by running this statement:
DROP DATABASE IF EXISTS nextpress;

What does this database command do?

This deletes the local nextpress database. MySQL removes the articles table and every saved article with it.

  • Close the Laragon terminal window.
  • Return to the Laragon window.
  • Select Stop All.

Your database and local services are now removed or stopped. The final cleanup removes the project files.

  • Press the Windows key to open search.
  • Type File Explorer into the search field.
  • Press Enter to open File Explorer.
  • Enter C:\laragon\www in the address bar.
  • Select the nextpress folder.
  • Press the Delete key.
  • Confirm that the nextpress folder no longer appears inside C:\laragon\www.

That completes the clean reset. Your saved articles and local NextPress files are now removed.

Nice Work!

Nice Work!

Outstanding work! NextPress now publishes articles through a React interface backed by an Express API. Your MySQL database keeps every article durable across API restarts.

You've learned how to:

  • Build a browser-visible article feed with a controlled creation form that updates after each successful publish.
  • Connect the browser to HTTP GET and POST routes that exchange JSON with Express. Restart the API to expose the limits of in-memory storage.
  • Replace the temporary article array with durable MySQL persistence that uses prepared inserts plus environment variables for connection settings.
  • Complete the optional Secret Mission by deriving a word-count badge from each article body. Calculate the combined feed total without adding redundant columns to the database.

Ready to quiz yourself?