Zero to Production: Deploy a Full-Stack App with SST and AWS in 60 Minutes#
In 2025, deploying production-ready applications shouldn’t require weeks of DevOps work. Today, I’ll show you how to build and deploy a full-stack serverless application in just 60 minutes using SST (Serverless Stack) v3, the framework that’s revolutionizing how we build on AWS.
What We’re Building#
We’ll create a real-time collaborative todo application with:
- Backend: Serverless API with Lambda and API Gateway
- Database: DynamoDB for scalable data storage
- Authentication: AWS Cognito for secure user management
- Frontend: React app with real-time updates
- Hosting: S3 and CloudFront for global distribution
The best part? Everything is type-safe, from infrastructure to API calls.
Why SST in 2025?#
SST has become the go-to framework for serverless development because it:
- Provides a fantastic local development experience with Live Lambda
- Offers type-safe infrastructure as code
- Includes built-in best practices for security and performance
- Deploys with a single command
Prerequisites (5 minutes)#
Before we start, ensure you have:
1
2
3
4
5
6
7
8
| # Check Node.js version (18+ required)
node --version
# Install AWS CLI and configure credentials
aws configure
# Install SST CLI globally
npm install -g sst
|
You’ll also need:
- An AWS account (free tier works fine)
- Basic knowledge of React and TypeScript
- A code editor (VS Code recommended)
Step 1: Project Setup (10 minutes)#
Let’s create our project:
1
2
3
4
5
6
7
| # Create a new SST project
npx create-sst@latest my-todo-app
cd my-todo-app
# Select the following options:
# ✓ Template: TypeScript starter
# ✓ Package manager: npm
|
Your project structure should look like this:
1
2
3
4
5
6
| my-todo-app/
├── sst.config.ts # SST configuration
├── packages/
│ ├── functions/ # Lambda functions
│ └── web/ # React frontend
└── stacks/ # Infrastructure definitions
|
Let’s examine the key configuration file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // sst.config.ts
import { SSTConfig } from "sst";
import { MyStack } from "./stacks/MyStack";
export default {
config(_input) {
return {
name: "my-todo-app",
region: "us-east-1",
};
},
stacks(app) {
app.stack(MyStack);
}
} satisfies SSTConfig;
|
Step 2: Building the Backend (20 minutes)#
Define the Infrastructure#
First, let’s create our DynamoDB table and API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| // stacks/MyStack.ts
import { StackContext, Table, Api, Cognito, StaticSite } from "sst/constructs";
export function MyStack({ stack }: StackContext) {
// Create DynamoDB table for todos
const table = new Table(stack, "Todos", {
fields: {
userId: "string",
todoId: "string",
createdAt: "number",
},
primaryIndex: { partitionKey: "userId", sortKey: "todoId" },
});
// Create Cognito User Pool for authentication
const auth = new Cognito(stack, "Auth", {
login: ["email"],
});
// Create the API
const api = new Api(stack, "Api", {
authorizers: {
jwt: {
type: "user_pool",
userPool: {
id: auth.userPoolId,
clientIds: [auth.userPoolClientId],
},
},
},
defaults: {
authorizer: "jwt",
function: {
bind: [table],
environment: {
TABLE_NAME: table.tableName,
},
},
},
routes: {
"GET /todos": "packages/functions/src/list.handler",
"POST /todos": "packages/functions/src/create.handler",
"PUT /todos/{id}": "packages/functions/src/update.handler",
"DELETE /todos/{id}": "packages/functions/src/delete.handler",
},
});
// We'll add the frontend later
stack.addOutputs({
ApiEndpoint: api.url,
UserPoolId: auth.userPoolId,
UserPoolClientId: auth.userPoolClientId,
});
}
|
Create Lambda Functions#
Now let’s implement our API handlers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| // packages/functions/src/create.ts
import { APIGatewayProxyHandlerV2 } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "crypto";
const client = new DynamoDBClient({});
const dynamoDb = DynamoDBDocumentClient.from(client);
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const data = JSON.parse(event.body || "{}");
const userId = event.requestContext.authorizer?.jwt.claims.sub;
const params = {
TableName: process.env.TABLE_NAME,
Item: {
userId,
todoId: randomUUID(),
text: data.text,
completed: false,
createdAt: Date.now(),
},
};
await dynamoDb.send(new PutCommand(params));
return {
statusCode: 201,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params.Item),
};
};
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // packages/functions/src/list.ts
import { APIGatewayProxyHandlerV2 } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({});
const dynamoDb = DynamoDBDocumentClient.from(client);
export const handler: APIGatewayProxyHandlerV2 = async (event) => {
const userId = event.requestContext.authorizer?.jwt.claims.sub;
const params = {
TableName: process.env.TABLE_NAME,
KeyConditionExpression: "userId = :userId",
ExpressionAttributeValues: {
":userId": userId,
},
};
const result = await dynamoDb.send(new QueryCommand(params));
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result.Items),
};
};
|
Step 3: Building the Frontend (20 minutes)#
Setup React with Vite#
1
2
3
4
| # Navigate to web package
cd packages/web
npm create vite@latest . -- --template react-ts
npm install @aws-amplify/ui-react aws-amplify
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| // packages/web/src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Amplify } from 'aws-amplify'
import { Authenticator } from '@aws-amplify/ui-react'
import '@aws-amplify/ui-react/styles.css'
import App from './App'
import './index.css'
// These will be replaced by SST
Amplify.configure({
Auth: {
region: import.meta.env.VITE_REGION,
userPoolId: import.meta.env.VITE_USER_POOL_ID,
userPoolWebClientId: import.meta.env.VITE_USER_POOL_CLIENT_ID,
},
API: {
endpoints: [{
name: 'api',
endpoint: import.meta.env.VITE_API_URL,
custom_header: async () => {
return { Authorization: `Bearer ${(await Auth.currentSession()).getIdToken().getJwtToken()}` }
}
}]
}
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Authenticator>
<App />
</Authenticator>
</React.StrictMode>,
)
|
Create the Todo App Component#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
| // packages/web/src/App.tsx
import { useState, useEffect } from 'react'
import { API } from 'aws-amplify'
import './App.css'
interface Todo {
todoId: string
text: string
completed: boolean
createdAt: number
}
function App() {
const [todos, setTodos] = useState<Todo[]>([])
const [newTodo, setNewTodo] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchTodos()
}, [])
const fetchTodos = async () => {
try {
const response = await API.get('api', '/todos', {})
setTodos(response)
setLoading(false)
} catch (error) {
console.error('Error fetching todos:', error)
setLoading(false)
}
}
const createTodo = async (e: React.FormEvent) => {
e.preventDefault()
if (!newTodo.trim()) return
try {
const response = await API.post('api', '/todos', {
body: { text: newTodo }
})
setTodos([...todos, response])
setNewTodo('')
} catch (error) {
console.error('Error creating todo:', error)
}
}
const toggleTodo = async (todo: Todo) => {
try {
await API.put('api', `/todos/${todo.todoId}`, {
body: { completed: !todo.completed }
})
setTodos(todos.map(t =>
t.todoId === todo.todoId
? { ...t, completed: !t.completed }
: t
))
} catch (error) {
console.error('Error updating todo:', error)
}
}
const deleteTodo = async (todoId: string) => {
try {
await API.del('api', `/todos/${todoId}`, {})
setTodos(todos.filter(t => t.todoId !== todoId))
} catch (error) {
console.error('Error deleting todo:', error)
}
}
if (loading) return <div>Loading...</div>
return (
<div className="app">
<h1>My Todo App</h1>
<form onSubmit={createTodo} className="todo-form">
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a new todo..."
className="todo-input"
/>
<button type="submit" className="add-button">Add</button>
</form>
<ul className="todo-list">
{todos.map(todo => (
<li key={todo.todoId} className="todo-item">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo)}
/>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
<button
onClick={() => deleteTodo(todo.todoId)}
className="delete-button"
>
Delete
</button>
</li>
))}
</ul>
</div>
)
}
export default App
|
Update Stack to Include Frontend#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // stacks/MyStack.ts (add this to the existing file)
const site = new StaticSite(stack, "Site", {
path: "packages/web",
buildCommand: "npm run build",
buildOutput: "dist",
environment: {
VITE_API_URL: api.url,
VITE_REGION: stack.region,
VITE_USER_POOL_ID: auth.userPoolId,
VITE_USER_POOL_CLIENT_ID: auth.userPoolClientId,
},
});
stack.addOutputs({
SiteUrl: site.url,
});
|
Step 4: Local Development (5 minutes)#
SST provides an amazing local development experience:
1
2
| # From the project root
npm run dev
|
This starts:
- Live Lambda development (changes deploy instantly)
- Local DynamoDB simulation
- Hot reload for your React app
Visit the URLs provided in the terminal to see your app running locally!
Step 5: Deployment (5 minutes)#
Deploying to production is incredibly simple:
1
2
| # Deploy to your AWS account
npx sst deploy --stage prod
|
SST will:
- Build and optimize your Lambda functions
- Create all AWS resources (DynamoDB, Cognito, API Gateway)
- Build and deploy your React app to S3/CloudFront
- Output all the URLs and configuration
Step 6: Post-Deployment Setup (5 minutes)#
After deployment, you’ll see outputs like:
1
2
3
4
5
6
| ✓ Deployed:
MyStack
ApiEndpoint: https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com
UserPoolId: us-east-1_xxxxxxxxx
UserPoolClientId: xxxxxxxxxxxxxxxxxxxxxxxxxx
SiteUrl: https://xxxxxxxxxx.cloudfront.net
|
Visit the SiteUrl
to see your live application!
Cost Analysis#
Running this application costs approximately:
- Development: $0 (free tier covers everything)
- Small scale (1K users): ~$5/month
- Medium scale (10K users): ~$50/month
The serverless model means you only pay for what you use!
Next Steps#
You now have a production-ready application! Here are some enhancements to consider:
- Add WebSocket support for real-time collaboration
- Implement DynamoDB Streams for event-driven updates
- Add CloudWatch dashboards for monitoring
- Set up CI/CD with GitHub Actions
- Enable AWS WAF for additional security
Key Takeaways#
- SST makes serverless development accessible and enjoyable
- Type-safe infrastructure prevents runtime errors
- Local development experience rivals traditional apps
- Production deployment is just one command away
Conclusion#
In just 60 minutes, we’ve built and deployed a full-stack serverless application that’s:
- Scalable to millions of users
- Secured with AWS Cognito
- Globally distributed via CloudFront
- Cost-effective with pay-per-use pricing
The serverless revolution isn’t coming – it’s here. With tools like SST, there’s never been a better time to build on AWS.
Have questions or run into issues? Drop a comment below or reach out on Twitter. Happy building!
Resources#