Writing Data
In the previous lesson, you learned how to read data from Webiny using the Read API. Now it's time to learn how to write data back to Webiny!
In this lesson, you'll build a contact form that allows users to submit their information, which will be stored in your Webiny Headless CMS.
In this lesson...
Here are the topics we'll cover
Write data using GraphQL mutations.
Securely submit data with Server Actions.
Validate forms and handle errors.
What you'll build:
A contact form page at /contact that:
- Collects name, email, and message from users
- Validates input on the client side
- Submits data securely via Server Actions
- Creates new entries in Webiny using the Manage API
- Shows success/error feedback to users
Prerequisites:
- Completed Lesson 8 (Next.js app setup)
- Your Next.js app running locally
- Access to Webiny Admin
Introduction
So far, you've only been reading data from Webiny. But headless CMS systems are powerful because they can also accept and store data from your applications.
Common use cases for writing data:
- Contact forms (what we'll build)
- User-generated content (comments, reviews)
- Survey submissions
- Newsletter signups
- Form builders
Why use the Manage API?
The Read API is read-only. To create, update, or delete content, you need the Manage API. This separation is intentional for security:
- Read API: Public-facing, can be cached, read-only
- Manage API: More powerful, requires stricter permissions
In this lesson, we'll use the Manage API to create new contact form submissions. We'll explore updating and deleting in later lessons.
Creating the ContactSubmission Content Model
First, let's create a content model to store contact form submissions.
You can create this model in two ways:
- Via UI (easier for beginners, visual approach)
- Via Code (better for version control, reproducible)
Choose the approach that works best for you!
If you completed Lesson 4 (Creating Content Model via Code), you're already familiar with the code approach. If not, start with the UI approach - it's more visual and beginner-friendly!
Option 1: Create Model via UI
Step 1: Navigate to Content Models
- Open Webiny Admin in your browser
- Click Headless CMS in the left sidebar
- Click Models to see your content models
Step 2: Create New Model
- Click New Model in the top right
- Fill in the model details:
- Name:
Contact Submission - Model ID:
contactSubmission(auto-generated) - Description:
Stores contact form submissions from the website
- Name:
- Click Create Model
Step 3: Add Fields
Add the following fields to your model:
1. Name Field
- Field Type: Text
- Label:
Name - Field ID:
name - Placeholder:
Enter your full name - Validation:
- ✅ Required
- Min length: 2
- Max length: 100
2. Email Field
- Field Type: Text
- Label:
Email - Field ID:
email - Placeholder:
your.email@example.com - Validation:
- ✅ Required
- ✅ Pattern validation: Email (built-in)
3. Message Field
- Field Type: Long Text
- Label:
Message - Field ID:
message - Placeholder:
Enter your message... - Validation:
- ✅ Required
- Min length: 10
- Max length: 1000
4. Submitted At Field
- Field Type: Date/Time
- Label:
Submitted At - Field ID:
submittedAt - Settings:
- ✅ Set current date/time on creation
The "Submitted At" field will automatically record when the form was submitted. This is useful for sorting and filtering submissions.
Step 4: Configure Model Settings
Before saving, configure these settings:
- Title Field: Select
name(this will be the display name in lists) - Description Field: Select
message(optional, shows preview in lists)
Step 5: Save the Model
Click Save to create your content model.
Your ContactSubmission content model is now ready to receive data! You can view it in the Models list.
Option 2: Create Model via Code
If you prefer a code-based approach (recommended for production projects), you can define the model in your Webiny project.
Step 1: Navigate to Your Webiny Project
Open your Webiny project directory (not the Next.js app) in your code editor.
Step 2: Create the Model File
Create a new file in your Webiny project:
extensions/contactSubmissionModel.ts
The extensions folder is where all content model definitions live. You should already have
productModel.ts and productCategoryModel.ts there from earlier lessons.
Step 3: Define the Model
Add the following code to contactSubmissionModel.ts:
Key parts of this code:
- Model ID:
contactSubmission- used in GraphQL API - Fields: Define all four fields with validation
- Layout: How fields appear in the UI (
[["name", "email"], ["message"], ["submittedAt"]]) - Title Field:
name- used for display in lists - Description Field:
message- shows preview - API Names:
ContactSubmission(singular),ContactSubmissions(plural)
Step 4: Register the Model
Open the main extensions file:
extensions/index.ts
Import and export your new model:
// Existing imports...
import { ProductModel } from "./productModel";
import { ProductCategoryModel } from "./productCategoryModel";
// Add this import
import { ContactSubmissionModel } from "./contactSubmissionModel";
export default [
// Existing models
ProductModel,
ProductCategoryModel,
// Add this
ContactSubmissionModel
];
Step 5: Apply the Changes
To apply the changes, you can either deploy the project or use watch mode.
Option 1: Deploy
Deploy your Webiny project to apply the changes:
yarn webiny deploy
Wait for the deployment to complete (this may take a few minutes).
Option 2: Use Watch Mode (Faster for Development)
yarn webiny watch api
If you're using watch mode, the changes will be automatically deployed and visible in the Admin UI when you refresh. You can access the Admin either locally (via yarn webiny watch admin) or through your deployed Admin app (CloudFront URL).
Watch mode is ideal during development as it automatically redeploys your API changes without manual deployment. Since we're only changing backend API code with these extensions, your Admin app (whether local or deployed) will pick up the changes after a refresh.
Step 6: Verify in Webiny Admin
- Open Webiny Admin
- Go to Headless CMS → Models
- You should see Contact Submission in the list
Your ContactSubmission content model is now deployed and ready to use! The code approach makes it easy to version control and deploy across environments.
Benefits of the code approach:
- ✅ Version controlled (committed to git)
- ✅ Reproducible across environments
- ✅ Can be reviewed in pull requests
- ✅ TypeScript type safety
- ✅ Easy to update and redeploy
Updating Your API Key with Write Permissions
In Lesson 8, we created an API key with Read-only permissions. Now we need to expand those permissions to include the Manage API so we can write data to Webiny.
Step 1: Navigate to API Keys
- Click Settings in the left sidebar
- Click API Keys
Step 2: Edit the Existing API Key
Find the Next.js App API key you created in Lesson 8 and click on it to edit.
Step 3: Update Permissions
Configure the permissions to include both Read and Manage:
- Access Level: Keep as Custom access
- GraphQL API Types:
- ✅ Read (checked - we still need this for product listing)
- ❌ Preview (unchecked - not needed)
- ✅ Manage (checked - NEW! This allows writing data)
Important: We're giving this API key access to the Manage API, which is more powerful than the Read API. In production, you should restrict this key to only the ContactSubmission model and only CREATE operations. However, without AACL (Business plan), we can only control the API type level.
Security Best Practice: Even though we can't restrict to specific models without AACL, we'll only use the Manage API for contact form submissions in our code. In your Server Action, only create ContactSubmission entries with this key.
Step 4: Save Changes
Click Save API Key to update the permissions.
Do I need a new token? No! The existing API token from Lesson 8 continues to work. The token now has access to both the Read API and the Manage API. You only need to get a new token if you want to regenerate it for security reasons.
Update Environment Variables
Add the Manage API URL to your Next.js app. We'll reuse the same API token from Lesson 8.
Step 1: Find Your Manage API URL
In your Webiny project directory, run:
yarn webiny info
Look for the Headless CMS - Manage API URL. It will look like:
https://d1ud9w3i9i7d77.cloudfront.net/cms/manage
Step 2: Update .env.local
Open your .env.local file in the Next.js app and add the Manage API URL:
# API Token (works for both Read and Manage APIs)
WEBINY_API_TOKEN=your-api-token-here
# API Endpoints
WEBINY_READ_API_URL=https://your-url.cloudfront.net/cms/read
WEBINY_MANAGE_API_URL=https://your-url.cloudfront.net/cms/manage
Replace https://your-url.cloudfront.net/cms/manage with your actual Manage API URL.
Notice we're using the same token (WEBINY_API_TOKEN) for both APIs! This works because we
updated the API key permissions to include both Read and Manage access.
Step 3: Restart Dev Server
Stop and restart your dev server to load the new environment variables:
npm run dev
Never commit .env.local to version control! The Manage API token has write permissions and
should be kept secret.
Creating TypeScript Types for Contact Submission
Let's add types for our contact form data.
Update lib/types.ts and add the following interfaces:
// Add to existing types.ts file
export interface ContactSubmission {
id: string;
entryId: string;
values: {
name: string;
email: string;
message: string;
submittedAt: string; // ISO date string
};
}
export interface ContactFormData {
name: string;
email: string;
message: string;
}
// Mutation response type
export interface CreateContactSubmissionResponse {
createContactSubmission: {
data: ContactSubmission | null;
error: WebinyError | null;
};
}
Creating the Mutation Function
Now let's add a function to submit contact form data to Webiny.
Create a new file lib/mutations.ts:
import type {
ContactFormData,
CreateContactSubmissionResponse,
} from "./types";
const MANAGE_API_URL = process.env.WEBINY_MANAGE_API_URL!;
const API_TOKEN = process.env.WEBINY_API_TOKEN!;
if (!MANAGE_API_URL || !API_TOKEN) {
throw new Error(
"Missing required environment variables: WEBINY_MANAGE_API_URL and WEBINY_API_TOKEN"
);
}
interface GraphQLResponse<T> {
data?: T;
errors?: Array<{
message: string;
locations?: Array<{ line: number; column: number }>;
}>;
}
async function fetchWebinyMutation<T>(
query: string,
variables?: Record<string, unknown>
): Promise<T> {
const response = await fetch(MANAGE_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: API_TOKEN,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(\`HTTP error! status: \${response.status}\`);
}
const result: GraphQLResponse<T> = await response.json();
// Check for GraphQL-level errors
if (result.errors) {
throw new Error(\`GraphQL error: \${result.errors[0].message}\`);
}
if (!result.data) {
throw new Error("No data returned from GraphQL mutation");
}
return result.data;
}
export async function createContactSubmission(
data: ContactFormData
): Promise<ContactSubmission> {
const CREATE_CONTACT_SUBMISSION_MUTATION = /* GraphQL */ \`
mutation CreateContactSubmission($data: ContactSubmissionInput!) {
createContactSubmission(data: $data) {
data {
id
entryId
values {
name
email
message
submittedAt
}
}
error {
message
code
data
}
}
}
\`;
const response = await fetchWebinyMutation<CreateContactSubmissionResponse>(
CREATE_CONTACT_SUBMISSION_MUTATION,
{
data: {
name: data.name,
email: data.email,
message: data.message,
submittedAt: new Date().toISOString(),
},
}
);
// Check for Webiny-level errors
if (response.createContactSubmission.error) {
throw new Error(
response.createContactSubmission.error.message ||
"Failed to create contact submission"
);
}
if (!response.createContactSubmission.data) {
throw new Error("No data returned from createContactSubmission");
}
return response.createContactSubmission.data;
}
Key differences from queries:
- Mutation keyword: Uses
mutationinstead ofquery - Variables: Passes data via
$datavariable - Input type: Uses
ContactSubmissionInput!(generated by Webiny) - Operation: Creates new data instead of fetching
The mutation structure follows the same error handling pattern as queries: check for GraphQL
errors (top-level), then Webiny errors (in the error field).
Creating the Server Action
Next.js Server Actions allow us to securely call server-side code from client components.
Create a new file app/actions/submitContact.ts:
"use server";
import { createContactSubmission } from "@/lib/mutations";
import type { ContactFormData } from "@/lib/types";
export async function submitContactForm(formData: ContactFormData) {
try {
const submission = await createContactSubmission(formData);
return {
success: true,
data: submission
};
} catch (error) {
console.error("Failed to submit contact form:", error);
return {
success: false,
error:
error instanceof Error ? error.message : "Failed to submit contact form. Please try again."
};
}
}
Why use Server Actions?
- Security: API tokens never reach the client
- Simplicity: No need to create API routes
- Type safety: Full TypeScript support
- Built-in: Native Next.js feature (no extra libraries)
The "use server" directive tells Next.js this code runs only on the server.
Creating the Contact Form Page
Now let's build the actual contact form that users will interact with.
Create a new file app/contact/page.tsx:
"use client";
import { useState } from "react";
import { submitContactForm } from "@/actions/submitContact";
import type { ContactFormData } from "@/lib/types";
export default function ContactPage() {
const [formData, setFormData] = useState<ContactFormData>({
name: "",
email: "",
message: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<{
type: "success" | "error" | null;
message: string;
}>({ type: null, message: "" });
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setSubmitStatus({ type: null, message: "" });
const result = await submitContactForm(formData);
setIsSubmitting(false);
if (result.success) {
setSubmitStatus({
type: "success",
message: "Thank you! Your message has been sent successfully.",
});
// Reset form
setFormData({ name: "", email: "", message: "" });
} else {
setSubmitStatus({
type: "error",
message: result.error || "Something went wrong. Please try again.",
});
}
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}));
};
return (
<main className="min-h-screen p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-4xl font-bold mb-4">Contact Us</h1>
<p className="text-gray-600 mb-8">
Have a question? Send us a message and we'll get back to you soon.
</p>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name Field */}
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-gray-700 mb-2"
>
Name *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
minLength={2}
maxLength={100}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your full name"
disabled={isSubmitting}
/>
</div>
{/* Email Field */}
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-2"
>
Email *
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="your.email@example.com"
disabled={isSubmitting}
/>
</div>
{/* Message Field */}
<div>
<label
htmlFor="message"
className="block text-sm font-medium text-gray-700 mb-2"
>
Message *
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
required
minLength={10}
maxLength={1000}
rows={6}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="Enter your message..."
disabled={isSubmitting}
/>
<p className="text-sm text-gray-500 mt-1">
{formData.message.length}/1000 characters
</p>
</div>
{/* Status Messages */}
{submitStatus.type === "success" && (
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg">
<p className="font-semibold">Success!</p>
<p className="text-sm">{submitStatus.message}</p>
</div>
)}
{submitStatus.type === "error" && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
<p className="font-semibold">Error</p>
<p className="text-sm">{submitStatus.message}</p>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white font-semibold py-3 px-6 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? "Sending..." : "Send Message"}
</button>
</form>
</div>
</main>
);
}
Key features of this form:
- Client Component: Uses
"use client"because it has interactive state - Form State: Manages name, email, message, and submission status
- Validation: HTML5 validation (required, minLength, maxLength, email type)
- Loading State: Disables form while submitting
- Success/Error Feedback: Shows clear messages to users
- Form Reset: Clears form after successful submission
- Character Counter: Shows message length (UX improvement)
Testing the Contact Form
Let's test the complete flow!
Step 1: Navigate to Contact Page
In your browser, go to:
http://localhost:3000/contact
You should see your contact form with three fields.
Step 2: Fill Out the Form
Enter some test data:
- Name:
John Doe - Email:
john@example.com - Message:
This is a test message from the contact form.
Step 3: Submit the Form
Click Send Message. You should see:
- Button text changes to "Sending..."
- Form fields become disabled
- After a moment, a green success message appears
- Form fields are cleared
Step 4: Verify in Webiny Admin
- Open Webiny Admin
- Go to Headless CMS → Contact Submissions
- You should see your new submission!
Congratulations! You've successfully written data to Webiny from your Next.js application!
How It Works: The Complete Flow
Let's review what happens when a user submits the form:
-
User fills out form (Client Component:
app/contact/page.tsx)- React state manages form data
- HTML5 validation on submit
-
Form calls Server Action (
submitContactForm)- Runs securely on the server
- API tokens never exposed to client
-
Server Action calls mutation (
createContactSubmission)- Connects to Webiny Manage API
- Sends GraphQL mutation
- Passes form data as variables
-
Webiny processes mutation
- Validates data against content model
- Creates new ContactSubmission entry
- Returns created entry or error
-
Response flows back
- Server Action receives response
- Returns success/error to Client Component
- Form updates UI based on result
Security highlights:
- ✅ API tokens stay on server (never in browser)
- ✅ Validation happens on both client and server
- ✅ Webiny validates against content model schema
- ✅ Limited API key permissions (Manage API only)
Adding Navigation
Let's add a link to the contact page from the homepage.
Update app/page.tsx:
import { getProducts } from "@/lib/webiny";
import type { Product } from "@/lib/types";
import Link from "next/link";
export default async function HomePage() {
let products: Product[];
let error: string | null = null;
try {
products = await getProducts();
} catch (err) {
error = err instanceof Error ? err.message : "Failed to fetch products";
products = [];
}
return (
<main className="min-h-screen p-8">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-4xl font-bold">Our Products</h1>
<Link
href="/contact"
className="bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors"
>
Contact Us
</Link>
</div>
{/* Rest of your existing code... */}
</div>
</main>
);
}
Now users can easily navigate between the product listing and contact form!
Troubleshooting
"Missing required environment variables" Error
Problem: The app crashes with environment variable errors.
Solution:
- Verify both
WEBINY_MANAGE_API_URLandWEBINY_API_TOKENare in.env.local - Restart your dev server after adding new environment variables
- Check for typos in variable names
"GraphQL error" or "Webiny error" When Submitting
Problem: Form submission fails with an error.
Solution:
- Check the API key has Manage API permissions
- Verify the API key token is correct in
.env.local - Make sure the ContactSubmission model exists in Webiny
- Check browser console and server logs for detailed error messages
Form Submits But Nothing Appears in Webiny
Problem: Form shows success but no entry in Webiny Admin.
Solution:
- Check you're looking at the correct Webiny environment
- Verify the mutation is using the correct content model ID (
contactSubmission) - Check Webiny Admin filters - you might be filtering out new entries
- Look at the network tab to see the actual mutation response
TypeScript Errors
Problem: TypeScript complains about types.
Solution:
- Make sure you added all type definitions to
lib/types.ts - Check that imports match the exported interface names
- Verify the Server Action file has
"use server"directive
Summary
Congratulations! You've successfully built a contact form that writes data to Webiny Headless CMS!
In this lesson, you learned how to:
- ✅ Create a content model for form submissions (ContactSubmission)
- ✅ Understand the difference between Read API and Manage API
- ✅ Create API keys with write permissions
- ✅ Write GraphQL mutations (not just queries)
- ✅ Use Next.js Server Actions for secure server-side operations
- ✅ Build a form with validation, loading states, and feedback
- ✅ Handle both GraphQL-level and Webiny-level errors
- ✅ Test the complete data flow from form to CMS
What's Next?
In the next lesson (Lifecycle Events), we'll add a lifecycle hook that validates whether the email is a work email or personal email, and automatically sets a flag on the submission. This introduces you to Webiny's powerful extension system!
Want to see the complete code? Check out the completed example
repository on the
lesson-9-completed branch.
It's time to take a quiz!
Test your knowledge and see what you've just learned.
Why do we use Server Actions instead of calling the mutation directly from the client component?
Next lesson: Lifecycle Events - Add email validation with Webiny lifecycle hooks.