diff --git src/wp-admin/css/dashboard.css src/wp-admin/css/dashboard.css
index 98fb99378d..2b80450a4a 100644
--- src/wp-admin/css/dashboard.css
+++ src/wp-admin/css/dashboard.css
@@ -301,6 +301,147 @@
 	content: "\f153";
 }
 
+/* Dashboard WordPress events */
+
+.community-events-errors {
+	margin: 0;
+}
+
+.community-events-loading {
+	padding: 10px 12px 8px;
+}
+
+.community-events {
+	margin-bottom: 6px;
+	padding: 0 12px;
+}
+
+.community-events .spinner {
+	float: none;
+	margin: 0;
+	padding-bottom: 3px;
+}
+
+.community-events-errors[aria-hidden="true"],
+.community-events-errors *[aria-hidden="true"],
+.community-events-loading[aria-hidden="true"],
+.community-events[aria-hidden="true"],
+.community-events *[aria-hidden="true"] {
+	display: none;
+}
+
+.community-events .activity-block:first-child,
+.community-events h2 {
+	padding-top: 12px;
+	padding-bottom: 10px;
+}
+
+.community-events-form {
+	margin: 5px 0;
+}
+
+.community-events-form .regular-text {
+	margin-top: 2px;
+	width: 40%;
+}
+
+.community-events-form label {
+	display: inline-block;
+	padding-bottom: 3px;
+}
+
+.community-events .activity-block > p {
+	margin-bottom: 0;
+	display: inline;
+}
+
+#community-events-submit {
+	margin-left: 2px;
+}
+
+.community-events .button-link:hover,
+.community-events .button-link:active {
+	color: #00a0d2;
+}
+
+.community-events-cancel.button.button-link {
+	color: #0073aa;
+	text-decoration: underline;
+	margin-left: 2px;
+}
+
+.community-events ul {
+	background-color: #fafafa;
+	padding-left: 0;
+	padding-right: 0;
+	padding-bottom: 0;
+}
+
+.community-events li {
+	margin: 0;
+	padding: 8px 12px;
+	color: #72777c;
+}
+.community-events li:first-child {
+	border-top: 1px solid #eee;
+}
+
+.community-events li ~ li {
+	border-top: 1px solid #eee;
+}
+
+.community-events .activity-block {
+	border-bottom: 0;
+}
+.community-events .activity-block.last {
+	border-bottom: 1px solid #eee;
+	padding-top: 0;
+}
+
+.community-events .event-info {
+	display: block;
+}
+
+.event-icon {
+	height: 18px;
+	padding-right: 10px;
+	width: 18px;
+	display: none; /* Hide on smaller screens */
+}
+.rtl .event-icon {
+	padding-right: 0;
+	padding-left: 10px;
+}
+
+.event-icon:before {
+	color: #82878C;
+	font-size: 18px;
+}
+.event-meetup .event-icon:before {
+	content: "\f484";
+}
+.event-wordcamp .event-icon:before {
+	content: "\f486";
+}
+
+.community-events .event-title {
+	font-weight: 600;
+	display: block;
+}
+
+.community-events .event-date,
+.community-events .event-time {
+	display: block;
+}
+
+.community-events-footer {
+	margin-top: 0;
+	margin-bottom: 0;
+	padding: 12px;
+	border-top: 1px solid #eee;
+	color: #ddd;
+}
+
 /* Dashboard WordPress news */
 
 #dashboard_primary .inside {
@@ -333,9 +474,8 @@ body #dashboard-widgets .postbox form .submit {
 }
 
 #dashboard_primary .rss-widget {
-	border-bottom: 1px solid #eee;
 	font-size: 13px;
-	padding: 8px 12px 10px;
+	padding: 0 12px 0;
 }
 
 #dashboard_primary .rss-widget:last-child {
@@ -357,7 +497,8 @@ body #dashboard-widgets .postbox form .submit {
 }
 
 #dashboard_primary .rss-widget ul li {
-	margin-bottom: 8px;
+	padding: 4px 0;
+	margin: 0;
 }
 
 /* Dashboard right now */
@@ -874,9 +1015,9 @@ form.initial-form.quickpress-open input#title {
 }
 
 a.rsswidget {
-	font-size: 14px;
+	font-size: 13px;
 	font-weight: 600;
-	line-height: 1.7em;
+	line-height: 1.4em;
 }
 
 .rss-widget ul li {
@@ -1087,6 +1228,10 @@ a.rsswidget {
 		width: 30px;
 		margin: 4px 10px 5px 0;
 	}
+
+	.community-events-toggle-location {
+		height: 38px;
+	}
 }
 
 /* Smartphone */
@@ -1110,3 +1255,40 @@ a.rsswidget {
 		left: -35px;
 	}
 }
+
+@media screen and (min-width: 355px) {
+	.community-events .event-info {
+		display: table-row;
+		float: left;
+		max-width: 59%;
+	}
+	.rtl .community-events .event-info {
+		float: right;
+	}
+
+	.event-icon,
+	.event-icon[aria-hidden="true"] {
+		display: table-cell;
+	}
+
+	.event-info-inner {
+		display: table-cell;
+	}
+
+	.community-events .event-date-time {
+		float: right;
+		max-width: 39%;
+	}
+	.rtl .community-events .event-date-time {
+		float: left;
+	}
+
+	.community-events .event-date,
+	.community-events .event-time {
+		text-align: right;
+	}
+	.rtl .community-events .event-date,
+	.rtl .community-events .event-time {
+		text-align: left;
+	}
+}
diff --git src/wp-admin/includes/class-wp-community-events.php src/wp-admin/includes/class-wp-community-events.php
new file mode 100644
index 0000000000..a87c2f6bc9
--- /dev/null
+++ src/wp-admin/includes/class-wp-community-events.php
@@ -0,0 +1,408 @@
+<?php
+/**
+ * Administration: Community Events class
+ *
+ * @package WordPress
+ * @subpackage Administration
+ * @since 4.8.0
+ */
+
+/**
+ * Class WP_Community_Events
+ *
+ * A client for api.wordpress.org/events.
+ *
+ * @since 4.8.0
+ */
+class WP_Community_Events {
+	/**
+	 * WP user ID.
+	 *
+	 * @access protected
+	 * @since 4.8.0
+	 *
+	 * @var int
+	 */
+	protected $user_id = 0;
+
+	/**
+	 * Stored location data for the user.
+	 *
+	 * @access protected
+	 * @since 4.8.0
+	 *
+	 * @var bool|array
+	 */
+	protected $user_location = false;
+
+	/**
+	 * WP_Community_Events constructor.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 *
+	 * @param int        $user_id       WP user ID.
+	 * @param bool|array $user_location Stored location data for the user.
+	 *                                  `false` to pass no location;
+	 *                                  `array` to pass a location {
+	 *     @type string $description The name of the location
+	 *     @type string $latitude    The latitude in decimal degrees notation, without the degree
+	 *                               symbol. e.g., `47.615200`.
+	 *     @type string $longitude   The longitude in decimal degrees notation, without the degree
+	 *                               symbol. e.g., `-122.341100`.
+	 *     @type string $country     The ISO 3166-1 alpha-2 country code. e.g., `BR`
+	 * }
+	 */
+	public function __construct( $user_id, $user_location = false ) {
+		$this->user_id       = absint( $user_id );
+		$this->user_location = $user_location;
+	}
+
+	/**
+	 * Get data about events near a particular location.
+	 *
+	 * If the `user_location` property is set and there are cached events for this
+	 * location, these will be immediately returned.
+	 *
+	 * If not, this method will send a request to the Events API with location data.
+	 * The API will send back a recognized location based on the data, along with
+	 * nearby events.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 *
+	 * @param string $location_search Optional city name to help determine the location.
+	 *                                e.g., "Seattle". Default empty string.
+	 * @param string $timezone        Optional timezone to help determine the location.
+	 *                                Default empty string.
+	 * @return array|WP_Error         A WP_Error on failure; an array with location and events on
+	 *                                success.
+	 */
+	public function get_events( $location_search = '', $timezone = '' ) {
+		$cached_events = $this->get_cached_events();
+
+		if ( ! $location_search && $cached_events ) {
+			return $cached_events;
+		}
+
+		$request_url    = $this->get_request_url( $location_search, $timezone );
+		$response       = wp_remote_get( $request_url );
+		$response_code  = wp_remote_retrieve_response_code( $response );
+		$response_body  = json_decode( wp_remote_retrieve_body( $response ), true );
+		$response_error = null;
+		$debugging_info = compact( 'request_url', 'response_code', 'response_body' );
+
+		if ( is_wp_error( $response ) ) {
+			$response_error = $response;
+		} elseif ( 200 !== $response_code ) {
+			$response_error = new WP_Error(
+				'api-error',
+				/* translators: %s is a numeric HTTP status code; e.g., 400, 403, 500, 504, etc. */
+				esc_html( sprintf( __( 'Invalid API response code (%d)' ), $response_code ) )
+			);
+		} elseif ( ! isset( $response_body['location'], $response_body['events'] ) ) {
+			$response_error = new WP_Error(
+				'api-invalid-response',
+				isset( $response_body['error'] ) ? $response_body['error'] : __( 'Unknown API error.' )
+			);
+		}
+
+		if ( is_wp_error( $response_error ) ) {
+			$this->maybe_log_events_response( $response_error->get_error_message(), $debugging_info );
+
+			return $response_error;
+		} else {
+			$expiration = false;
+
+			if ( isset( $response_body['ttl'] ) ) {
+				$expiration = $response_body['ttl'];
+				unset( $response_body['ttl'] );
+			}
+
+			$this->cache_events( $response_body, $expiration );
+
+			$response_body = $this->trim_events( $response_body );
+			$response_body = $this->format_event_data_time( $response_body );
+
+			// Avoid bloating the log with all the event data, but keep the count.
+			$debugging_info['response_body']['events'] = count( $debugging_info['response_body']['events'] ) . ' events trimmed.';
+
+			$this->maybe_log_events_response( 'Valid response received', $debugging_info );
+
+			return $response_body;
+		}
+	}
+
+	/**
+	 * Build a URL for requests to the Events API
+	 *
+	 * @access protected
+	 * @since 4.8.0
+	 *
+	 * @param  string $search   City search string. Default empty string.
+	 * @param  string $timezone Timezone string. Default empty string.
+	 * @return string           The request URL.
+	 */
+	protected function get_request_url( $search = '', $timezone = '' ) {
+		$api_url = 'https://api.wordpress.org/events/1.0/';
+		$args    = array( 'number' => 5 ); // Get more than three in case some get trimmed out.
+
+		/*
+		 * Send the minimal set of necessary arguments in order to increase the
+		 * chances of a cache-hit on the API side.
+		 */
+		if ( empty( $search ) && isset( $this->user_location['latitude'], $this->user_location['longitude'] ) ) {
+			$args['latitude']  = $this->user_location['latitude'];
+			$args['longitude'] = $this->user_location['longitude'];
+		} else {
+			$args['ip']     = $this->maybe_anonymize_ip_address( $this->get_unsafe_client_ip() );
+			$args['locale'] = get_user_locale( $this->user_id );
+
+			if ( $timezone ) {
+				$args['timezone'] = $timezone;
+			}
+
+			if ( $search ) {
+				$args['location'] = $search;
+			}
+		}
+
+		return add_query_arg( $args, $api_url );
+	}
+
+	/**
+	 * Determine the user's actual IP if possible
+	 *
+	 * If the user is making their request through a proxy, or if the web server
+	 * is behind a proxy, then $_SERVER['REMOTE_ADDR'] will be the proxy address
+	 * rather than the user's actual address.
+	 *
+	 * Modified from http://stackoverflow.com/a/2031935/450127, MIT license.
+	 *
+	 * SECURITY WARNING: This function is _NOT_ intended to be used in
+	 * circumstances where the authenticity of the IP address matters. This does
+	 * _NOT_ guarantee that the returned address is valid or accurate, and it can
+	 * be easily spoofed.
+	 *
+	 * @access protected
+	 * @since 4.8.0
+	 *
+	 * @return false|string `false` on failure, the `string` address on success
+	 */
+	protected function get_unsafe_client_ip() {
+		$client_ip = false;
+
+		// In order of preference, with the best ones for this purpose first.
+		$address_headers = array(
+			'HTTP_CLIENT_IP',
+			'HTTP_X_FORWARDED_FOR',
+			'HTTP_X_FORWARDED',
+			'HTTP_X_CLUSTER_CLIENT_IP',
+			'HTTP_FORWARDED_FOR',
+			'HTTP_FORWARDED',
+			'REMOTE_ADDR',
+		);
+
+		foreach ( $address_headers as $header ) {
+			if ( array_key_exists( $header, $_SERVER ) ) {
+				/*
+				 * HTTP_X_FORWARDED_FOR can contain a chain of comma-separated
+				 * addresses. The first one is the original client. It can't be
+				 * trusted for authenticity, but we don't need to for this purpose.
+				 */
+				$address_chain = explode( ',', $_SERVER[ $header ] );
+				$client_ip     = trim( $address_chain[0] );
+
+				break;
+			}
+		}
+
+		return $client_ip;
+	}
+
+	/**
+	 * Attempt to partially anonymize an IP address by converting it a network ID
+	 *
+	 * Geolocating the network ID will usually return a similar location as the
+	 * actual IP, but provides some privacy for the user.
+	 *
+	 * Modified from https://github.com/geertw/php-ip-anonymizer, MIT license.
+	 *
+	 * @access protected
+	 * @since 4.8.0
+	 *
+	 * @param  string      $address The IP address that should be anonymized.
+	 * @return bool|string          The anonymized address on success;
+	 *                              the given address or false on failure.
+	 */
+	protected function maybe_anonymize_ip_address( $address ) {
+		// These functions are not available on Windows until PHP 5.3
+		if ( ! function_exists( 'inet_pton' ) || ! function_exists( 'inet_ntop' ) ) {
+			return $address;
+		}
+
+		$netmask = 4 === strlen( inet_pton( $address ) ) ? '255.255.255.0' : 'ffff:ffff:ffff:ffff:0000:0000:0000:0000';
+
+		return inet_ntop( inet_pton( $address ) & inet_pton( $netmask ) );
+	}
+
+	/**
+	 * Generate a transient key based on user location
+	 *
+	 * This could be reduced to a one-liner in the calling functions, but it's
+	 * intentionally a separate function because it's called from multiple
+	 * functions, and having it abstracted keeps the logic consistent and DRY,
+	 * which is less prone to errors.
+	 *
+	 * @access protected
+	 * @since 4.8.0
+	 *
+	 * @param  array       $location Should contain 'latitude' and 'longitude' indexes.
+	 * @return bool|string           `false` on failure, or a string on success
+	 */
+	protected function get_events_transient_key( $location ) {
+		$key = false;
+
+		if ( isset( $location['latitude'], $location['longitude'] ) ) {
+			$key = 'community-events-' . md5( $location['latitude'] . $location['longitude'] );
+		}
+
+		return $key;
+	}
+
+	/**
+	 * Cache an array of events data from the Events API.
+	 *
+	 * @access protected
+	 * @since 4.8.0
+	 *
+	 * @param array    $events               Response body from the API request.
+	 * @param int|bool $expiration Optional. Amount of time to cache the events. Defaults to false.
+	 * @return bool `true` if events were cached; `false` if not.
+	 */
+	protected function cache_events( $events, $expiration = false ) {
+		$set              = false;
+		$transient_key    = $this->get_events_transient_key( $events['location'] );
+		$cache_expiration = $expiration ? absint( $expiration ) : HOUR_IN_SECONDS * 12;
+
+		if ( $transient_key ) {
+			$set = set_site_transient( $transient_key, $events, $cache_expiration );
+		}
+
+		return $set;
+	}
+
+	/**
+	 * Get cached events
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 *
+	 * @return false|array `false` on failure; an array containing `location`
+	 *                     and `events` items on success.
+	 */
+	public function get_cached_events() {
+		$cached_response = get_site_transient( $this->get_events_transient_key( $this->user_location ) );
+		$cached_response = $this->trim_events( $cached_response );
+
+		return $this->format_event_data_time( $cached_response );
+	}
+
+	/**
+	 * Add formatted date and time items for each event in an API response
+	 *
+	 * This has to be called after the data is pulled from the cache, because
+	 * the cached events are shared by all users. If it was called before storing
+	 * the cache, then all users would see the events in the localized data/time
+	 * of the user who triggered the cache refresh, rather than their own.
+	 *
+	 * @access protected
+	 * @since 4.8.0
+	 *
+	 * @param  array $response_body The response which contains the events.
+	 * @return array                The response with dates and times formatted
+	 */
+	protected function format_event_data_time( $response_body ) {
+		if ( isset( $response_body['events'] ) ) {
+			foreach ( $response_body['events'] as $key => $event ) {
+				$timestamp = strtotime( $event['date'] );
+
+				/*
+				 * The `date_format` option is not used because it's important
+				 * in this context to keep the day of the week in the formatted date,
+				 * so that users can tell at a glance if the event is on a day they
+				 * are available, without having to open the link.
+				 */
+				/* translators: Date format for upcoming events on the dashboard. Include the day of the week. See https://secure.php.net/date. */
+				$response_body['events'][ $key ]['formatted_date'] = date_i18n( __( 'l, M j, Y' ), $timestamp );
+				$response_body['events'][ $key ]['formatted_time'] = date_i18n( get_option( 'time_format' ), $timestamp );
+			}
+		}
+
+		return $response_body;
+	}
+
+	/**
+	 * Discard events that occurred more than 24 hours ago, then reduce the remaining list down to three items.
+	 *
+	 * @access protected
+	 * @since 4.8.0
+	 *
+	 * @param  array $response_body The response body which contains the events.
+	 * @return array                The response body with events trimmed.
+	 */
+	protected function trim_events( $response_body ) {
+		if ( isset( $response_body['events'] ) ) {
+			$current_timestamp = current_time('timestamp' );
+
+			foreach ( $response_body['events'] as $key => $event ) {
+				// Skip WordCamps, because they might be multi-day events.
+				if ( 'meetup' !== $event['type'] ) {
+					continue;
+				}
+
+				$event_timestamp = strtotime( $event['date'] );
+
+				if ( $current_timestamp > $event_timestamp && ( $current_timestamp - $event_timestamp ) > DAY_IN_SECONDS ) {
+					unset( $response_body['events'][ $key ] );
+				}
+			}
+
+			$response_body['events'] = array_slice( $response_body['events'], 0, 3 );
+		}
+
+		return $response_body;
+	}
+
+	/**
+	 * Log responses to Events API requests
+	 *
+	 * All responses are logged when debugging, even if they're not WP_Errors.
+	 * Debugging info is still needed for "successful" responses, because
+	 * the API might have returned a different location than the one the user
+	 * intended to receive. In those cases, knowing the exact `request_url` is
+	 * critical.
+	 *
+	 * Errors are logged instead of being triggered, to avoid breaking the JSON
+	 * response when called from AJAX handlers and `display_errors` is enabled.
+	 *
+	 * @access protected
+	 * @since 4.8.0
+	 *
+	 * @param string $message        A description of what occurred
+	 * @param array  $debugging_info Details that provide more context for the
+	 *                               log entry
+	 */
+	protected function maybe_log_events_response( $message, $details ) {
+		if ( ! WP_DEBUG_LOG ) {
+			return;
+		}
+
+		error_log( sprintf(
+			'%s: %s. Details: %s',
+			__METHOD__,
+			trim( $message, '.' ),
+			wp_json_encode( $details )
+		) );
+	}
+}
diff --git src/wp-admin/includes/dashboard.php src/wp-admin/includes/dashboard.php
index be0b201c9f..4e2b7245f3 100644
--- src/wp-admin/includes/dashboard.php
+++ src/wp-admin/includes/dashboard.php
@@ -52,8 +52,8 @@ function wp_dashboard_setup() {
 		wp_add_dashboard_widget( 'dashboard_quick_press', $quick_draft_title, 'wp_dashboard_quick_press' );
 	}
 
-	// WordPress News
-	wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress News' ), 'wp_dashboard_primary' );
+	// WordPress Events and News
+	wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress Events and News' ), 'wp_dashboard_events_news' );
 
 	if ( is_network_admin() ) {
 
@@ -130,6 +130,45 @@ function wp_dashboard_setup() {
 }
 
 /**
+ * Get the community events data that needs to be passed to dashboard.js
+ *
+ * @since 4.8.0
+ *
+ * @return array The script data.
+ */
+function get_community_events_script_data() {
+	require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
+
+	$user_id       = get_current_user_id();
+	$user_location = get_user_option( 'community-events-location', $user_id );
+	$events_client = new WP_Community_Events( $user_id, $user_location );
+
+	$script_data = array(
+		'cache' => $events_client->get_cached_events(),
+
+		'l10n' => array(
+			'enter_closest_city' => __( 'Enter your closest city name to find nearby events' ),
+			'error_occurred_please_try_again' => __( 'An error occured. Please try again.' ),
+
+			/*
+			 * These specific examples were chosen to highlight the fact that a
+			 * state is not needed, even for cities whose name is not unique.
+			 * It would be too cumbersome to include that in the instructions
+			 * to the user, so it's left as an implication.
+			 */
+			/* translators: %s is the name of the city we couldn't locate. Replace the examples with cities in your locale, but test that they match the expected location before including them. Use endonyms (native locale names) whenever possible. */
+			'could_not_locate_city' => __( "We couldn't locate <em>%s</em>. Please try another nearby city. For example: Kansas City; Springfield; Portland." ),
+
+			// This one is only used with wp.a11y.speak(), so it can/should be more brief.
+			/* translators: %s is the name of a city. */
+			'city_updated' => __( 'City updated. Listing events near %s.' ),
+		)
+	);
+
+	return $script_data;
+}
+
+/**
  * Adds a new dashboard widget.
  *
  * @since 2.7.0
@@ -1069,6 +1108,167 @@ function wp_dashboard_rss_control( $widget_id, $form_inputs = array() ) {
 	wp_widget_rss_form( $widget_options[$widget_id], $form_inputs );
 }
 
+
+/**
+ * Callback function to render the Events and News dashboard widget
+ *
+ * @since 4.8.0
+ */
+function wp_dashboard_events_news() {
+	wp_print_community_events_markup();
+
+	?>
+
+	<div class="wordpress-news hide-if-no-js">
+		<?php wp_dashboard_primary(); ?>
+	</div>
+
+	<p class="community-events-footer">
+		<a href="https://make.wordpress.org/community/meetups-landing-page" target="_blank">
+			<?php esc_html_e( 'Meetups' ); ?> <span class="dashicons dashicons-external"></span>
+		</a>
+
+		|
+
+		<a href="https://central.wordcamp.org/schedule/" target="_blank">
+			<?php esc_html_e( 'WordCamps' ); ?> <span class="dashicons dashicons-external"></span>
+		</a>
+
+		|
+
+		<?php // translators: If a Rosetta site exists (e.g. https://es.wordpress.org/news/), then use that. Otherwise, leave untranslated. ?>
+		<a href="<?php esc_html_e( 'https://wordpress.org/news/' ); ?>" target="_blank">
+			<?php esc_html_e( 'News' ); ?> <span class="dashicons dashicons-external"></span>
+		</a>
+	</p>
+
+	<?php
+}
+
+/**
+ * Print the markup for the Community Events section of the Events and News Dashboard widget
+ *
+ * @since 4.8.0
+ */
+function wp_print_community_events_markup() {
+	$script_data = get_community_events_script_data();
+
+	?>
+
+	<div class="community-events-errors notice notice-error inline hide-if-js">
+		<p class="hide-if-js">
+			<?php esc_html_e( 'This widget requires JavaScript.'); ?>
+		</p>
+
+		<p class="community-events-error-occurred" aria-hidden="true">
+			<?php echo esc_html( $script_data['l10n']['error_occurred_please_try_again'] ); ?>
+		</p>
+
+		<p class="community-events-could-not-locate" aria-hidden="true"></p>
+	</div>
+
+	<div class="community-events-loading hide-if-no-js">
+		<?php esc_html_e( 'Loading&hellip;'); ?>
+	</div>
+
+	<?php
+	/*
+	 * Hide the main element when the page first loads, because the content
+	 * won't be ready until wp.communityEvents.renderEventsTemplate() has run.
+	 */
+	?>
+	<div id="community-events" class="community-events" aria-hidden="true">
+		<div class="activity-block">
+			<p>
+				<span id="community-events-location-message"></span>
+
+				<button class="button-link community-events-toggle-location" aria-label="<?php esc_attr_e( 'Edit city'); ?>" aria-expanded="false">
+					<span class="dashicons dashicons-edit"></span>
+				</button>
+			</p>
+
+			<form class="community-events-form" aria-hidden="true" action="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>" method="post">
+				<label for="community-events-location">
+					<?php esc_html_e( 'City name:' ); ?>
+				</label>
+				<?php /* translators: Replace with the name of a city in your locale that shows events. Use only the city name itself, without any region or country. Use the endonym instead of the English name. */ ?>
+				<input id="community-events-location" class="regular-text" type="text" name="community-events-location" placeholder="<?php esc_attr_e( 'Cincinnati' ); ?>" />
+
+				<?php submit_button( esc_html__( 'Submit' ), 'secondary', 'community-events-submit', false ); ?>
+
+				<button class="community-events-cancel button button-link" type="button" aria-expanded="false">
+					<?php esc_html_e( 'Cancel' ); ?>
+				</button>
+
+				<span class="spinner"></span>
+			</form>
+		</div>
+
+		<ul class="community-events-results activity-block last"></ul>
+	</div>
+
+	<?php
+}
+
+/**
+ * Render the events templates for the Event and News widget
+ *
+ * @since 4.8.0
+ */
+function wp_print_community_events_templates() {
+	$script_data = get_community_events_script_data();
+
+	?>
+
+	<script id="tmpl-community-events-attend-event-near" type="text/template">
+		<?php printf(
+			/* translators: %s is a placeholder for the name of a city. */
+			__( 'Attend an upcoming event near <strong>%s</strong>' ),
+			'{{ data.location.description }}'
+		); ?>
+	</script>
+
+	<script id="tmpl-community-events-could-not-locate" type="text/template">
+		<?php printf(
+			$script_data['l10n']['could_not_locate_city'],
+			'{{data.unknownCity}}'
+		); ?>
+	</script>
+
+	<script id="tmpl-community-events-event-list" type="text/template">
+		<# _.each( data.events, function( event ) { #>
+			<li class="event event-{{ event.type }} wp-clearfix">
+				<div class="event-info">
+					<div class="dashicons event-icon" aria-hidden="true"></div>
+					<div class="event-info-inner">
+						<a class="event-title" href="{{ event.url }}">{{ event.title }}</a>
+						<span class="event-city">{{ event.location.location }}</span>
+					</div>
+				</div>
+
+				<div class="event-date-time">
+					<span class="event-date">{{ event.formatted_date }}</span>
+					<# if ( 'meetup' === event.type ) { #>
+						<span class="event-time">{{ event.formatted_time }}</span>
+					<# } #>
+				</div>
+			</li>
+		<# } ) #>
+	</script>
+
+	<script id="tmpl-community-events-no-upcoming-events" type="text/template">
+		<li class="event-none">
+			<?php printf(
+				/* translators: Replace the URL if a locale-specific one exists */
+				__( 'There aren\'t any events scheduled near %s at the moment. Would you like to <a href="https://make.wordpress.org/community/handbook/meetup-organizer/welcome/">organize one</a>?' ),
+				'{{data.location.description}}'
+			); ?>
+		</li>
+	</script>
+
+	<?php
+}
+
 /**
  * WordPress News dashboard widget.
  *
@@ -1105,9 +1305,9 @@ function wp_dashboard_primary() {
 			 */
 			'title'        => apply_filters( 'dashboard_primary_title', __( 'WordPress Blog' ) ),
 			'items'        => 1,
-			'show_summary' => 1,
+			'show_summary' => 0,
 			'show_author'  => 0,
-			'show_date'    => 1,
+			'show_date'    => 0,
 		),
 		'planet' => array(
 
@@ -1152,20 +1352,6 @@ function wp_dashboard_primary() {
 		)
 	);
 
-	if ( ( ! wp_disallow_file_mods( 'dashboard_widget' ) ) && ( ! is_multisite() && is_blog_admin() && current_user_can( 'install_plugins' ) ) || ( is_network_admin() && current_user_can( 'manage_network_plugins' ) && current_user_can( 'install_plugins' ) ) ) {
-		$feeds['plugins'] = array(
-			'link'         => '',
-			'url'          => array(
-				'popular' => 'http://wordpress.org/plugins/rss/browse/popular/',
-			),
-			'title'        => '',
-			'items'        => 1,
-			'show_summary' => 0,
-			'show_author'  => 0,
-			'show_date'    => 0,
-		);
-	}
-
 	wp_dashboard_cached_rss_widget( 'dashboard_primary', 'wp_dashboard_primary_output', $feeds );
 }
 
@@ -1181,95 +1367,12 @@ function wp_dashboard_primary_output( $widget_id, $feeds ) {
 	foreach ( $feeds as $type => $args ) {
 		$args['type'] = $type;
 		echo '<div class="rss-widget">';
-		if ( $type === 'plugins' ) {
-			wp_dashboard_plugins_output( $args['url'], $args );
-		} else {
 			wp_widget_rss_output( $args['url'], $args );
-		}
 		echo "</div>";
 	}
 }
 
 /**
- * Display plugins text for the WordPress news widget.
- *
- * @since 2.5.0
- *
- * @param string $rss  The RSS feed URL.
- * @param array  $args Array of arguments for this RSS feed.
- */
-function wp_dashboard_plugins_output( $rss, $args = array() ) {
-	// Plugin feeds plus link to install them
-	$popular = fetch_feed( $args['url']['popular'] );
-
-	if ( false === $plugin_slugs = get_transient( 'plugin_slugs' ) ) {
-		$plugin_slugs = array_keys( get_plugins() );
-		set_transient( 'plugin_slugs', $plugin_slugs, DAY_IN_SECONDS );
-	}
-
-	echo '<ul>';
-
-	foreach ( array( $popular ) as $feed ) {
-		if ( is_wp_error( $feed ) || ! $feed->get_item_quantity() )
-			continue;
-
-		$items = $feed->get_items(0, 5);
-
-		// Pick a random, non-installed plugin
-		while ( true ) {
-			// Abort this foreach loop iteration if there's no plugins left of this type
-			if ( 0 == count($items) )
-				continue 2;
-
-			$item_key = array_rand($items);
-			$item = $items[$item_key];
-
-			list($link, $frag) = explode( '#', $item->get_link() );
-
-			$link = esc_url($link);
-			if ( preg_match( '|/([^/]+?)/?$|', $link, $matches ) )
-				$slug = $matches[1];
-			else {
-				unset( $items[$item_key] );
-				continue;
-			}
-
-			// Is this random plugin's slug already installed? If so, try again.
-			reset( $plugin_slugs );
-			foreach ( $plugin_slugs as $plugin_slug ) {
-				if ( $slug == substr( $plugin_slug, 0, strlen( $slug ) ) ) {
-					unset( $items[$item_key] );
-					continue 2;
-				}
-			}
-
-			// If we get to this point, then the random plugin isn't installed and we can stop the while().
-			break;
-		}
-
-		// Eliminate some common badly formed plugin descriptions
-		while ( ( null !== $item_key = array_rand($items) ) && false !== strpos( $items[$item_key]->get_description(), 'Plugin Name:' ) )
-			unset($items[$item_key]);
-
-		if ( !isset($items[$item_key]) )
-			continue;
-
-		$raw_title = $item->get_title();
-
-		$ilink = wp_nonce_url('plugin-install.php?tab=plugin-information&plugin=' . $slug, 'install-plugin_' . $slug) . '&amp;TB_iframe=true&amp;width=600&amp;height=800';
-		echo '<li class="dashboard-news-plugin"><span>' . __( 'Popular Plugin' ) . ':</span> ' . esc_html( $raw_title ) .
-			'&nbsp;<a href="' . $ilink . '" class="thickbox open-plugin-details-modal" aria-label="' .
-			/* translators: %s: plugin name */
-			esc_attr( sprintf( __( 'Install %s' ), $raw_title ) ) . '">(' . __( 'Install' ) . ')</a></li>';
-
-		$feed->__destruct();
-		unset( $feed );
-	}
-
-	echo '</ul>';
-}
-
-/**
  * Display file upload quota on dashboard.
  *
  * Runs on the {@see 'activity_box_end'} hook in wp_dashboard_right_now().
diff --git src/wp-admin/includes/deprecated.php src/wp-admin/includes/deprecated.php
index 2bf25d3336..76f7709ff2 100644
--- src/wp-admin/includes/deprecated.php
+++ src/wp-admin/includes/deprecated.php
@@ -1295,6 +1295,101 @@ function wp_dashboard_secondary() {}
 function wp_dashboard_secondary_control() {}
 
 /**
+ * Display plugins text for the WordPress news widget.
+ *
+ * @since 2.5.0
+ * @deprecated 4.8.0
+ *
+ * @param string $rss  The RSS feed URL.
+ * @param array  $args Array of arguments for this RSS feed.
+ */
+function wp_dashboard_plugins_output( $rss, $args = array() ) {
+	_deprecated_function( __FUNCTION__, '4.8.0' );
+
+	// Plugin feeds plus link to install them
+	$popular = fetch_feed( $args['url']['popular'] );
+
+	if ( false === $plugin_slugs = get_transient( 'plugin_slugs' ) ) {
+		$plugin_slugs = array_keys( get_plugins() );
+		set_transient( 'plugin_slugs', $plugin_slugs, DAY_IN_SECONDS );
+	}
+
+	echo '<ul>';
+
+	foreach ( array( $popular ) as $feed ) {
+		if ( is_wp_error( $feed ) || ! $feed->get_item_quantity() )
+			continue;
+
+		$items = $feed->get_items(0, 5);
+
+		// Pick a random, non-installed plugin
+		while ( true ) {
+			// Abort this foreach loop iteration if there's no plugins left of this type
+			if ( 0 == count($items) )
+				continue 2;
+
+			$item_key = array_rand($items);
+			$item = $items[$item_key];
+
+			list($link, $frag) = explode( '#', $item->get_link() );
+
+			$link = esc_url($link);
+			if ( preg_match( '|/([^/]+?)/?$|', $link, $matches ) )
+				$slug = $matches[1];
+			else {
+				unset( $items[$item_key] );
+				continue;
+			}
+
+			// Is this random plugin's slug already installed? If so, try again.
+			reset( $plugin_slugs );
+			foreach ( $plugin_slugs as $plugin_slug ) {
+				if ( $slug == substr( $plugin_slug, 0, strlen( $slug ) ) ) {
+					unset( $items[$item_key] );
+					continue 2;
+				}
+			}
+
+			// If we get to this point, then the random plugin isn't installed and we can stop the while().
+			break;
+		}
+
+		// Eliminate some common badly formed plugin descriptions
+		while ( ( null !== $item_key = array_rand($items) ) && false !== strpos( $items[$item_key]->get_description(), 'Plugin Name:' ) )
+			unset($items[$item_key]);
+
+		if ( !isset($items[$item_key]) )
+			continue;
+
+		$raw_title = $item->get_title();
+
+		$ilink = wp_nonce_url('plugin-install.php?tab=plugin-information&plugin=' . $slug, 'install-plugin_' . $slug) . '&amp;TB_iframe=true&amp;width=600&amp;height=800';
+		echo '<li class="dashboard-news-plugin"><span>' . __( 'Popular Plugin' ) . ':</span> ' . esc_html( $raw_title ) .
+			'&nbsp;<a href="' . $ilink . '" class="thickbox open-plugin-details-modal" aria-label="' .
+			/* translators: %s: plugin name */
+			esc_attr( sprintf( __( 'Install %s' ), $raw_title ) ) . '">(' . __( 'Install' ) . ')</a></li>';
+
+		$feed->__destruct();
+		unset( $feed );
+	}
+
+	echo '</ul>';
+}
+
+/**
+ * noop the Nearby WordPress Events feature plugin bootstrap process
+ *
+ * This function was never used in Core, but it's necessary to prevent the
+ * feature plugin from bootstrapping now that its functionality has been merged
+ * into Core.
+ *
+ * See https://plugins.trac.wordpress.org/browser/nearby-wp-events/tags/0.7/nearby-wordpress-events.php?marks=58-87#L55
+ *
+ * @deprecated 4.8.0
+ */
+function wp_get_nearby_events() {}
+
+/**
  * This was once used to move child posts to a new parent.
  *
  * @since 2.3.0
diff --git src/wp-admin/index.php src/wp-admin/index.php
index d2d7ec889d..e2dde48aaa 100644
--- src/wp-admin/index.php
+++ src/wp-admin/index.php
@@ -15,6 +15,8 @@ require_once(ABSPATH . 'wp-admin/includes/dashboard.php');
 wp_dashboard_setup();
 
 wp_enqueue_script( 'dashboard' );
+wp_localize_script( 'dashboard', 'communityEventsData', get_community_events_script_data() );
+
 if ( current_user_can( 'edit_theme_options' ) )
 	wp_enqueue_script( 'customize-loader' );
 if ( current_user_can( 'install_plugins' ) ) {
@@ -138,4 +140,6 @@ include( ABSPATH . 'wp-admin/admin-header.php' );
 </div><!-- wrap -->
 
 <?php
+wp_print_community_events_templates();
+
 require( ABSPATH . 'wp-admin/admin-footer.php' );
diff --git src/wp-admin/js/dashboard.js src/wp-admin/js/dashboard.js
index fa100dd16c..a6a5662871 100644
--- src/wp-admin/js/dashboard.js
+++ src/wp-admin/js/dashboard.js
@@ -187,3 +187,269 @@ jQuery(document).ready( function($) {
 	}
 
 } );
+
+
+wp.communityEvents = wp.communityEvents || {};
+
+jQuery( function( $ ) {
+	'use strict';
+
+	var app = wp.communityEvents = {
+		initialized: false,
+		model: null,
+
+		/**
+		 * Main entry point
+		 *
+		 * @since 4.8.0
+		 */
+		init: function() {
+			if ( app.initialized ) {
+				return;
+			}
+
+			var $container = $( '#community-events' );
+
+			/*
+			 * When JavaScript is disabled, the errors container is shown, so
+			 * that "This widget requires Javascript" message can be seen.
+			 *
+			 * When JS is enabled, the container is hidden at first, and then
+			 * revealed during the template rendering, if there actually are
+			 * errors to show.
+			 *
+			 * The display indicator switches from `hide-if-js` to `aria-hidden`
+			 * here in order to maintain consistency with all the other fields
+			 * that key off of `aria-hidden` to determine their visibility.
+			 * `aria-hidden` can't be used initially, because there would be no
+			 * way to set it to false when JavaScript is disabled, which would
+			 * prevent people from seeing the "This widget requires JavaScript"
+			 * message.
+			 */
+			$( '.community-events-errors' )
+				.attr( 'aria-hidden', true )
+				.removeClass( 'hide-if-js' );
+
+			$container.on( 'click', '.community-events-toggle-location', app.toggleLocationForm );
+			$container.on( 'click', '.community-events-cancel', app.toggleLocationForm );
+
+			$container.on( 'submit', '.community-events-form', function( event ) {
+				event.preventDefault();
+
+				app.getEvents( {
+					location: $( '#community-events-location' ).val()
+				});
+			});
+
+			if ( communityEventsData && communityEventsData.cache && communityEventsData.cache.location && communityEventsData.cache.events ) {
+				app.renderEventsTemplate( communityEventsData.cache, 'app' );
+			} else {
+				app.getEvents();
+			}
+
+			app.initialized = true;
+		},
+
+		/**
+		 * Toggle the visibility of the Edit Location form
+		 *
+		 * @since 4.8.0
+		 *
+		 * @param {event|string} action 'show' or 'hide' to specify a state;
+		 *                              Or an event object to flip between states
+		 */
+		toggleLocationForm: function( action ) {
+			var $toggleButton = $( '.community-events-toggle-location' ),
+			    $cancelButton = $( '.community-events-cancel' ),
+			    $form         = $( '.community-events-form' );
+
+			if ( 'object' === typeof action ) {
+				// Strict comparison doesn't work in this case.
+				action = 'true' == $toggleButton.attr( 'aria-expanded' ) ? 'hide' : 'show';
+			}
+
+			if ( 'hide' === action ) {
+				$toggleButton.attr( 'aria-expanded', false );
+				$cancelButton.attr( 'aria-expanded', false );
+				$form.attr( 'aria-hidden', true );
+			} else {
+				$toggleButton.attr( 'aria-expanded', true );
+				$cancelButton.attr( 'aria-expanded', true );
+				$form.attr( 'aria-hidden', false );
+			}
+		},
+
+		/**
+		 * Send Ajax request to fetch events for the widget
+		 *
+		 * @since 4.8.0
+		 *
+		 * @param {object} requestParams
+		 */
+		getEvents: function( requestParams ) {
+			var initiatedBy,
+			    app = this,
+			    $spinner = $( '.community-events-form' ).children( '.spinner' ),
+			    dashboardLoadPromise = wp.api.init( { 'versionString': 'wp/dashboard/v1/' } );
+
+			requestParams          = requestParams || {};
+			requestParams.timezone = window.Intl ? window.Intl.DateTimeFormat().resolvedOptions().timeZone : '';
+
+			initiatedBy = requestParams.location ? 'user' : 'app';
+
+			$spinner.addClass( 'is-active' );
+
+			dashboardLoadPromise.done( function() {
+				if ( ! app.model ) {
+					app.model = new wp.api.models.CommunityEventsMe();
+				}
+
+				$.when( app.model.fetch( { data: requestParams } ) )
+					.always( function() {
+						$spinner.removeClass( 'is-active' );
+					})
+
+					.done( function( response ) {
+						if ( 'no_location_available' === response.error ) {
+							if ( requestParams.location ) {
+								response.unknownCity = requestParams.location;
+							} else {
+								/*
+								 * No location was passed, which means that this was an automatic query
+								 * based on IP, locale, and timezone. Since the user didn't initiate it,
+								 * it should fail silently. Otherwise, the error could confuse and/or
+								 * annoy them.
+								 */
+								delete response.error;
+							}
+						}
+						app.renderEventsTemplate( response, initiatedBy );
+					})
+
+					.fail( function() {
+						app.renderEventsTemplate( {
+							'location' : false,
+							'error'    : true
+						}, initiatedBy );
+					});
+			});
+		},
+
+		/**
+		 * Render the template for the Events section of the Events & News widget
+		 *
+		 * @since 4.8.0
+		 *
+		 * @param {Object} templateParams The various parameters that will get passed to wp.template
+		 * @param {string} initiatedBy    'user' to indicate that this was triggered manually by the user;
+		 *                                'app' to indicate it was triggered automatically by the app itself.
+		 */
+		renderEventsTemplate: function( templateParams, initiatedBy ) {
+			var template,
+			    elementVisibility,
+			    l10nPlaceholder  = /%(?:\d\$)?s/g, // Match `%s`, `%1$s`, `%2$s`, etc.
+			    $locationMessage = $( '#community-events-location-message' ),
+			    $results         = $( '.community-events-results' );
+
+			/*
+			 * Hide all toggleable elements by default, to keep the logic simple.
+			 * Otherwise, each block below would have to turn hide everything that
+			 * could have been shown at an earlier point.
+			 *
+			 * The exception to that is that the .community-events container. It's hidden
+			 * when the page is first loaded, because the content isn't ready yet,
+			 * but once we've reached this point, it should always be shown.
+			 */
+			elementVisibility = {
+				'.community-events'                  : true,
+				'.community-events-loading'          : false,
+				'.community-events-errors'           : false,
+				'.community-events-error-occurred'   : false,
+				'.community-events-could-not-locate' : false,
+				'#community-events-location-message' : false,
+				'.community-events-toggle-location'  : false,
+				'.community-events-results'          : false
+			};
+
+			/*
+			 * Determine which templates should be rendered and which elements
+			 * should be displayed
+			 */
+			if ( templateParams.location && templateParams.location.description ) {
+				template = wp.template( 'community-events-attend-event-near' );
+				$locationMessage.html( template( templateParams ) );
+
+				if ( templateParams.events.length ) {
+					template = wp.template( 'community-events-event-list' );
+					$results.html( template( templateParams ) );
+				} else {
+					template = wp.template( 'community-events-no-upcoming-events' );
+					$results.html( template( templateParams ) );
+				}
+				wp.a11y.speak( communityEventsData.l10n.city_updated.replace( l10nPlaceholder, templateParams.location.description ) );
+
+				elementVisibility['#community-events-location-message'] = true;
+				elementVisibility['.community-events-toggle-location']  = true;
+				elementVisibility['.community-events-results']          = true;
+
+			} else if ( templateParams.unknownCity ) {
+				template = wp.template( 'community-events-could-not-locate' );
+				$( '.community-events-could-not-locate' ).html( template( templateParams ) );
+				wp.a11y.speak( communityEventsData.l10n.could_not_locate_city.replace( l10nPlaceholder, templateParams.unknownCity ) );
+
+				elementVisibility['.community-events-errors']           = true;
+				elementVisibility['.community-events-could-not-locate'] = true;
+
+			} else if ( templateParams.error && 'user' === initiatedBy ) {
+				/*
+				 * Errors messages are only shown for requests that were initiated
+				 * by the user, not for ones that were initiated by the app itself.
+				 * Showing error messages for an event that user isn't aware of
+				 * could be confusing or unnecessarily distracting.
+				 */
+				wp.a11y.speak( communityEventsData.l10n.error_occurred_please_try_again );
+
+				elementVisibility['.community-events-errors']         = true;
+				elementVisibility['.community-events-error-occurred'] = true;
+
+			} else {
+				$locationMessage.text( communityEventsData.l10n.enter_closest_city );
+
+				elementVisibility['#community-events-location-message'] = true;
+				elementVisibility['.community-events-toggle-location']  = true;
+			}
+
+			// Set the visibility of toggleable elements.
+			_.each( elementVisibility, function( isVisible, element ) {
+				$( element ).attr( 'aria-hidden', ! isVisible );
+			});
+
+			$( '.community-events-toggle-location' ).attr( 'aria-expanded', elementVisibility['.community-events-toggle-location'] );
+
+			/*
+			 * During the initial page load, the location form should be hidden
+			 * by default if the user has saved a valid location during a previous
+			 * session. It's safe to assume that they want to continue using that
+			 * location, and displaying the form would unnecessarily clutter the
+			 * widget.
+			 */
+			if ( 'app' === initiatedBy && templateParams.location.description ) {
+				app.toggleLocationForm( 'hide' );
+			} else {
+				app.toggleLocationForm( 'show' );
+			}
+		}
+	};
+
+	if ( $( '#dashboard_primary' ).is( ':visible' ) ) {
+		app.init();
+	} else {
+		$( document ).on( 'postbox-toggled', function( event, postbox ) {
+			var $postbox = $( postbox );
+
+			if ( 'dashboard_primary' === $postbox.attr( 'id' ) && $postbox.is( ':visible' ) ) {
+				app.init();
+			}
+		});
+	}
+});
diff --git src/wp-admin/network/index.php src/wp-admin/network/index.php
index 81ededbe37..e3a2c03ad7 100644
--- src/wp-admin/network/index.php
+++ src/wp-admin/network/index.php
@@ -54,6 +54,7 @@ get_current_screen()->set_help_sidebar(
 wp_dashboard_setup();
 
 wp_enqueue_script( 'dashboard' );
+wp_localize_script( 'dashboard', 'communityEventsData', get_community_events_script_data() );
 wp_enqueue_script( 'plugin-install' );
 add_thickbox();
 
@@ -73,4 +74,6 @@ require_once( ABSPATH . 'wp-admin/admin-header.php' );
 
 </div><!-- wrap -->
 
-<?php include( ABSPATH . 'wp-admin/admin-footer.php' ); ?>
+<?php
+wp_print_community_events_templates();
+include( ABSPATH . 'wp-admin/admin-footer.php' );
diff --git src/wp-includes/rest-api.php src/wp-includes/rest-api.php
index e6cdc3a959..8da64fdc5f 100644
--- src/wp-includes/rest-api.php
+++ src/wp-includes/rest-api.php
@@ -237,6 +237,60 @@ function create_initial_rest_routes() {
 	// Settings.
 	$controller = new WP_REST_Settings_Controller;
 	$controller->register_routes();
+
+	// Dashboard.
+	register_rest_route(
+		'wp/dashboard/v1',
+		'/community-events/me',
+		array(
+			'methods'             => WP_REST_Server::READABLE,
+			'callback'            => 'rest_get_community_events',
+			'permission_callback' => 'rest_get_community_events_permissions_check',
+
+			'args' => array(
+				'location' => array( 'validate_callback' => 'sanitize_text_field' ),
+				'timezone' => array( 'validate_callback' => 'sanitize_text_field' ),
+			),
+		)
+	);
+}
+
+/**
+ * Checks if a given request has access to get community events.
+ *
+ * @return bool
+ */
+function rest_get_community_events_permissions_check() {
+	return current_user_can( 'read' );
+}
+
+/**
+ * Retrieves nearby events.
+ *
+ * @since 4.8.0
+ *
+ * @param  WP_REST_Request $request Full details about the request.
+ * @return array|WP_Error           WP_REST_Response on success, or WP_Error object on failure.
+ */
+function rest_get_community_events( $request ) {
+	$user_id = get_current_user_id();
+
+	if ( empty( $user_id ) ) {
+		return new WP_Error( 'rest_not_logged_in', __( 'You are not currently logged in.' ), array( 'status' => 401 ) );
+	}
+
+	require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
+
+	$saved_location = get_user_option( 'community-events-location', $user_id );
+	$events_client  = new WP_Community_Events( $user_id, $saved_location );
+	$events         = $events_client->get_events( $request->get_param('location'), $request->get_param('timezone') );
+
+	// Store the location network-wide, so the user doesn't have to set it on each site.
+	if ( ! is_wp_error( $events ) && isset( $events['location'] ) ) {
+		update_user_option( $user_id, 'community-events-location', $events['location'], true );
+	}
+
+	return $events;
 }
 
 /**
diff --git src/wp-includes/script-loader.php src/wp-includes/script-loader.php
index def438c260..8bcfa03d93 100644
--- src/wp-includes/script-loader.php
+++ src/wp-includes/script-loader.php
@@ -724,7 +724,7 @@ function wp_default_scripts( &$scripts ) {
 			'current' => __( 'Current Color' ),
 		) );
 
-		$scripts->add( 'dashboard', "/wp-admin/js/dashboard$suffix.js", array( 'jquery', 'admin-comments', 'postbox' ), false, 1 );
+		$scripts->add( 'dashboard', "/wp-admin/js/dashboard$suffix.js", array( 'jquery', 'admin-comments', 'postbox', 'wp-api', 'wp-util', 'wp-a11y' ), false, 1 );
 
 		$scripts->add( 'list-revisions', "/wp-includes/js/wp-list-revisions$suffix.js" );
 
diff --git tests/phpunit/tests/admin/includesCommunityEvents.php tests/phpunit/tests/admin/includesCommunityEvents.php
new file mode 100644
index 0000000000..f0a0a7eafe
--- /dev/null
+++ tests/phpunit/tests/admin/includesCommunityEvents.php
@@ -0,0 +1,268 @@
+<?php
+/**
+ * Unit tests for methods in WP_Community_Events.
+ *
+ * @package WordPress
+ * @subpackage UnitTests
+ * @since 4.8.0
+ */
+
+/**
+ * Class Test_WP_Community_Events.
+ *
+ * @group admin
+ * @group community-events
+ *
+ * @since 4.8.0
+ */
+class Test_WP_Community_Events extends WP_UnitTestCase {
+	/**
+	 * An instance of the class to test.
+	 *
+	 * @access private
+	 * @since 4.8.0
+	 *
+	 * @var WP_Community_Events
+	 */
+	private $instance;
+
+	/**
+	 * Perform for every test.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 */
+	public function setUp() {
+		parent::setUp();
+
+		require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
+
+		$this->instance = new WP_Community_Events( 1, $this->get_user_location() );
+	}
+
+	/**
+	 * Simulate a stored user location.
+	 *
+	 * @access private
+	 * @since 4.8.0
+	 *
+	 * @return array The mock location.
+	 */
+	private function get_user_location() {
+		return array(
+			'description' => 'San Francisco',
+			'latitude'    => '37.7749300',
+			'longitude'   => '-122.4194200',
+			'country'     => 'US',
+		);
+	}
+
+	/**
+	 * Test: `get_events()` should return an instance of WP_Error if the response code is not 200.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 */
+	public function test_get_events_bad_response_code() {
+		add_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
+
+		$this->assertWPError( $this->instance->get_events() );
+
+		remove_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
+	}
+
+	/**
+	 * Test: The response body should not be cached if the response code is not 200.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 */
+	public function test_get_cached_events_bad_response_code() {
+		add_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
+
+		$this->instance->get_events();
+
+		$this->assertFalse( $this->instance->get_cached_events() );
+
+		remove_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
+	}
+
+	/**
+	 * Simulate an HTTP response with a non-200 response code.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 *
+	 * @return array A mock response with a 404 HTTP status code
+	 */
+	public function _http_request_bad_response_code() {
+		return array(
+			'headers'  => '',
+			'body'     => '',
+			'response' => array(
+				'code' => 404,
+			),
+			'cookies'  => '',
+			'filename' => '',
+		);
+	}
+
+	/**
+	 * Test: `get_events()` should return an instance of WP_Error if the response body does not have
+	 * the required properties.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 */
+	public function test_get_events_invalid_response() {
+		add_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
+
+		$this->assertWPError( $this->instance->get_events() );
+
+		remove_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
+	}
+
+	/**
+	 * Test: The response body should not be cached if it does not have the required properties.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 */
+	public function test_get_cached_events_invalid_response() {
+		add_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
+
+		$this->instance->get_events();
+
+		$this->assertFalse( $this->instance->get_cached_events() );
+
+		remove_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
+	}
+
+	/**
+	 * Simulate an HTTP response with a body that does not have the required properties.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 *
+	 * @return array A mock response that's missing required properties.
+	 */
+	public function _http_request_invalid_response() {
+		return array(
+			'headers'  => '',
+			'body'     => wp_json_encode( array() ),
+			'response' => array(
+				'code' => 200,
+			),
+			'cookies'  => '',
+			'filename' => '',
+		);
+	}
+
+	/**
+	 * Test: With a valid response, `get_events()` should return an associated array containing a location array and
+	 * an events array with individual events that have formatted time and date.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 */
+	public function test_get_events_valid_response() {
+		add_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
+
+		$response = $this->instance->get_events();
+
+		$this->assertNotWPError( $response );
+		$this->assertEqualSetsWithIndex( $this->get_user_location(), $response['location'] );
+		$this->assertEquals( date( 'l, M j, Y', strtotime( 'next Sunday 1pm' ) ), $response['events'][0]['formatted_date'] );
+		$this->assertEquals( '1:00 pm', $response['events'][0]['formatted_time'] );
+
+		remove_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
+	}
+
+	/**
+	 * Test: `get_cached_events()` should return the same data as `get_events()`, including formatted time
+	 * and date values for each event.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 */
+	public function test_get_cached_events_valid_response() {
+		add_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
+
+		$this->instance->get_events();
+
+		$cached_events = $this->instance->get_cached_events();
+
+		$this->assertNotWPError( $cached_events );
+		$this->assertEqualSetsWithIndex( $this->get_user_location(), $cached_events['location'] );
+		$this->assertEquals( date( 'l, M j, Y', strtotime( 'next Sunday 1pm' ) ), $cached_events['events'][0]['formatted_date'] );
+		$this->assertEquals( '1:00 pm', $cached_events['events'][0]['formatted_time'] );
+
+		remove_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
+	}
+
+	/**
+	 * Simulate an HTTP response with valid location and event data.
+	 *
+	 * @access public
+	 * @since 4.8.0
+	 *
+	 * @return array A mock HTTP response with valid data.
+	 */
+	public function _http_request_valid_response() {
+		return array(
+			'headers'  => '',
+			'body'     => wp_json_encode( array(
+				'location' => $this->get_user_location(),
+				'events'   => array(
+					array(
+						'type'           => 'meetup',
+						'title'          => 'Flexbox + CSS Grid: Magic for Responsive Layouts',
+						'url'            => 'https://www.meetup.com/Eastbay-WordPress-Meetup/events/236031233/',
+						'meetup'         => 'The East Bay WordPress Meetup Group',
+						'meetup_url'     => 'https://www.meetup.com/Eastbay-WordPress-Meetup/',
+						'date'           => date( 'Y-m-d H:i:s', strtotime( 'next Sunday 1pm' ) ),
+						'location'       => array(
+							'location'  => 'Oakland, CA, USA',
+							'country'   => 'us',
+							'latitude'  => 37.808453,
+							'longitude' => -122.26593,
+						),
+					),
+					array(
+						'type'           => 'meetup',
+						'title'          => 'Part 3- Site Maintenance - Tools to Make It Easy',
+						'url'            => 'https://www.meetup.com/Wordpress-Bay-Area-CA-Foothills/events/237706839/',
+						'meetup'         => 'WordPress Bay Area Foothills Group',
+						'meetup_url'     => 'https://www.meetup.com/Wordpress-Bay-Area-CA-Foothills/',
+						'date'           => date( 'Y-m-d H:i:s', strtotime( 'next Wednesday 1:30pm' ) ),
+						'location'       => array(
+							'location'  => 'Milpitas, CA, USA',
+							'country'   => 'us',
+							'latitude'  => 37.432813,
+							'longitude' => -121.907095,
+						),
+					),
+					array(
+						'type'           => 'wordcamp',
+						'title'          => 'WordCamp Kansas City',
+						'url'            => 'https://2017.kansascity.wordcamp.org',
+						'meetup'         => null,
+						'meetup_url'     => null,
+						'date'           => date( 'Y-m-d H:i:s', strtotime( 'next Saturday' ) ),
+						'location'       => array(
+							'location'  => 'Kansas City, MO',
+							'country'   => 'US',
+							'latitude'  => 39.0392325,
+							'longitude' => -94.577076,
+						),
+					),
+				),
+			) ),
+			'response' => array(
+				'code' => 200,
+			),
+			'cookies'  => '',
+			'filename' => '',
+		);
+	}
+}
